billing_service_unified_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "testing"
  6. "time"
  7. "github.com/Wei-Shaw/sub2api/internal/config"
  8. "github.com/stretchr/testify/require"
  9. )
  10. // ---------------------------------------------------------------------------
  11. // CalculateCostUnified
  12. // ---------------------------------------------------------------------------
  13. func TestCalculateCostUnified_NilResolver_FallsBackToOldPath(t *testing.T) {
  14. svc := newTestBillingService()
  15. tokens := UsageTokens{InputTokens: 1000, OutputTokens: 500}
  16. input := CostInput{
  17. Model: "claude-sonnet-4",
  18. Tokens: tokens,
  19. RateMultiplier: 1.0,
  20. Resolver: nil, // no resolver
  21. }
  22. cost, err := svc.CalculateCostUnified(input)
  23. require.NoError(t, err)
  24. // Should match the old-path result exactly
  25. expected, err := svc.calculateCostInternal("claude-sonnet-4", tokens, 1.0, "", nil)
  26. require.NoError(t, err)
  27. require.InDelta(t, expected.TotalCost, cost.TotalCost, 1e-10)
  28. require.InDelta(t, expected.ActualCost, cost.ActualCost, 1e-10)
  29. // BillingMode is NOT set by old path through CalculateCostUnified (resolver == nil)
  30. require.Empty(t, cost.BillingMode)
  31. }
  32. func TestCalculateCostUnified_TokenMode(t *testing.T) {
  33. bs := newTestBillingService()
  34. resolver := NewModelPricingResolver(nil, bs)
  35. tokens := UsageTokens{InputTokens: 1000, OutputTokens: 500}
  36. input := CostInput{
  37. Ctx: context.Background(),
  38. Model: "claude-sonnet-4",
  39. Tokens: tokens,
  40. RateMultiplier: 1.5,
  41. Resolver: resolver,
  42. }
  43. cost, err := bs.CalculateCostUnified(input)
  44. require.NoError(t, err)
  45. require.NotNil(t, cost)
  46. // Verify token billing: Input: 1000*3e-6=0.003, Output: 500*15e-6=0.0075
  47. expectedTotal := 1000*3e-6 + 500*15e-6
  48. require.InDelta(t, expectedTotal, cost.TotalCost, 1e-10)
  49. require.InDelta(t, expectedTotal*1.5, cost.ActualCost, 1e-10)
  50. require.Equal(t, string(BillingModeToken), cost.BillingMode)
  51. }
  52. func TestCalculateCostUnified_TokenModeAppliesRateMultiplierToImageTokens(t *testing.T) {
  53. bs := newTestBillingService()
  54. resolver := NewModelPricingResolver(nil, bs)
  55. tokens := UsageTokens{InputTokens: 1000, OutputTokens: 600, ImageOutputTokens: 100}
  56. cost, err := bs.CalculateCostUnified(CostInput{
  57. Ctx: context.Background(),
  58. Model: "claude-sonnet-4",
  59. Tokens: tokens,
  60. RateMultiplier: 3.0,
  61. Resolver: resolver,
  62. })
  63. require.NoError(t, err)
  64. textInput := 1000 * 3e-6
  65. textOutput := 500 * 15e-6
  66. imageOutput := 100 * 15e-6
  67. require.InDelta(t, textInput+textOutput+imageOutput, cost.TotalCost, 1e-10)
  68. require.InDelta(t, (textInput+textOutput+imageOutput)*3.0, cost.ActualCost, 1e-10)
  69. require.InDelta(t, imageOutput, cost.ImageOutputCost, 1e-10)
  70. }
  71. func TestCalculateCostUnified_PerRequestMode(t *testing.T) {
  72. // Set up a ChannelService with a per-request pricing channel
  73. cs := newTestChannelServiceWithCache(t, &channelCache{
  74. pricingByGroupModel: map[channelModelKey]*ChannelModelPricing{
  75. {groupID: 1, model: "claude-sonnet-4"}: {
  76. BillingMode: BillingModePerRequest,
  77. PerRequestPrice: testPtrFloat64(0.05),
  78. },
  79. },
  80. channelByGroupID: map[int64]*Channel{
  81. 1: {ID: 1, Status: StatusActive},
  82. },
  83. groupPlatform: map[int64]string{1: ""},
  84. wildcardByGroupPlatform: map[channelGroupPlatformKey][]*wildcardPricingEntry{},
  85. mappingByGroupModel: map[channelModelKey]string{},
  86. wildcardMappingByGP: map[channelGroupPlatformKey][]*wildcardMappingEntry{},
  87. byID: map[int64]*Channel{},
  88. })
  89. bs := newTestBillingService()
  90. resolver := NewModelPricingResolver(cs, bs)
  91. groupID := int64(1)
  92. input := CostInput{
  93. Ctx: context.Background(),
  94. Model: "claude-sonnet-4",
  95. GroupID: &groupID,
  96. Tokens: UsageTokens{InputTokens: 100, OutputTokens: 50},
  97. RequestCount: 3,
  98. RateMultiplier: 2.0,
  99. Resolver: resolver,
  100. }
  101. cost, err := bs.CalculateCostUnified(input)
  102. require.NoError(t, err)
  103. require.NotNil(t, cost)
  104. // 3 requests * $0.05 = $0.15
  105. require.InDelta(t, 0.15, cost.TotalCost, 1e-10)
  106. // ActualCost = 0.15 * 2.0 = 0.30
  107. require.InDelta(t, 0.30, cost.ActualCost, 1e-10)
  108. require.Equal(t, string(BillingModePerRequest), cost.BillingMode)
  109. }
  110. func TestCalculateCostUnified_ImageMode(t *testing.T) {
  111. cs := newTestChannelServiceWithCache(t, &channelCache{
  112. pricingByGroupModel: map[channelModelKey]*ChannelModelPricing{
  113. {groupID: 2, model: "gemini-image"}: {
  114. BillingMode: BillingModeImage,
  115. PerRequestPrice: testPtrFloat64(0.10),
  116. },
  117. },
  118. channelByGroupID: map[int64]*Channel{
  119. 2: {ID: 2, Status: StatusActive},
  120. },
  121. groupPlatform: map[int64]string{2: ""},
  122. wildcardByGroupPlatform: map[channelGroupPlatformKey][]*wildcardPricingEntry{},
  123. mappingByGroupModel: map[channelModelKey]string{},
  124. wildcardMappingByGP: map[channelGroupPlatformKey][]*wildcardMappingEntry{},
  125. byID: map[int64]*Channel{},
  126. })
  127. bs := &BillingService{
  128. cfg: &config.Config{},
  129. fallbackPrices: map[string]*ModelPricing{},
  130. }
  131. resolver := NewModelPricingResolver(cs, bs)
  132. groupID := int64(2)
  133. input := CostInput{
  134. Ctx: context.Background(),
  135. Model: "gemini-image",
  136. GroupID: &groupID,
  137. Tokens: UsageTokens{},
  138. RequestCount: 2,
  139. RateMultiplier: 1.0,
  140. Resolver: resolver,
  141. }
  142. cost, err := bs.CalculateCostUnified(input)
  143. require.NoError(t, err)
  144. require.NotNil(t, cost)
  145. // 2 * $0.10 = $0.20
  146. require.InDelta(t, 0.20, cost.TotalCost, 1e-10)
  147. require.InDelta(t, 0.20, cost.ActualCost, 1e-10)
  148. require.Equal(t, string(BillingModeImage), cost.BillingMode)
  149. }
  150. func channelTimeResolvedForTest(base *ModelPricing, intervals []PricingInterval) *ResolvedPricing {
  151. return &ResolvedPricing{
  152. Mode: BillingModeToken,
  153. BasePricing: base,
  154. Intervals: intervals,
  155. Source: PricingSourceChannel,
  156. channelPricing: &ChannelModelPricing{
  157. BillingMode: BillingModeToken,
  158. TimePricing: &ChannelTimePricing{
  159. Timezone: "Asia/Shanghai",
  160. Periods: []ChannelTimePricingPeriod{{
  161. StartTime: "09:00",
  162. EndTime: "12:00",
  163. Multiplier: 2,
  164. }},
  165. },
  166. },
  167. longContextPricingEnabled: true,
  168. }
  169. }
  170. func TestCalculateCostUnified_ChannelTimePricingScalesBaseAndActualCost(t *testing.T) {
  171. billing := NewBillingService(&config.Config{}, nil)
  172. resolved := channelTimeResolvedForTest(&ModelPricing{InputPricePerToken: 0.001}, nil)
  173. cost, err := billing.CalculateCostUnified(CostInput{
  174. Ctx: context.Background(),
  175. Model: "model",
  176. Tokens: UsageTokens{InputTokens: 1000},
  177. RateMultiplier: 0.8,
  178. Resolver: &ModelPricingResolver{},
  179. Resolved: resolved,
  180. PricingAt: time.Date(2026, 8, 17, 1, 0, 0, 0, time.UTC),
  181. })
  182. require.NoError(t, err)
  183. require.InDelta(t, 2.0, cost.InputCost, 1e-12)
  184. require.InDelta(t, 2.0, cost.TotalCost, 1e-12)
  185. require.InDelta(t, 1.6, cost.ActualCost, 1e-12)
  186. }
  187. func TestCalculateCostUnified_ChannelTimePricingScalesMatchingInterval(t *testing.T) {
  188. intervalInputPrice := 0.003
  189. resolved := channelTimeResolvedForTest(
  190. &ModelPricing{InputPricePerToken: 0.001},
  191. []PricingInterval{{MinTokens: 0, InputPrice: &intervalInputPrice}},
  192. )
  193. billing := NewBillingService(&config.Config{}, nil)
  194. cost, err := billing.CalculateCostUnified(CostInput{
  195. Ctx: context.Background(),
  196. Model: "model",
  197. Tokens: UsageTokens{InputTokens: 1000},
  198. Resolver: &ModelPricingResolver{},
  199. Resolved: resolved,
  200. PricingAt: time.Date(2026, 8, 17, 1, 0, 0, 0, time.UTC),
  201. })
  202. require.NoError(t, err)
  203. require.InDelta(t, 6.0, cost.InputCost, 1e-12)
  204. require.InDelta(t, 6.0, cost.TotalCost, 1e-12)
  205. }
  206. func TestCalculateCostUnified_ChannelTimePricingScalesBaseOnUnmatchedInterval(t *testing.T) {
  207. intervalInputPrice := 0.003
  208. resolved := channelTimeResolvedForTest(
  209. &ModelPricing{InputPricePerToken: 0.001},
  210. []PricingInterval{{MinTokens: 2000, InputPrice: &intervalInputPrice}},
  211. )
  212. billing := NewBillingService(&config.Config{}, nil)
  213. cost, err := billing.CalculateCostUnified(CostInput{
  214. Ctx: context.Background(),
  215. Model: "model",
  216. Tokens: UsageTokens{InputTokens: 1000},
  217. Resolver: &ModelPricingResolver{},
  218. Resolved: resolved,
  219. PricingAt: time.Date(2026, 8, 17, 1, 0, 0, 0, time.UTC),
  220. })
  221. require.NoError(t, err)
  222. require.InDelta(t, 2.0, cost.InputCost, 1e-12)
  223. require.InDelta(t, 2.0, cost.TotalCost, 1e-12)
  224. }
  225. func TestCalculateCostUnified_ChannelTimePricingDoesNotApplyToGroupPricing(t *testing.T) {
  226. resolved := channelTimeResolvedForTest(&ModelPricing{InputPricePerToken: 0.001}, nil)
  227. resolved.Source = PricingSourceGroup
  228. billing := NewBillingService(&config.Config{}, nil)
  229. cost, err := billing.CalculateCostUnified(CostInput{
  230. Ctx: context.Background(),
  231. Model: "model",
  232. Tokens: UsageTokens{InputTokens: 1000},
  233. Resolver: &ModelPricingResolver{},
  234. Resolved: resolved,
  235. PricingAt: time.Date(2026, 8, 17, 1, 0, 0, 0, time.UTC),
  236. })
  237. require.NoError(t, err)
  238. require.InDelta(t, 1.0, cost.TotalCost, 1e-12)
  239. }
  240. func TestCalculateCostUnified_ChannelTimePricingDoesNotApplyOutsideMatchingTime(t *testing.T) {
  241. resolved := channelTimeResolvedForTest(&ModelPricing{InputPricePerToken: 0.001}, nil)
  242. billing := NewBillingService(&config.Config{}, nil)
  243. for _, pricingAt := range []time.Time{
  244. time.Time{},
  245. time.Date(2026, 8, 17, 5, 0, 0, 0, time.UTC),
  246. } {
  247. cost, err := billing.CalculateCostUnified(CostInput{
  248. Ctx: context.Background(),
  249. Model: "model",
  250. Tokens: UsageTokens{InputTokens: 1000},
  251. Resolver: &ModelPricingResolver{},
  252. Resolved: resolved,
  253. PricingAt: pricingAt,
  254. })
  255. require.NoError(t, err)
  256. require.InDelta(t, 1.0, cost.TotalCost, 1e-12)
  257. }
  258. }
  259. func TestApplyCostBreakdownMultiplierScalesAllMonetaryFields(t *testing.T) {
  260. cost := &CostBreakdown{
  261. InputCost: 1,
  262. ImageInputCost: 2,
  263. OutputCost: 3,
  264. ImageOutputCost: 4,
  265. CacheCreationCost: 5,
  266. CacheReadCost: 6,
  267. TotalCost: 21,
  268. ActualCost: 42,
  269. BillingMode: string(BillingModeToken),
  270. LongContextBillingApplied: true,
  271. }
  272. applyCostBreakdownMultiplier(cost, 1.5)
  273. require.InDelta(t, 1.5, cost.InputCost, 1e-12)
  274. require.InDelta(t, 3.0, cost.ImageInputCost, 1e-12)
  275. require.InDelta(t, 4.5, cost.OutputCost, 1e-12)
  276. require.InDelta(t, 6.0, cost.ImageOutputCost, 1e-12)
  277. require.InDelta(t, 7.5, cost.CacheCreationCost, 1e-12)
  278. require.InDelta(t, 9.0, cost.CacheReadCost, 1e-12)
  279. require.InDelta(t, 31.5, cost.TotalCost, 1e-12)
  280. require.InDelta(t, 63.0, cost.ActualCost, 1e-12)
  281. require.Equal(t, string(BillingModeToken), cost.BillingMode)
  282. require.True(t, cost.LongContextBillingApplied)
  283. }
  284. // TestCalculateCostUnified_RateMultiplierZeroProducesZero 锁定新行为:
  285. // 保存时强制 > 0;若 0 仍泄漏到计费层,按 0 计费(而非历史上的 1.0)。
  286. func TestCalculateCostUnified_RateMultiplierZeroProducesZero(t *testing.T) {
  287. bs := newTestBillingService()
  288. resolver := NewModelPricingResolver(nil, bs)
  289. tokens := UsageTokens{InputTokens: 1000, OutputTokens: 500}
  290. cost, err := bs.CalculateCostUnified(CostInput{
  291. Ctx: context.Background(),
  292. Model: "claude-sonnet-4",
  293. Tokens: tokens,
  294. RateMultiplier: 0,
  295. Resolver: resolver,
  296. })
  297. require.NoError(t, err)
  298. require.Greater(t, cost.TotalCost, 0.0)
  299. require.InDelta(t, 0.0, cost.ActualCost, 1e-10)
  300. }
  301. // TestCalculateCostUnified_NegativeRateMultiplierClampedToZero 锁定新行为:
  302. // 负数倍率按 0 计费,避免历史的 <=0 → 1.0 把配置异常静默按标准价扣费。
  303. func TestCalculateCostUnified_NegativeRateMultiplierClampedToZero(t *testing.T) {
  304. bs := newTestBillingService()
  305. resolver := NewModelPricingResolver(nil, bs)
  306. tokens := UsageTokens{InputTokens: 1000}
  307. cost, err := bs.CalculateCostUnified(CostInput{
  308. Ctx: context.Background(),
  309. Model: "claude-sonnet-4",
  310. Tokens: tokens,
  311. RateMultiplier: -5.0,
  312. Resolver: resolver,
  313. })
  314. require.NoError(t, err)
  315. require.Greater(t, cost.TotalCost, 0.0)
  316. require.InDelta(t, 0.0, cost.ActualCost, 1e-10)
  317. }
  318. func TestCalculateCostUnified_BillingModeFieldFilled(t *testing.T) {
  319. bs := newTestBillingService()
  320. resolver := NewModelPricingResolver(nil, bs)
  321. cost, err := bs.CalculateCostUnified(CostInput{
  322. Ctx: context.Background(),
  323. Model: "claude-sonnet-4",
  324. Tokens: UsageTokens{InputTokens: 100},
  325. RateMultiplier: 1.0,
  326. Resolver: resolver,
  327. })
  328. require.NoError(t, err)
  329. require.Equal(t, "token", cost.BillingMode)
  330. }
  331. func TestCalculateCostUnified_UsesPreResolvedPricing(t *testing.T) {
  332. bs := newTestBillingService()
  333. resolver := NewModelPricingResolver(nil, bs)
  334. // Pre-resolve with per_request mode to verify it's used instead of re-resolving
  335. preResolved := &ResolvedPricing{
  336. Mode: BillingModePerRequest,
  337. DefaultPerRequestPrice: 0.07,
  338. }
  339. cost, err := bs.CalculateCostUnified(CostInput{
  340. Ctx: context.Background(),
  341. Model: "claude-sonnet-4",
  342. Tokens: UsageTokens{InputTokens: 100},
  343. RequestCount: 2,
  344. RateMultiplier: 1.0,
  345. Resolver: resolver,
  346. Resolved: preResolved,
  347. })
  348. require.NoError(t, err)
  349. require.NotNil(t, cost)
  350. // 2 * $0.07 = $0.14
  351. require.InDelta(t, 0.14, cost.TotalCost, 1e-10)
  352. require.Equal(t, string(BillingModePerRequest), cost.BillingMode)
  353. }
  354. // ---------------------------------------------------------------------------
  355. // helpers
  356. // ---------------------------------------------------------------------------
  357. // newTestChannelServiceWithCache creates a ChannelService with a pre-populated
  358. // cache snapshot, bypassing the repository layer entirely.
  359. func newTestChannelServiceWithCache(t *testing.T, cache *channelCache) *ChannelService {
  360. t.Helper()
  361. cs := &ChannelService{}
  362. cache.loadedAt = time.Now()
  363. cs.cache.Store(cache)
  364. return cs
  365. }