account_stats_pricing_test.go 32 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057
  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. // matchAccountStatsRule
  12. // ---------------------------------------------------------------------------
  13. func TestMatchAccountStatsRule_BothEmpty_NoMatch(t *testing.T) {
  14. rule := &AccountStatsPricingRule{}
  15. require.False(t, matchAccountStatsRule(rule, 1, 10))
  16. }
  17. func TestMatchAccountStatsRule_AccountIDMatch(t *testing.T) {
  18. rule := &AccountStatsPricingRule{AccountIDs: []int64{1, 2, 3}}
  19. require.True(t, matchAccountStatsRule(rule, 2, 999))
  20. }
  21. func TestMatchAccountStatsRule_GroupIDMatch(t *testing.T) {
  22. rule := &AccountStatsPricingRule{GroupIDs: []int64{10, 20}}
  23. require.True(t, matchAccountStatsRule(rule, 999, 20))
  24. }
  25. func TestMatchAccountStatsRule_BothConfigured_AccountMatch(t *testing.T) {
  26. rule := &AccountStatsPricingRule{
  27. AccountIDs: []int64{1, 2},
  28. GroupIDs: []int64{10, 20},
  29. }
  30. require.True(t, matchAccountStatsRule(rule, 2, 999))
  31. }
  32. func TestMatchAccountStatsRule_BothConfigured_GroupMatch(t *testing.T) {
  33. rule := &AccountStatsPricingRule{
  34. AccountIDs: []int64{1, 2},
  35. GroupIDs: []int64{10, 20},
  36. }
  37. require.True(t, matchAccountStatsRule(rule, 999, 10))
  38. }
  39. func TestMatchAccountStatsRule_BothConfigured_NeitherMatch(t *testing.T) {
  40. rule := &AccountStatsPricingRule{
  41. AccountIDs: []int64{1, 2},
  42. GroupIDs: []int64{10, 20},
  43. }
  44. require.False(t, matchAccountStatsRule(rule, 999, 999))
  45. }
  46. // ---------------------------------------------------------------------------
  47. // findPricingForModel
  48. // ---------------------------------------------------------------------------
  49. func TestFindPricingForModel(t *testing.T) {
  50. exactPricing := ChannelModelPricing{
  51. ID: 1,
  52. Models: []string{"claude-opus-4"},
  53. }
  54. wildcardPricing := ChannelModelPricing{
  55. ID: 2,
  56. Models: []string{"claude-*"},
  57. }
  58. platformPricing := ChannelModelPricing{
  59. ID: 3,
  60. Platform: "openai",
  61. Models: []string{"gpt-4o"},
  62. }
  63. emptyPlatformPricing := ChannelModelPricing{
  64. ID: 4,
  65. Models: []string{"gemini-2.5-pro"},
  66. }
  67. tests := []struct {
  68. name string
  69. list []ChannelModelPricing
  70. platform string
  71. model string
  72. wantID int64
  73. wantNil bool
  74. }{
  75. {
  76. name: "exact match",
  77. list: []ChannelModelPricing{exactPricing},
  78. platform: "anthropic",
  79. model: "claude-opus-4",
  80. wantID: 1,
  81. },
  82. {
  83. name: "exact match case insensitive",
  84. list: []ChannelModelPricing{{ID: 5, Models: []string{"Claude-Opus-4"}}},
  85. platform: "",
  86. model: "claude-opus-4",
  87. wantID: 5,
  88. },
  89. {
  90. name: "wildcard match",
  91. list: []ChannelModelPricing{wildcardPricing},
  92. platform: "anthropic",
  93. model: "claude-opus-4",
  94. wantID: 2,
  95. },
  96. {
  97. name: "exact match takes priority over wildcard",
  98. list: []ChannelModelPricing{wildcardPricing, exactPricing},
  99. platform: "anthropic",
  100. model: "claude-opus-4",
  101. wantID: 1,
  102. },
  103. {
  104. name: "platform mismatch skipped",
  105. list: []ChannelModelPricing{platformPricing},
  106. platform: "anthropic",
  107. model: "gpt-4o",
  108. wantNil: true,
  109. },
  110. {
  111. name: "empty platform in pricing matches any",
  112. list: []ChannelModelPricing{emptyPlatformPricing},
  113. platform: "gemini",
  114. model: "gemini-2.5-pro",
  115. wantID: 4,
  116. },
  117. {
  118. name: "empty platform in query matches any pricing platform",
  119. list: []ChannelModelPricing{platformPricing},
  120. platform: "",
  121. model: "gpt-4o",
  122. wantID: 3,
  123. },
  124. {
  125. name: "no match at all",
  126. list: []ChannelModelPricing{exactPricing, wildcardPricing},
  127. platform: "anthropic",
  128. model: "gpt-4o",
  129. wantNil: true,
  130. },
  131. {
  132. name: "empty list returns nil",
  133. list: nil,
  134. model: "claude-opus-4",
  135. wantNil: true,
  136. },
  137. {
  138. name: "wildcard matches by config order (first match wins)",
  139. list: []ChannelModelPricing{
  140. {ID: 10, Models: []string{"claude-*"}},
  141. {ID: 11, Models: []string{"claude-opus-*"}},
  142. },
  143. platform: "",
  144. model: "claude-opus-4",
  145. wantID: 10, // config order: "claude-*" is first and matches, so it wins
  146. },
  147. {
  148. name: "shorter wildcard used when longer does not match",
  149. list: []ChannelModelPricing{
  150. {ID: 10, Models: []string{"claude-*"}},
  151. {ID: 11, Models: []string{"claude-opus-*"}},
  152. },
  153. platform: "",
  154. model: "claude-sonnet-4",
  155. wantID: 10, // only "claude-*" matches
  156. },
  157. }
  158. for _, tt := range tests {
  159. t.Run(tt.name, func(t *testing.T) {
  160. result := findPricingForModel(tt.list, tt.platform, tt.model)
  161. if tt.wantNil {
  162. require.Nil(t, result)
  163. return
  164. }
  165. require.NotNil(t, result)
  166. require.Equal(t, tt.wantID, result.ID)
  167. })
  168. }
  169. }
  170. // ---------------------------------------------------------------------------
  171. // calculateStatsCost
  172. // ---------------------------------------------------------------------------
  173. func TestCalculateStatsCost_NilPricing(t *testing.T) {
  174. result := calculateStatsCost(nil, UsageTokens{}, 1)
  175. require.Nil(t, result)
  176. }
  177. func TestCalculateStatsCost_TokenBilling(t *testing.T) {
  178. pricing := &ChannelModelPricing{
  179. BillingMode: BillingModeToken,
  180. InputPrice: testPtrFloat64(0.001),
  181. OutputPrice: testPtrFloat64(0.002),
  182. }
  183. tokens := UsageTokens{
  184. InputTokens: 100,
  185. OutputTokens: 50,
  186. }
  187. result := calculateStatsCost(pricing, tokens, 1)
  188. require.NotNil(t, result)
  189. // 100*0.001 + 50*0.002 = 0.1 + 0.1 = 0.2
  190. require.InDelta(t, 0.2, *result, 1e-12)
  191. }
  192. func TestCalculateStatsCost_TokenBilling_WithCache(t *testing.T) {
  193. pricing := &ChannelModelPricing{
  194. BillingMode: BillingModeToken,
  195. InputPrice: testPtrFloat64(0.001),
  196. OutputPrice: testPtrFloat64(0.002),
  197. CacheWritePrice: testPtrFloat64(0.003),
  198. CacheReadPrice: testPtrFloat64(0.0005),
  199. }
  200. tokens := UsageTokens{
  201. InputTokens: 100,
  202. OutputTokens: 50,
  203. CacheCreationTokens: 200,
  204. CacheReadTokens: 300,
  205. }
  206. result := calculateStatsCost(pricing, tokens, 1)
  207. require.NotNil(t, result)
  208. // 100*0.001 + 50*0.002 + 200*0.003 + 300*0.0005
  209. // = 0.1 + 0.1 + 0.6 + 0.15 = 0.95
  210. require.InDelta(t, 0.95, *result, 1e-12)
  211. }
  212. func TestCalculateStatsCost_TokenBilling_WithCacheTTLPrices(t *testing.T) {
  213. pricing := &ChannelModelPricing{
  214. BillingMode: BillingModeToken,
  215. CacheWritePrice: testPtrFloat64(0.003),
  216. CacheWrite1hPrice: testPtrFloat64(0.005),
  217. }
  218. tokens := UsageTokens{
  219. CacheCreationTokens: 200,
  220. CacheCreation5mTokens: 80,
  221. CacheCreation1hTokens: 120,
  222. }
  223. result := calculateStatsCost(pricing, tokens, 1)
  224. require.NotNil(t, result)
  225. // 80*0.003 + 120*0.005 = 0.84
  226. require.InDelta(t, 0.84, *result, 1e-12)
  227. }
  228. func TestCalculateStatsCost_TokenBilling_WithImageOutput(t *testing.T) {
  229. pricing := &ChannelModelPricing{
  230. BillingMode: BillingModeToken,
  231. InputPrice: testPtrFloat64(0.001),
  232. OutputPrice: testPtrFloat64(0.002),
  233. ImageOutputPrice: testPtrFloat64(0.01),
  234. }
  235. tokens := UsageTokens{
  236. InputTokens: 100,
  237. OutputTokens: 50,
  238. ImageOutputTokens: 10,
  239. }
  240. result := calculateStatsCost(pricing, tokens, 1)
  241. require.NotNil(t, result)
  242. // 100*0.001 + 50*0.002 + 10*0.01 = 0.1 + 0.1 + 0.1 = 0.3
  243. require.InDelta(t, 0.3, *result, 1e-12)
  244. }
  245. func TestCalculateStatsCost_TokenBilling_PartialPricesNil(t *testing.T) {
  246. pricing := &ChannelModelPricing{
  247. BillingMode: BillingModeToken,
  248. InputPrice: testPtrFloat64(0.001),
  249. // OutputPrice, CacheWritePrice, etc. are all nil → treated as 0
  250. }
  251. tokens := UsageTokens{
  252. InputTokens: 100,
  253. OutputTokens: 50,
  254. CacheCreationTokens: 200,
  255. }
  256. result := calculateStatsCost(pricing, tokens, 1)
  257. require.NotNil(t, result)
  258. // Only input contributes: 100*0.001 = 0.1
  259. require.InDelta(t, 0.1, *result, 1e-12)
  260. }
  261. func TestCalculateStatsCost_TokenBilling_AllTokensZero(t *testing.T) {
  262. pricing := &ChannelModelPricing{
  263. BillingMode: BillingModeToken,
  264. InputPrice: testPtrFloat64(0.001),
  265. OutputPrice: testPtrFloat64(0.002),
  266. }
  267. tokens := UsageTokens{} // all zeros
  268. result := calculateStatsCost(pricing, tokens, 1)
  269. // totalCost == 0 → returns nil (does not override, falls back to default formula)
  270. require.Nil(t, result)
  271. }
  272. func TestCalculateStatsCost_PerRequestBilling(t *testing.T) {
  273. pricing := &ChannelModelPricing{
  274. BillingMode: BillingModePerRequest,
  275. PerRequestPrice: testPtrFloat64(0.05),
  276. }
  277. tokens := UsageTokens{InputTokens: 999, OutputTokens: 999}
  278. result := calculateStatsCost(pricing, tokens, 3)
  279. require.NotNil(t, result)
  280. // 0.05 * 3 = 0.15
  281. require.InDelta(t, 0.15, *result, 1e-12)
  282. }
  283. func TestCalculateStatsCost_PerRequestBilling_PriceNil(t *testing.T) {
  284. pricing := &ChannelModelPricing{
  285. BillingMode: BillingModePerRequest,
  286. // PerRequestPrice is nil
  287. }
  288. result := calculateStatsCost(pricing, UsageTokens{}, 1)
  289. require.Nil(t, result)
  290. }
  291. func TestCalculateStatsCost_PerRequestBilling_PriceZero(t *testing.T) {
  292. pricing := &ChannelModelPricing{
  293. BillingMode: BillingModePerRequest,
  294. PerRequestPrice: testPtrFloat64(0),
  295. }
  296. result := calculateStatsCost(pricing, UsageTokens{}, 1)
  297. // price == 0 → condition *pricing.PerRequestPrice > 0 is false → returns nil
  298. require.Nil(t, result)
  299. }
  300. func TestCalculateStatsCost_ImageBilling(t *testing.T) {
  301. pricing := &ChannelModelPricing{
  302. BillingMode: BillingModeImage,
  303. PerRequestPrice: testPtrFloat64(0.10),
  304. }
  305. result := calculateStatsCost(pricing, UsageTokens{}, 2)
  306. require.NotNil(t, result)
  307. // 0.10 * 2 = 0.20
  308. require.InDelta(t, 0.20, *result, 1e-12)
  309. }
  310. func TestCalculateStatsCost_ImageBilling_PriceNil(t *testing.T) {
  311. pricing := &ChannelModelPricing{
  312. BillingMode: BillingModeImage,
  313. // PerRequestPrice is nil
  314. }
  315. result := calculateStatsCost(pricing, UsageTokens{}, 1)
  316. require.Nil(t, result)
  317. }
  318. func TestCalculateStatsCost_DefaultBillingMode_FallsToToken(t *testing.T) {
  319. // BillingMode is empty string (default) → falls into token billing
  320. pricing := &ChannelModelPricing{
  321. InputPrice: testPtrFloat64(0.001),
  322. OutputPrice: testPtrFloat64(0.002),
  323. }
  324. tokens := UsageTokens{
  325. InputTokens: 100,
  326. OutputTokens: 50,
  327. }
  328. result := calculateStatsCost(pricing, tokens, 1)
  329. require.NotNil(t, result)
  330. require.InDelta(t, 0.2, *result, 1e-12)
  331. }
  332. // ---------------------------------------------------------------------------
  333. // tryCustomRules — 多规则顺序测试
  334. // ---------------------------------------------------------------------------
  335. func TestTryCustomRules_FirstMatchWins(t *testing.T) {
  336. channel := &Channel{
  337. AccountStatsPricingRules: []AccountStatsPricingRule{
  338. {
  339. GroupIDs: []int64{1},
  340. Pricing: []ChannelModelPricing{
  341. {ID: 100, Models: []string{"claude-opus-4"}, InputPrice: testPtrFloat64(0.01), OutputPrice: testPtrFloat64(0.02)},
  342. },
  343. },
  344. {
  345. GroupIDs: []int64{1},
  346. Pricing: []ChannelModelPricing{
  347. {ID: 200, Models: []string{"claude-opus-4"}, InputPrice: testPtrFloat64(0.99), OutputPrice: testPtrFloat64(0.99)},
  348. },
  349. },
  350. },
  351. }
  352. tokens := UsageTokens{InputTokens: 100, OutputTokens: 50}
  353. result := tryCustomRules(channel, 999, 1, "", "claude-opus-4", tokens, 1)
  354. require.NotNil(t, result)
  355. // 应使用第一条规则的价格:100*0.01 + 50*0.02 = 2.0
  356. require.InDelta(t, 2.0, *result, 1e-12)
  357. }
  358. func TestTryCustomRules_SkipsNonMatchingRules(t *testing.T) {
  359. channel := &Channel{
  360. AccountStatsPricingRules: []AccountStatsPricingRule{
  361. {
  362. AccountIDs: []int64{888}, // 不匹配
  363. Pricing: []ChannelModelPricing{
  364. {ID: 100, Models: []string{"claude-opus-4"}, InputPrice: testPtrFloat64(0.99)},
  365. },
  366. },
  367. {
  368. GroupIDs: []int64{1}, // 匹配
  369. Pricing: []ChannelModelPricing{
  370. {ID: 200, Models: []string{"claude-opus-4"}, InputPrice: testPtrFloat64(0.05)},
  371. },
  372. },
  373. },
  374. }
  375. tokens := UsageTokens{InputTokens: 100}
  376. result := tryCustomRules(channel, 999, 1, "", "claude-opus-4", tokens, 1)
  377. require.NotNil(t, result)
  378. // 跳过规则1(账号不匹配),使用规则2:100*0.05 = 5.0
  379. require.InDelta(t, 5.0, *result, 1e-12)
  380. }
  381. func TestTryCustomRules_NoMatch_ReturnsNil(t *testing.T) {
  382. channel := &Channel{
  383. AccountStatsPricingRules: []AccountStatsPricingRule{
  384. {
  385. AccountIDs: []int64{888},
  386. Pricing: []ChannelModelPricing{
  387. {ID: 100, Models: []string{"claude-opus-4"}, InputPrice: testPtrFloat64(0.01)},
  388. },
  389. },
  390. },
  391. }
  392. tokens := UsageTokens{InputTokens: 100}
  393. result := tryCustomRules(channel, 999, 2, "", "claude-opus-4", tokens, 1)
  394. require.Nil(t, result) // 账号和分组都不匹配
  395. }
  396. func TestTryCustomRules_RuleMatchesButModelNot_ContinuesToNext(t *testing.T) {
  397. channel := &Channel{
  398. AccountStatsPricingRules: []AccountStatsPricingRule{
  399. {
  400. GroupIDs: []int64{1},
  401. Pricing: []ChannelModelPricing{
  402. {ID: 100, Models: []string{"gpt-4o"}, InputPrice: testPtrFloat64(0.01)}, // 模型不匹配
  403. },
  404. },
  405. {
  406. GroupIDs: []int64{1},
  407. Pricing: []ChannelModelPricing{
  408. {ID: 200, Models: []string{"claude-opus-4"}, InputPrice: testPtrFloat64(0.05)}, // 模型匹配
  409. },
  410. },
  411. },
  412. }
  413. tokens := UsageTokens{InputTokens: 100}
  414. result := tryCustomRules(channel, 999, 1, "", "claude-opus-4", tokens, 1)
  415. require.NotNil(t, result)
  416. require.InDelta(t, 5.0, *result, 1e-12) // 使用规则2
  417. }
  418. // ---------------------------------------------------------------------------
  419. // tryModelFilePricing
  420. // ---------------------------------------------------------------------------
  421. // newTestBillingServiceWithPrices creates a BillingService with pre-populated
  422. // fallback prices for testing. No config or pricing service is needed.
  423. // The key must match what getFallbackPricing resolves to for a given model name.
  424. // E.g., model "claude-sonnet-4" resolves to key "claude-sonnet-4".
  425. func newTestBillingServiceWithPrices(prices map[string]*ModelPricing) *BillingService {
  426. return &BillingService{
  427. fallbackPrices: prices,
  428. }
  429. }
  430. func TestTryModelFilePricing_Success(t *testing.T) {
  431. bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{
  432. "claude-sonnet-4": {
  433. InputPricePerToken: 0.001,
  434. OutputPricePerToken: 0.002,
  435. },
  436. })
  437. tokens := UsageTokens{InputTokens: 100, OutputTokens: 50}
  438. result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{})
  439. require.NotNil(t, result)
  440. // 100*0.001 + 50*0.002 = 0.1 + 0.1 = 0.2
  441. require.InDelta(t, 0.2, *result, 1e-12)
  442. }
  443. func TestTryModelFilePricing_Fable51MaxEffortUsesTripleQuota(t *testing.T) {
  444. bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{
  445. "claude-fable-5-1": {InputPricePerToken: 0.001},
  446. })
  447. tokens := UsageTokens{InputTokens: 100}
  448. standard := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", time.Time{}, "xhigh")
  449. max := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", time.Time{}, "max")
  450. require.NotNil(t, standard)
  451. require.NotNil(t, max)
  452. require.InDelta(t, *standard*3, *max, 1e-12)
  453. }
  454. func TestTryModelFilePricing_AppliesLongContextPricing(t *testing.T) {
  455. bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{
  456. "gpt-5.6-sol": {
  457. InputPricePerToken: 0.001,
  458. OutputPricePerToken: 0.002,
  459. CacheReadPricePerToken: 0.0001,
  460. LongContextInputThreshold: 100,
  461. LongContextInputMultiplier: 2,
  462. LongContextOutputMultiplier: 1.5,
  463. },
  464. })
  465. tokens := UsageTokens{InputTokens: 101, OutputTokens: 10, CacheReadTokens: 5}
  466. result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, "", time.Time{})
  467. require.NotNil(t, result)
  468. // Input and cache-read use the 2x input tier; output uses the 1.5x tier.
  469. require.InDelta(t, 0.233, *result, 1e-12)
  470. }
  471. func TestTryModelFilePricing_AppliesServiceTierPricing(t *testing.T) {
  472. bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{
  473. "gpt-5.6-sol": {
  474. InputPricePerToken: 0.001,
  475. InputPricePerTokenPriority: 0.002,
  476. OutputPricePerToken: 0.002,
  477. OutputPricePerTokenPriority: 0.004,
  478. CacheCreationPricePerToken: 0.003,
  479. CacheCreationPricePerTokenPriority: 0.006,
  480. CacheReadPricePerToken: 0.0005,
  481. CacheReadPricePerTokenPriority: 0.001,
  482. },
  483. })
  484. tokens := UsageTokens{
  485. InputTokens: 100,
  486. OutputTokens: 50,
  487. CacheCreationTokens: 20,
  488. CacheReadTokens: 10,
  489. }
  490. tests := []struct {
  491. name string
  492. serviceTier string
  493. want float64
  494. }{
  495. {name: "standard", serviceTier: "", want: 0.265},
  496. {name: "priority", serviceTier: "priority", want: 0.53},
  497. {name: "flex", serviceTier: "flex", want: 0.1325},
  498. }
  499. for _, tt := range tests {
  500. t.Run(tt.name, func(t *testing.T) {
  501. result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, tt.serviceTier, time.Time{})
  502. require.NotNil(t, result)
  503. require.InDelta(t, tt.want, *result, 1e-12)
  504. })
  505. }
  506. }
  507. func TestTryModelFilePricing_CombinesPriorityAndLongContextPricing(t *testing.T) {
  508. bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{
  509. "gpt-5.6-sol": {
  510. InputPricePerToken: 0.001,
  511. InputPricePerTokenPriority: 0.002,
  512. OutputPricePerToken: 0.002,
  513. OutputPricePerTokenPriority: 0.004,
  514. CacheCreationPricePerToken: 0.003,
  515. CacheCreationPricePerTokenPriority: 0.006,
  516. CacheReadPricePerToken: 0.0005,
  517. CacheReadPricePerTokenPriority: 0.001,
  518. LongContextInputThreshold: 100,
  519. LongContextInputMultiplier: 2,
  520. LongContextOutputMultiplier: 1.5,
  521. },
  522. })
  523. tokens := UsageTokens{
  524. InputTokens: 101,
  525. OutputTokens: 10,
  526. CacheCreationTokens: 5,
  527. CacheReadTokens: 5,
  528. }
  529. result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, "priority", time.Time{})
  530. require.NotNil(t, result)
  531. // priority 单价先应用,再叠加长上下文输入 2x、输出 1.5x。
  532. require.InDelta(t, 0.534, *result, 1e-12)
  533. }
  534. func TestTryModelFilePricing_PricingNotFound(t *testing.T) {
  535. // "nonexistent-model" does not match any fallback pattern
  536. bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{})
  537. tokens := UsageTokens{InputTokens: 100, OutputTokens: 50}
  538. result := tryModelFilePricing(bs, "nonexistent-model", tokens, "", time.Time{})
  539. require.Nil(t, result)
  540. }
  541. func TestTryModelFilePricing_NilFallback(t *testing.T) {
  542. // getFallbackPricing returns nil when key maps to nil
  543. bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{
  544. "claude-sonnet-4": nil,
  545. })
  546. tokens := UsageTokens{InputTokens: 100}
  547. result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{})
  548. require.Nil(t, result)
  549. }
  550. func TestTryModelFilePricing_ZeroCost(t *testing.T) {
  551. bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{
  552. "claude-sonnet-4": {
  553. InputPricePerToken: 0.001,
  554. OutputPricePerToken: 0.002,
  555. },
  556. })
  557. tokens := UsageTokens{} // all zero tokens → cost = 0 → nil
  558. result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{})
  559. require.Nil(t, result)
  560. }
  561. func TestTryModelFilePricing_WithImageOutput(t *testing.T) {
  562. bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{
  563. "claude-sonnet-4": {
  564. InputPricePerToken: 0.001,
  565. OutputPricePerToken: 0.002,
  566. ImageOutputPricePerToken: 0.01,
  567. },
  568. })
  569. tokens := UsageTokens{
  570. InputTokens: 100,
  571. OutputTokens: 50,
  572. ImageOutputTokens: 10,
  573. }
  574. result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{})
  575. require.NotNil(t, result)
  576. // ImageOutputTokens 是 OutputTokens 的子集,先扣除再按图片单价计。
  577. // 100*0.001 + (50-10)*0.002 + 10*0.01 = 0.1 + 0.08 + 0.1 = 0.28
  578. require.InDelta(t, 0.28, *result, 1e-12)
  579. }
  580. func TestTryModelFilePricing_WithCacheTokens(t *testing.T) {
  581. bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{
  582. "claude-sonnet-4": {
  583. InputPricePerToken: 0.001,
  584. OutputPricePerToken: 0.002,
  585. CacheCreationPricePerToken: 0.003,
  586. CacheReadPricePerToken: 0.0005,
  587. },
  588. })
  589. tokens := UsageTokens{
  590. InputTokens: 100,
  591. OutputTokens: 50,
  592. CacheCreationTokens: 200,
  593. CacheReadTokens: 300,
  594. }
  595. result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{})
  596. require.NotNil(t, result)
  597. // 100*0.001 + 50*0.002 + 200*0.003 + 300*0.0005
  598. // = 0.1 + 0.1 + 0.6 + 0.15 = 0.95
  599. require.InDelta(t, 0.95, *result, 1e-12)
  600. }
  601. func TestTryModelFilePricing_DeepSeekPeakPricing(t *testing.T) {
  602. weekday := func(hour, minute int) time.Time {
  603. return time.Date(2026, time.August, 24, hour, minute, 0, 0, time.UTC)
  604. }
  605. for _, model := range []struct {
  606. name string
  607. input, output, cacheReadPrice float64
  608. }{
  609. {"deepseek-v4-flash", 2.2e-7, 6.6e-7, 7e-9},
  610. {"deepseek-v4-pro", 6.6e-7, 1.98e-6, 2.2e-8},
  611. } {
  612. for _, usage := range []struct {
  613. name string
  614. tokens UsageTokens
  615. }{
  616. {"input", UsageTokens{InputTokens: 1000}},
  617. {"output", UsageTokens{OutputTokens: 500}},
  618. {"cache_read", UsageTokens{CacheReadTokens: 1000}},
  619. {"mixed", UsageTokens{InputTokens: 1000, OutputTokens: 500, CacheReadTokens: 1000}},
  620. } {
  621. t.Run(model.name+"/"+usage.name, func(t *testing.T) {
  622. bs := newTestBillingService()
  623. tokens := usage.tokens
  624. baseCost := float64(tokens.InputTokens)*model.input +
  625. float64(tokens.OutputTokens)*model.output + float64(tokens.CacheReadTokens)*model.cacheReadPrice
  626. for _, slot := range []struct {
  627. name string
  628. at time.Time
  629. multiplier float64
  630. }{
  631. {"before_morning_peak", weekday(0, 59), 1},
  632. {"morning_peak_start", weekday(1, 0), 2},
  633. {"morning_peak_last_minute", weekday(3, 59), 2},
  634. {"morning_peak_end", weekday(4, 0), 1},
  635. {"afternoon_peak_start", weekday(6, 0), 2},
  636. {"afternoon_peak_last_minute", weekday(9, 59), 2},
  637. {"afternoon_peak_end", weekday(10, 0), 1},
  638. {"saturday", time.Date(2026, time.August, 22, 2, 0, 0, 0, time.UTC), 1},
  639. {"sunday", time.Date(2026, time.August, 23, 7, 0, 0, 0, time.UTC), 1},
  640. } {
  641. t.Run(slot.name, func(t *testing.T) {
  642. cost := tryModelFilePricing(bs, model.name, tokens, "", slot.at)
  643. require.NotNil(t, cost)
  644. require.InDelta(t, baseCost*slot.multiplier, *cost, 1e-12)
  645. })
  646. }
  647. })
  648. }
  649. }
  650. }
  651. func TestResolveAccountStatsCost_DeepSeekPricingPriority(t *testing.T) {
  652. peak := time.Date(2026, time.August, 24, 2, 0, 0, 0, time.UTC)
  653. for _, tt := range []struct {
  654. name string
  655. customRule bool
  656. applyPricing bool
  657. noChannel bool
  658. want float64
  659. }{
  660. {name: "catalog", want: 1000 * 2.2e-7 * 2},
  661. {name: "custom_rule", customRule: true, want: 1},
  662. {name: "custom_rule_before_customer_price", customRule: true, applyPricing: true, want: 1},
  663. {name: "customer_price", applyPricing: true, want: 0.75},
  664. {name: "no_channel", noChannel: true},
  665. } {
  666. t.Run(tt.name, func(t *testing.T) {
  667. channel := &Channel{
  668. ID: 1, Status: StatusActive, ApplyPricingToAccountStats: tt.applyPricing,
  669. ModelPricing: []ChannelModelPricing{{
  670. Models: []string{"deepseek-v4-flash"}, InputPrice: testPtrFloat64(0.02),
  671. }},
  672. }
  673. if tt.customRule {
  674. channel.AccountStatsPricingRules = []AccountStatsPricingRule{{
  675. AccountIDs: []int64{1},
  676. Pricing: []ChannelModelPricing{{
  677. Models: []string{"deepseek-v4-flash"}, InputPrice: testPtrFloat64(0.001),
  678. }},
  679. }}
  680. }
  681. cs := newTestChannelServiceForStats(t, channel, 10, PlatformDeepseek)
  682. groupID := int64(10)
  683. if tt.noChannel {
  684. groupID = 99
  685. }
  686. cost := resolveAccountStatsCost(context.Background(), cs, newTestBillingService(),
  687. 1, groupID, "deepseek-v4-flash", UsageTokens{InputTokens: 1000}, 1, 0.75, "", peak)
  688. if tt.noChannel {
  689. require.Nil(t, cost)
  690. return
  691. }
  692. require.NotNil(t, cost)
  693. require.InDelta(t, tt.want, *cost, 1e-12)
  694. })
  695. }
  696. }
  697. // ---------------------------------------------------------------------------
  698. // resolveAccountStatsCost — integration tests covering the 4-level priority chain
  699. // ---------------------------------------------------------------------------
  700. func TestResolveAccountStatsCost_NilChannelService(t *testing.T) {
  701. result := resolveAccountStatsCost(
  702. context.Background(),
  703. nil, // channelService is nil
  704. newTestBillingServiceWithPrices(map[string]*ModelPricing{}),
  705. 1, 1, "claude-sonnet-4",
  706. UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{},
  707. )
  708. require.Nil(t, result)
  709. }
  710. func TestResolveAccountStatsCost_EmptyUpstreamModel(t *testing.T) {
  711. cs := newTestChannelServiceForStats(t, &Channel{
  712. ID: 1,
  713. Status: StatusActive,
  714. }, 1, "")
  715. result := resolveAccountStatsCost(
  716. context.Background(),
  717. cs,
  718. newTestBillingServiceWithPrices(map[string]*ModelPricing{}),
  719. 1, 1, "", // empty upstream model
  720. UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{},
  721. )
  722. require.Nil(t, result)
  723. }
  724. func TestResolveAccountStatsCost_GetChannelForGroupReturnsNil(t *testing.T) {
  725. // Group 99 is NOT in the cache, so GetChannelForGroup returns nil
  726. cs := newTestChannelServiceForStats(t, &Channel{
  727. ID: 1,
  728. Status: StatusActive,
  729. }, 1, "")
  730. result := resolveAccountStatsCost(
  731. context.Background(),
  732. cs,
  733. newTestBillingServiceWithPrices(map[string]*ModelPricing{}),
  734. 1, 99, "claude-sonnet-4", // groupID 99 has no channel
  735. UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{},
  736. )
  737. require.Nil(t, result)
  738. }
  739. func TestResolveAccountStatsCost_HitsCustomRule(t *testing.T) {
  740. channel := &Channel{
  741. ID: 1,
  742. Status: StatusActive,
  743. AccountStatsPricingRules: []AccountStatsPricingRule{
  744. {
  745. GroupIDs: []int64{10},
  746. Pricing: []ChannelModelPricing{
  747. {
  748. ID: 100,
  749. Models: []string{"claude-sonnet-4"},
  750. InputPrice: testPtrFloat64(0.01),
  751. OutputPrice: testPtrFloat64(0.02),
  752. },
  753. },
  754. },
  755. },
  756. }
  757. cs := newTestChannelServiceForStats(t, channel, 10, "anthropic")
  758. tokens := UsageTokens{InputTokens: 100, OutputTokens: 50}
  759. result := resolveAccountStatsCost(
  760. context.Background(),
  761. cs, nil, // billingService not needed when custom rule hits
  762. 1, 10, "claude-sonnet-4",
  763. tokens, 1, 999.0, "priority", time.Time{}, // 自定义账号价格不叠加服务层级倍率
  764. )
  765. require.NotNil(t, result)
  766. // 100*0.01 + 50*0.02 = 1.0 + 1.0 = 2.0
  767. require.InDelta(t, 2.0, *result, 1e-12)
  768. }
  769. func TestResolveAccountStatsCost_ApplyPricingToAccountStats_UsesTotalCost(t *testing.T) {
  770. channel := &Channel{
  771. ID: 1,
  772. Status: StatusActive,
  773. ApplyPricingToAccountStats: true,
  774. // No custom rules
  775. }
  776. cs := newTestChannelServiceForStats(t, channel, 10, "anthropic")
  777. tokens := UsageTokens{InputTokens: 100, OutputTokens: 50}
  778. result := resolveAccountStatsCost(
  779. context.Background(),
  780. cs, nil,
  781. 1, 10, "claude-sonnet-4",
  782. tokens, 1, 0.75, "priority", time.Time{}, // 已完成用户计费,不再重复应用服务层级倍率
  783. )
  784. require.NotNil(t, result)
  785. require.InDelta(t, 0.75, *result, 1e-12)
  786. }
  787. func TestResolveAccountStatsCost_ApplyPricingToAccountStats_ZeroTotalCost_ReturnsNil(t *testing.T) {
  788. channel := &Channel{
  789. ID: 1,
  790. Status: StatusActive,
  791. ApplyPricingToAccountStats: true,
  792. }
  793. cs := newTestChannelServiceForStats(t, channel, 10, "anthropic")
  794. result := resolveAccountStatsCost(
  795. context.Background(),
  796. cs, nil,
  797. 1, 10, "claude-sonnet-4",
  798. UsageTokens{}, 1, 0.0, "", time.Time{}, // totalCost = 0
  799. )
  800. require.Nil(t, result)
  801. }
  802. func TestResolveAccountStatsCost_FallsBackToLiteLLM(t *testing.T) {
  803. channel := &Channel{
  804. ID: 1,
  805. Status: StatusActive,
  806. ApplyPricingToAccountStats: false, // not enabled
  807. // No custom rules
  808. }
  809. cs := newTestChannelServiceForStats(t, channel, 10, "anthropic")
  810. bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{
  811. "claude-sonnet-4": {
  812. InputPricePerToken: 0.001,
  813. OutputPricePerToken: 0.002,
  814. },
  815. })
  816. tokens := UsageTokens{InputTokens: 100, OutputTokens: 50}
  817. result := resolveAccountStatsCost(
  818. context.Background(),
  819. cs, bs,
  820. 1, 10, "claude-sonnet-4",
  821. tokens, 1, 999.0, "", time.Time{}, // totalCost ignored
  822. )
  823. require.NotNil(t, result)
  824. // 100*0.001 + 50*0.002 = 0.1 + 0.1 = 0.2
  825. require.InDelta(t, 0.2, *result, 1e-12)
  826. }
  827. func TestResolveAccountStatsCost_FallbackHonorsAnthropicFast(t *testing.T) {
  828. channel := &Channel{ID: 1, Status: StatusActive}
  829. cs := newTestChannelServiceForStats(t, channel, 10, "anthropic")
  830. bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{
  831. "claude-opus-5": {
  832. InputPricePerToken: 5e-6,
  833. OutputPricePerToken: 25e-6,
  834. },
  835. })
  836. result := resolveAccountStatsCost(
  837. context.Background(), cs, bs,
  838. 1, 10, "claude-opus-5",
  839. UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000},
  840. 1, 0, "fast", time.Time{},
  841. )
  842. require.NotNil(t, result)
  843. require.InDelta(t, 60, *result, 1e-12)
  844. }
  845. func TestResolveAccountStatsCost_Gemini36FlashTierUsesFallbackPricing(t *testing.T) {
  846. channel := &Channel{
  847. ID: 1,
  848. Status: StatusActive,
  849. ApplyPricingToAccountStats: false,
  850. }
  851. cs := newTestChannelServiceForStats(t, channel, 10, "antigravity")
  852. bs := NewBillingService(&config.Config{}, nil)
  853. result := resolveAccountStatsCost(
  854. context.Background(),
  855. cs, bs,
  856. 1, 10, "gemini-3.6-flash-low",
  857. UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000, CacheReadTokens: 1_000_000}, 1, 0, "", time.Time{},
  858. )
  859. require.NotNil(t, result)
  860. require.InDelta(t, 9.15, *result, 1e-12)
  861. }
  862. func TestResolveAccountStatsCost_AllMiss_ReturnsNil(t *testing.T) {
  863. channel := &Channel{
  864. ID: 1,
  865. Status: StatusActive,
  866. ApplyPricingToAccountStats: false,
  867. // No custom rules
  868. }
  869. cs := newTestChannelServiceForStats(t, channel, 10, "anthropic")
  870. // BillingService with no pricing for the model
  871. bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{})
  872. tokens := UsageTokens{InputTokens: 100, OutputTokens: 50}
  873. result := resolveAccountStatsCost(
  874. context.Background(),
  875. cs, bs,
  876. 1, 10, "totally-unknown-model",
  877. tokens, 1, 0.0, "", time.Time{},
  878. )
  879. require.Nil(t, result)
  880. }
  881. func TestResolveAccountStatsCost_NilBillingService_SkipsLiteLLM(t *testing.T) {
  882. channel := &Channel{
  883. ID: 1,
  884. Status: StatusActive,
  885. ApplyPricingToAccountStats: false,
  886. }
  887. cs := newTestChannelServiceForStats(t, channel, 10, "anthropic")
  888. result := resolveAccountStatsCost(
  889. context.Background(),
  890. cs, nil, // billingService is nil
  891. 1, 10, "claude-sonnet-4",
  892. UsageTokens{InputTokens: 100}, 1, 0.0, "", time.Time{},
  893. )
  894. require.Nil(t, result)
  895. }
  896. func TestResolveAccountStatsCost_CustomRulePriorityOverApplyPricing(t *testing.T) {
  897. // Both custom rule and ApplyPricingToAccountStats are configured;
  898. // custom rule should take precedence.
  899. channel := &Channel{
  900. ID: 1,
  901. Status: StatusActive,
  902. ApplyPricingToAccountStats: true,
  903. AccountStatsPricingRules: []AccountStatsPricingRule{
  904. {
  905. GroupIDs: []int64{10},
  906. Pricing: []ChannelModelPricing{
  907. {
  908. ID: 100,
  909. Models: []string{"claude-sonnet-4"},
  910. InputPrice: testPtrFloat64(0.05),
  911. },
  912. },
  913. },
  914. },
  915. }
  916. cs := newTestChannelServiceForStats(t, channel, 10, "anthropic")
  917. tokens := UsageTokens{InputTokens: 100}
  918. result := resolveAccountStatsCost(
  919. context.Background(),
  920. cs, nil,
  921. 1, 10, "claude-sonnet-4",
  922. tokens, 1, 99.0, "", time.Time{}, // totalCost = 99.0 (would be used if ApplyPricing wins)
  923. )
  924. require.NotNil(t, result)
  925. // Custom rule: 100*0.05 = 5.0 (NOT 99.0 from totalCost)
  926. require.InDelta(t, 5.0, *result, 1e-12)
  927. }
  928. func TestApplyAccountStatsCost_UsesUsageLogServiceTier(t *testing.T) {
  929. channel := &Channel{
  930. ID: 1,
  931. Status: StatusActive,
  932. ApplyPricingToAccountStats: false,
  933. }
  934. cs := newTestChannelServiceForStats(t, channel, 10, "openai")
  935. bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{
  936. "gpt-5.6-sol": {
  937. InputPricePerToken: 0.001,
  938. InputPricePerTokenPriority: 0.002,
  939. OutputPricePerToken: 0.002,
  940. OutputPricePerTokenPriority: 0.004,
  941. },
  942. })
  943. serviceTier := "priority"
  944. usageLog := &UsageLog{ServiceTier: &serviceTier}
  945. applyAccountStatsCost(
  946. context.Background(), usageLog, cs, bs,
  947. 1, 10, "gpt-5.6-sol", "gpt-5.6-sol",
  948. UsageTokens{InputTokens: 100, OutputTokens: 50}, 999, time.Time{},
  949. )
  950. require.NotNil(t, usageLog.AccountStatsCost)
  951. require.InDelta(t, 0.4, *usageLog.AccountStatsCost, 1e-12)
  952. }
  953. // ---------------------------------------------------------------------------
  954. // helpers for resolveAccountStatsCost tests
  955. // ---------------------------------------------------------------------------
  956. // newTestChannelServiceForStats creates a ChannelService with a single channel
  957. // mapped to the given groupID, suitable for resolveAccountStatsCost tests.
  958. func newTestChannelServiceForStats(t *testing.T, channel *Channel, groupID int64, platform string) *ChannelService {
  959. t.Helper()
  960. cache := newEmptyChannelCache()
  961. cache.channelByGroupID[groupID] = channel
  962. cache.groupPlatform[groupID] = platform
  963. cs := &ChannelService{}
  964. cache.loadedAt = time.Now()
  965. cs.cache.Store(cache)
  966. return cs
  967. }