billing_service_test.go 70 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907
  1. //go:build unit
  2. package service
  3. import (
  4. "bytes"
  5. "log"
  6. "math"
  7. "strings"
  8. "testing"
  9. "github.com/Wei-Shaw/sub2api/internal/config"
  10. "github.com/stretchr/testify/require"
  11. )
  12. // captureStdLog 重定向 stdlib log 输出到 buffer,返回该 buffer;通过 t.Cleanup 还原。
  13. // 用于断言 GetModelPricing 的 fallback warn(log.Printf)打了几次。
  14. func captureStdLog(t *testing.T) *bytes.Buffer {
  15. t.Helper()
  16. var buf bytes.Buffer
  17. prevOut := log.Writer()
  18. prevFlags := log.Flags()
  19. log.SetOutput(&buf)
  20. log.SetFlags(0)
  21. t.Cleanup(func() {
  22. log.SetOutput(prevOut)
  23. log.SetFlags(prevFlags)
  24. })
  25. return &buf
  26. }
  27. func newTestBillingService() *BillingService {
  28. return NewBillingService(&config.Config{}, nil)
  29. }
  30. func newTestBillingServiceWithOpenAILadderCatalog(t *testing.T) *BillingService {
  31. t.Helper()
  32. return NewBillingService(&config.Config{}, newStubPricingServiceFromJSON(t, openAILadderCatalogJSON))
  33. }
  34. func TestCalculateCost_BasicComputation(t *testing.T) {
  35. svc := newTestBillingService()
  36. // 使用 claude-sonnet-4 的回退价格:Input $3/MTok, Output $15/MTok
  37. tokens := UsageTokens{
  38. InputTokens: 1000,
  39. OutputTokens: 500,
  40. }
  41. cost, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0)
  42. require.NoError(t, err)
  43. // 1000 * 3e-6 = 0.003, 500 * 15e-6 = 0.0075
  44. expectedInput := 1000 * 3e-6
  45. expectedOutput := 500 * 15e-6
  46. require.InDelta(t, expectedInput, cost.InputCost, 1e-10)
  47. require.InDelta(t, expectedOutput, cost.OutputCost, 1e-10)
  48. require.InDelta(t, expectedInput+expectedOutput, cost.TotalCost, 1e-10)
  49. require.InDelta(t, expectedInput+expectedOutput, cost.ActualCost, 1e-10)
  50. }
  51. func TestCalculateCost_WithCacheTokens(t *testing.T) {
  52. svc := newTestBillingService()
  53. tokens := UsageTokens{
  54. InputTokens: 1000,
  55. OutputTokens: 500,
  56. CacheCreationTokens: 2000,
  57. CacheReadTokens: 3000,
  58. }
  59. cost, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0)
  60. require.NoError(t, err)
  61. expectedCacheCreation := 2000 * 3.75e-6
  62. expectedCacheRead := 3000 * 0.3e-6
  63. require.InDelta(t, expectedCacheCreation, cost.CacheCreationCost, 1e-10)
  64. require.InDelta(t, expectedCacheRead, cost.CacheReadCost, 1e-10)
  65. expectedTotal := cost.InputCost + cost.OutputCost + expectedCacheCreation + expectedCacheRead
  66. require.InDelta(t, expectedTotal, cost.TotalCost, 1e-10)
  67. }
  68. func TestCalculateCost_RateMultiplier(t *testing.T) {
  69. svc := newTestBillingService()
  70. tokens := UsageTokens{InputTokens: 1000, OutputTokens: 500}
  71. cost1x, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0)
  72. require.NoError(t, err)
  73. cost2x, err := svc.CalculateCost("claude-sonnet-4", tokens, 2.0)
  74. require.NoError(t, err)
  75. // TotalCost 不受倍率影响,ActualCost 翻倍
  76. require.InDelta(t, cost1x.TotalCost, cost2x.TotalCost, 1e-10)
  77. require.InDelta(t, cost1x.ActualCost*2, cost2x.ActualCost, 1e-10)
  78. }
  79. func TestGetModelPricing_FallbackMatchesByFamily(t *testing.T) {
  80. svc := newTestBillingService()
  81. tests := []struct {
  82. model string
  83. expectedInput float64
  84. }{
  85. {"claude-opus-4.5-20250101", 5e-6},
  86. {"claude-3-opus-20240229", 15e-6},
  87. {"claude-sonnet-4-20250514", 3e-6},
  88. {"claude-3-5-sonnet-20241022", 3e-6},
  89. {"claude-3-5-haiku-20241022", 1e-6},
  90. {"claude-3-haiku-20240307", 0.25e-6},
  91. }
  92. for _, tt := range tests {
  93. pricing, err := svc.GetModelPricing(tt.model)
  94. require.NoError(t, err, "模型 %s", tt.model)
  95. require.InDelta(t, tt.expectedInput, pricing.InputPricePerToken, 1e-12, "模型 %s 输入价格", tt.model)
  96. }
  97. }
  98. func TestGetModelPricing_CaseInsensitive(t *testing.T) {
  99. svc := newTestBillingService()
  100. p1, err := svc.GetModelPricing("Claude-Sonnet-4")
  101. require.NoError(t, err)
  102. p2, err := svc.GetModelPricing("claude-sonnet-4")
  103. require.NoError(t, err)
  104. require.Equal(t, p1.InputPricePerToken, p2.InputPricePerToken)
  105. }
  106. // issue #3394: fallback warn 应按模型名去重,每个模型每进程最多打一条,
  107. // 避免热路径每请求刷屏 ops_system_logs。
  108. func TestGetModelPricing_FallbackWarnLoggedOncePerModel(t *testing.T) {
  109. svc := newTestBillingService()
  110. buf := captureStdLog(t)
  111. // glm-5.2 不在 LiteLLM,经 strings.Contains 命中 glm-5 兜底价 → 触发 fallback warn。
  112. for i := 0; i < 5; i++ {
  113. pricing, err := svc.GetModelPricing("glm-5.2")
  114. require.NoError(t, err)
  115. require.NotNil(t, pricing)
  116. }
  117. got := strings.Count(buf.String(), "Using fallback pricing for model: glm-5.2")
  118. require.Equal(t, 1, got, "同一模型的 fallback warn 应只打一条,实际日志:\n%s", buf.String())
  119. }
  120. // 去重按"每模型"而非全局:不同模型各打一条;大小写变体经入口 ToLower 归一,视为同一条目。
  121. func TestGetModelPricing_FallbackWarnPerModelNotGlobal(t *testing.T) {
  122. svc := newTestBillingService()
  123. buf := captureStdLog(t)
  124. for i := 0; i < 3; i++ {
  125. _, _ = svc.GetModelPricing("glm-5.2")
  126. _, _ = svc.GetModelPricing("GLM-5.2") // 与上一行同模型(ToLower 后),去重后不再打
  127. _, _ = svc.GetModelPricing("glm-4.6")
  128. }
  129. out := buf.String()
  130. require.Equal(t, 1, strings.Count(out, "model: glm-5.2"), out)
  131. require.Equal(t, 1, strings.Count(out, "model: glm-4.6"), out)
  132. require.Equal(t, 0, strings.Count(out, "model: GLM-5.2"), out) // 大写经 ToLower 归一,不应单独成行
  133. }
  134. // 回归:glm-5.2 必须命中自己的兜底价,不能被 strings.Contains("glm-5") 抢成 glm-5 价。
  135. // 历史 bug:兜底表缺 glm-5.2 条目,使用记录按 $1.00/$3.20 计费,比官方 $1.40/$4.40 少收约 27%。
  136. func TestGetModelPricing_GLM52UsesOwnPrice(t *testing.T) {
  137. svc := newTestBillingService()
  138. got, err := svc.GetModelPricing("glm-5.2")
  139. require.NoError(t, err)
  140. require.NotNil(t, got)
  141. // 官方 z.ai 口径:与 glm-5.1 同价(见 TestGetFallbackPricing_FamilyMatching)。
  142. require.InDelta(t, 1.4e-6, got.InputPricePerToken, 1e-12)
  143. require.InDelta(t, 4.4e-6, got.OutputPricePerToken, 1e-12)
  144. require.InDelta(t, 0.26e-6, got.CacheReadPricePerToken, 1e-12)
  145. }
  146. func TestGetModelPricing_UnknownClaudeModelFallsBackToSonnet(t *testing.T) {
  147. svc := newTestBillingService()
  148. // 不包含 opus/sonnet/haiku 关键词的 Claude 模型会走默认 Sonnet 价格
  149. pricing, err := svc.GetModelPricing("claude-unknown-model")
  150. require.NoError(t, err)
  151. require.InDelta(t, 3e-6, pricing.InputPricePerToken, 1e-12)
  152. }
  153. func TestGetModelPricing_UnknownOpenAIModelReturnsError(t *testing.T) {
  154. svc := newTestBillingService()
  155. pricing, err := svc.GetModelPricing("gpt-unknown-model")
  156. require.Error(t, err)
  157. require.Nil(t, pricing)
  158. require.Contains(t, err.Error(), "pricing not found")
  159. }
  160. func TestGetModelPricing_OpenAIGPT54Fallback(t *testing.T) {
  161. svc := newTestBillingService()
  162. pricing, err := svc.GetModelPricing("gpt-5.4")
  163. require.NoError(t, err)
  164. require.NotNil(t, pricing)
  165. require.InDelta(t, 2.5e-6, pricing.InputPricePerToken, 1e-12)
  166. require.InDelta(t, 15e-6, pricing.OutputPricePerToken, 1e-12)
  167. require.InDelta(t, 0.25e-6, pricing.CacheReadPricePerToken, 1e-12)
  168. // 静态兜底价不携带长上下文阶梯:阶梯一律由目录数据(above_272k 折算)驱动。
  169. require.Zero(t, pricing.LongContextInputThreshold)
  170. require.Zero(t, pricing.LongContextInputMultiplier)
  171. require.Zero(t, pricing.LongContextOutputMultiplier)
  172. }
  173. func TestGetModelPricing_CatalogAboveTierFieldsDriveLongContext(t *testing.T) {
  174. svc := newTestBillingServiceWithOpenAILadderCatalog(t)
  175. pricing, err := svc.GetModelPricing("gpt-5.4")
  176. require.NoError(t, err)
  177. require.Equal(t, 272000, pricing.LongContextInputThreshold)
  178. require.InDelta(t, 2.0, pricing.LongContextInputMultiplier, 1e-12)
  179. require.InDelta(t, 1.5, pricing.LongContextOutputMultiplier, 1e-12)
  180. require.False(t, pricing.LongContextThresholdInclusive, "openai 阈值语义为严格大于")
  181. }
  182. func TestGetModelPricing_OpenAICompactAliasesFallback(t *testing.T) {
  183. svc := newTestBillingService()
  184. tests := []struct {
  185. model string
  186. inputPrice float64
  187. outputPrice float64
  188. cacheRead float64
  189. longContext int
  190. }{
  191. {model: "gpt5.5", inputPrice: 5e-6, outputPrice: 30e-6, cacheRead: 0.5e-6, longContext: 0},
  192. {model: "openai/gpt5.4", inputPrice: 2.5e-6, outputPrice: 15e-6, cacheRead: 0.25e-6, longContext: 0},
  193. {model: "gpt5.4-mini", inputPrice: 7.5e-7, outputPrice: 4.5e-6, cacheRead: 7.5e-8, longContext: 0},
  194. {model: "gpt5.3codexspark", inputPrice: 1.5e-6, outputPrice: 12e-6, cacheRead: 0.15e-6, longContext: 0},
  195. }
  196. for _, tt := range tests {
  197. t.Run(tt.model, func(t *testing.T) {
  198. pricing, err := svc.GetModelPricing(tt.model)
  199. require.NoError(t, err)
  200. require.NotNil(t, pricing)
  201. require.InDelta(t, tt.inputPrice, pricing.InputPricePerToken, 1e-12)
  202. require.InDelta(t, tt.outputPrice, pricing.OutputPricePerToken, 1e-12)
  203. require.InDelta(t, tt.cacheRead, pricing.CacheReadPricePerToken, 1e-12)
  204. require.Equal(t, tt.longContext, pricing.LongContextInputThreshold)
  205. })
  206. }
  207. }
  208. func TestGetModelPricing_OpenAIGPT54MiniFallback(t *testing.T) {
  209. svc := newTestBillingService()
  210. pricing, err := svc.GetModelPricing("gpt-5.4-mini")
  211. require.NoError(t, err)
  212. require.NotNil(t, pricing)
  213. require.InDelta(t, 7.5e-7, pricing.InputPricePerToken, 1e-12)
  214. require.InDelta(t, 4.5e-6, pricing.OutputPricePerToken, 1e-12)
  215. require.InDelta(t, 7.5e-8, pricing.CacheReadPricePerToken, 1e-12)
  216. require.Zero(t, pricing.LongContextInputThreshold)
  217. }
  218. func TestCalculateCost_OpenAIGPT54LongContextAppliesWholeSessionMultipliers(t *testing.T) {
  219. svc := newTestBillingServiceWithOpenAILadderCatalog(t)
  220. tokens := UsageTokens{
  221. InputTokens: 300000,
  222. OutputTokens: 4000,
  223. }
  224. cost, err := svc.CalculateCost("gpt-5.4-2026-03-05", tokens, 1.0)
  225. require.NoError(t, err)
  226. expectedInput := float64(tokens.InputTokens) * 2.5e-6 * 2.0
  227. expectedOutput := float64(tokens.OutputTokens) * 15e-6 * 1.5
  228. require.InDelta(t, expectedInput, cost.InputCost, 1e-10)
  229. require.InDelta(t, expectedOutput, cost.OutputCost, 1e-10)
  230. require.InDelta(t, expectedInput+expectedOutput, cost.TotalCost, 1e-10)
  231. require.InDelta(t, expectedInput+expectedOutput, cost.ActualCost, 1e-10)
  232. require.True(t, cost.LongContextBillingApplied)
  233. }
  234. func TestCalculateCost_OpenAIGPT54LongContextMarkerRequiresActualCostIncrease(t *testing.T) {
  235. svc := newTestBillingServiceWithOpenAILadderCatalog(t)
  236. cost, err := svc.calculateCostWithServiceTierPolicy(
  237. "gpt-5.4-2026-03-05",
  238. UsageTokens{InputTokens: 300000},
  239. 0,
  240. "",
  241. true,
  242. )
  243. require.NoError(t, err)
  244. require.Zero(t, cost.ActualCost)
  245. require.False(t, cost.LongContextBillingApplied)
  246. }
  247. func TestCalculateCost_OpenAIGPT55ProUsesGPT55PricingPolicy(t *testing.T) {
  248. svc := newTestBillingServiceWithOpenAILadderCatalog(t)
  249. tokens := UsageTokens{
  250. InputTokens: 300000,
  251. OutputTokens: 4000,
  252. }
  253. cost, err := svc.CalculateCost("gpt-5.5-pro", tokens, 1.0)
  254. require.NoError(t, err)
  255. expectedInput := float64(tokens.InputTokens) * 30e-6 * 2.0
  256. expectedOutput := float64(tokens.OutputTokens) * 180e-6 * 1.5
  257. require.InDelta(t, expectedInput, cost.InputCost, 1e-10)
  258. require.InDelta(t, expectedOutput, cost.OutputCost, 1e-10)
  259. require.InDelta(t, expectedInput+expectedOutput, cost.TotalCost, 1e-10)
  260. require.InDelta(t, expectedInput+expectedOutput, cost.ActualCost, 1e-10)
  261. }
  262. func TestFallbackPricing_OpenAIGPT55UsesOfficialPrices(t *testing.T) {
  263. svc := newTestBillingService()
  264. pricing, err := svc.GetModelPricing("gpt-5.5")
  265. require.NoError(t, err)
  266. require.InDelta(t, 5e-6, pricing.InputPricePerToken, 1e-12)
  267. require.InDelta(t, 30e-6, pricing.OutputPricePerToken, 1e-12)
  268. require.InDelta(t, 0.5e-6, pricing.CacheReadPricePerToken, 1e-12)
  269. require.InDelta(t, 5e-6, pricing.CacheCreationPricePerToken, 1e-12)
  270. require.InDelta(t, 12.5e-6, pricing.InputPricePerTokenPriority, 1e-12)
  271. require.InDelta(t, 75e-6, pricing.OutputPricePerTokenPriority, 1e-12)
  272. }
  273. func TestFallbackPricing_OpenAIGPT55ProUsesOfficialPrices(t *testing.T) {
  274. svc := newTestBillingService()
  275. pricing, err := svc.GetModelPricing("gpt-5.5-pro")
  276. require.NoError(t, err)
  277. require.InDelta(t, 30e-6, pricing.InputPricePerToken, 1e-12)
  278. require.InDelta(t, 180e-6, pricing.OutputPricePerToken, 1e-12)
  279. require.InDelta(t, 30e-6, pricing.CacheReadPricePerToken, 1e-12)
  280. require.InDelta(t, 30e-6, pricing.CacheCreationPricePerToken, 1e-12)
  281. require.Zero(t, pricing.InputPricePerTokenPriority)
  282. require.Zero(t, pricing.OutputPricePerTokenPriority)
  283. }
  284. // 回归测试 #2293:长上下文计费触发时,cache_read_tokens 也应应用 LongContextInputMultiplier。
  285. // 修复前:CacheReadCost = tokens * 0.25e-6 (漏乘倍率,少计费用)。
  286. // 修复后:CacheReadCost = tokens * 0.25e-6 * LongContextInputMultiplier(=2.0)。
  287. func TestCalculateCost_OpenAIGPT54LongContextAppliesMultiplierToCacheRead(t *testing.T) {
  288. svc := newTestBillingServiceWithOpenAILadderCatalog(t)
  289. // InputTokens + CacheReadTokens = 1000 + 300000 = 301000 > 272000 阈值
  290. tokens := UsageTokens{
  291. InputTokens: 1000,
  292. CacheReadTokens: 300000,
  293. OutputTokens: 1000,
  294. }
  295. cost, err := svc.CalculateCost("gpt-5.4-2026-03-05", tokens, 1.0)
  296. require.NoError(t, err)
  297. expectedInput := float64(tokens.InputTokens) * 2.5e-6 * 2.0
  298. expectedOutput := float64(tokens.OutputTokens) * 15e-6 * 1.5
  299. expectedCacheRead := float64(tokens.CacheReadTokens) * 0.25e-6 * 2.0
  300. require.InDelta(t, expectedInput, cost.InputCost, 1e-10)
  301. require.InDelta(t, expectedOutput, cost.OutputCost, 1e-10)
  302. require.InDelta(t, expectedCacheRead, cost.CacheReadCost, 1e-10,
  303. "cache_read_cost should be scaled by LongContextInputMultiplier when long-context pricing applies (issue #2293)")
  304. expectedTotal := expectedInput + expectedOutput + expectedCacheRead
  305. require.InDelta(t, expectedTotal, cost.TotalCost, 1e-10)
  306. require.InDelta(t, expectedTotal, cost.ActualCost, 1e-10)
  307. }
  308. // 阴性测试:未触发长上下文时,cache_read_price 不应被错误地乘以倍率。
  309. func TestCalculateCost_OpenAIGPT54NoLongContextKeepsCacheReadAtBasePrice(t *testing.T) {
  310. svc := newTestBillingServiceWithOpenAILadderCatalog(t)
  311. // InputTokens + CacheReadTokens = 1000 + 100000 = 101000 < 272000 阈值,不触发长上下文
  312. tokens := UsageTokens{
  313. InputTokens: 1000,
  314. CacheReadTokens: 100000,
  315. OutputTokens: 1000,
  316. }
  317. cost, err := svc.CalculateCost("gpt-5.4-2026-03-05", tokens, 1.0)
  318. require.NoError(t, err)
  319. expectedCacheRead := float64(tokens.CacheReadTokens) * 0.25e-6
  320. require.InDelta(t, expectedCacheRead, cost.CacheReadCost, 1e-10,
  321. "cache_read_cost should remain at base price when below long-context threshold")
  322. }
  323. // 回归测试 #2816 follow-up:长上下文计费触发时,cache_creation_tokens 也应应用
  324. // LongContextInputMultiplier。computeCacheCreationCost 直接读取 pricing.* 价格,
  325. // 不经过 computeTokenBreakdown 内的 inputPrice / cacheReadPrice 倍率修改,因此
  326. // 修复前 cache_creation 部分会按基础价计算,少计费用约 50%(默认倍率 2.0)。
  327. func TestCalculateCost_OpenAIGPT54LongContextAppliesMultiplierToCacheCreation(t *testing.T) {
  328. svc := newTestBillingServiceWithOpenAILadderCatalog(t)
  329. // InputTokens + CacheReadTokens = 1000 + 300000 = 301000 > 272000 阈值
  330. tokens := UsageTokens{
  331. InputTokens: 1000,
  332. CacheReadTokens: 300000,
  333. CacheCreationTokens: 10000,
  334. OutputTokens: 1000,
  335. }
  336. cost, err := svc.CalculateCost("gpt-5.4-2026-03-05", tokens, 1.0)
  337. require.NoError(t, err)
  338. // gpt-5.4 fallback: CacheCreationPricePerToken = 2.5e-6, LongContextInputMultiplier = 2.0
  339. expectedCacheCreation := float64(tokens.CacheCreationTokens) * 2.5e-6 * 2.0
  340. require.InDelta(t, expectedCacheCreation, cost.CacheCreationCost, 1e-10,
  341. "cache_creation_cost should be scaled by LongContextInputMultiplier when long-context pricing applies")
  342. }
  343. // 阴性测试:未触发长上下文时,cache_creation_price 不应被错误地乘以倍率。
  344. func TestCalculateCost_OpenAIGPT54NoLongContextKeepsCacheCreationAtBasePrice(t *testing.T) {
  345. svc := newTestBillingServiceWithOpenAILadderCatalog(t)
  346. // InputTokens + CacheReadTokens = 1000 + 100000 = 101000 < 272000 阈值,不触发长上下文
  347. tokens := UsageTokens{
  348. InputTokens: 1000,
  349. CacheReadTokens: 100000,
  350. CacheCreationTokens: 10000,
  351. OutputTokens: 1000,
  352. }
  353. cost, err := svc.CalculateCost("gpt-5.4-2026-03-05", tokens, 1.0)
  354. require.NoError(t, err)
  355. expectedCacheCreation := float64(tokens.CacheCreationTokens) * 2.5e-6
  356. require.InDelta(t, expectedCacheCreation, cost.CacheCreationCost, 1e-10,
  357. "cache_creation_cost should remain at base price when below long-context threshold")
  358. }
  359. // 覆盖 5m / 1h ephemeral 分类计费路径:长上下文触发时两档价格都应被倍率缩放。
  360. // 使用手工构造的 pricing(参考 TestCalculateCost_SupportsCacheBreakdown 的写法)
  361. // 以便同时控制 SupportsCacheBreakdown + 长上下文阈值。
  362. func TestCalculateCost_LongContextAppliesMultiplierToCacheCreation5mAnd1h(t *testing.T) {
  363. svc := &BillingService{
  364. cfg: &config.Config{},
  365. fallbackPrices: map[string]*ModelPricing{
  366. "claude-sonnet-4": {
  367. InputPricePerToken: 3e-6,
  368. OutputPricePerToken: 15e-6,
  369. CacheReadPricePerToken: 0.3e-6,
  370. SupportsCacheBreakdown: true,
  371. CacheCreation5mPrice: 4e-6,
  372. CacheCreation1hPrice: 5e-6,
  373. LongContextInputThreshold: 272000,
  374. LongContextInputMultiplier: 2.0,
  375. LongContextOutputMultiplier: 1.5,
  376. },
  377. },
  378. }
  379. // InputTokens + CacheReadTokens = 1000 + 300000 = 301000 > 272000 阈值
  380. tokens := UsageTokens{
  381. InputTokens: 1000,
  382. CacheReadTokens: 300000,
  383. CacheCreation5mTokens: 8000,
  384. CacheCreation1hTokens: 4000,
  385. OutputTokens: 1000,
  386. }
  387. cost, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0)
  388. require.NoError(t, err)
  389. expected5m := float64(tokens.CacheCreation5mTokens) * 4e-6 * 2.0
  390. expected1h := float64(tokens.CacheCreation1hTokens) * 5e-6 * 2.0
  391. require.InDelta(t, expected5m+expected1h, cost.CacheCreationCost, 1e-10,
  392. "both 5m and 1h cache_creation prices should be scaled by LongContextInputMultiplier")
  393. }
  394. func TestGetFallbackPricing_FamilyMatching(t *testing.T) {
  395. svc := newTestBillingService()
  396. floatPtr := func(v float64) *float64 { return &v }
  397. // expectedOutput / expectedCacheRead 为 nil 时跳过该字段断言(保持与原有用例兼容)。
  398. tests := []struct {
  399. name string
  400. model string
  401. expectedInput float64
  402. expectedOutput *float64
  403. expectedCacheRead *float64
  404. expectNilPricing bool
  405. }{
  406. {name: "empty model", model: " ", expectNilPricing: true},
  407. {name: "claude opus 4.6", model: "claude-opus-4.6-20260201", expectedInput: 5e-6},
  408. {name: "claude opus 4.5 alt separator", model: "claude-opus-4-5-20260101", expectedInput: 5e-6},
  409. {name: "claude generic model fallback sonnet", model: "claude-foo-bar", expectedInput: 3e-6},
  410. {name: "gemini explicit fallback", model: "gemini-3-1-pro", expectedInput: 2e-6},
  411. {name: "gemini unknown no fallback", model: "gemini-2.0-pro", expectNilPricing: true},
  412. {name: "openai gpt5.4", model: "gpt-5.4", expectedInput: 2.5e-6},
  413. {name: "openai gpt5.4 mini", model: "gpt-5.4-mini", expectedInput: 7.5e-7},
  414. {name: "openai gpt5.3 codex", model: "gpt-5.3-codex", expectedInput: 1.5e-6},
  415. {name: "openai gpt5.3 codex spark", model: "gpt-5.3-codex-spark", expectedInput: 1.5e-6},
  416. {name: "openai legacy gpt5.1 falls back to gpt5.4", model: "gpt-5.1", expectedInput: 2.5e-6},
  417. {name: "openai legacy gpt5.1 codex falls back to gpt5.3 codex", model: "gpt-5.1-codex", expectedInput: 1.5e-6},
  418. {name: "openai legacy codex mini latest falls back to gpt5.3 codex", model: "codex-mini-latest", expectedInput: 1.5e-6},
  419. {name: "openai unknown no fallback", model: "gpt-unknown-model", expectNilPricing: true},
  420. {
  421. name: "deepseek v4 pro",
  422. model: "deepseek-v4-pro",
  423. expectedInput: 6.6e-7,
  424. expectedOutput: floatPtr(1.98e-6),
  425. expectedCacheRead: floatPtr(2.2e-8),
  426. },
  427. {
  428. name: "deepseek v4 flash",
  429. model: "deepseek-v4-flash",
  430. expectedInput: 2.2e-7,
  431. expectedOutput: floatPtr(6.6e-7),
  432. expectedCacheRead: floatPtr(7e-9),
  433. },
  434. {
  435. name: "deepseek v4 flash vision exp",
  436. model: "deepseek-v4-flash-vision-exp",
  437. expectedInput: 2.2e-7,
  438. expectedOutput: floatPtr(6.6e-7),
  439. expectedCacheRead: floatPtr(7e-9),
  440. },
  441. {
  442. // deepseek-chat / deepseek-reasoner 已停止服务,统一按 flash 价兜底。
  443. name: "deepseek chat discontinued maps to flash",
  444. model: "deepseek-chat",
  445. expectedInput: 2.2e-7,
  446. expectedOutput: floatPtr(6.6e-7),
  447. expectedCacheRead: floatPtr(7e-9),
  448. },
  449. {
  450. name: "deepseek reasoner discontinued maps to flash",
  451. model: "deepseek-reasoner",
  452. expectedInput: 2.2e-7,
  453. expectedOutput: floatPtr(6.6e-7),
  454. expectedCacheRead: floatPtr(7e-9),
  455. },
  456. {
  457. name: "unknown deepseek maps to flash",
  458. model: "deepseek-foo",
  459. expectedInput: 2.2e-7,
  460. expectedOutput: floatPtr(6.6e-7),
  461. expectedCacheRead: floatPtr(7e-9),
  462. },
  463. // ---- 智谱 GLM(z.ai USD 口径)----
  464. {
  465. name: "glm 5.3 flagship",
  466. model: "glm-5.3",
  467. expectedInput: 1.4e-6,
  468. expectedOutput: floatPtr(4.4e-6),
  469. expectedCacheRead: floatPtr(0.26e-6),
  470. },
  471. {
  472. name: "glm 5.3 flash",
  473. model: "glm-5.3-flash",
  474. expectedInput: 0.15e-6,
  475. expectedOutput: floatPtr(0.5e-6),
  476. expectedCacheRead: floatPtr(0.03e-6),
  477. },
  478. {
  479. name: "glm 5.2 flagship",
  480. model: "glm-5.2",
  481. expectedInput: 1.4e-6,
  482. expectedOutput: floatPtr(4.4e-6),
  483. expectedCacheRead: floatPtr(0.26e-6),
  484. },
  485. {
  486. name: "glm 5.1 flagship",
  487. model: "glm-5.1",
  488. expectedInput: 1.4e-6,
  489. expectedOutput: floatPtr(4.4e-6),
  490. expectedCacheRead: floatPtr(0.26e-6),
  491. },
  492. {
  493. name: "glm 5 base",
  494. model: "glm-5",
  495. expectedInput: 1e-6,
  496. expectedOutput: floatPtr(3.2e-6),
  497. expectedCacheRead: floatPtr(0.2e-6),
  498. },
  499. {
  500. name: "glm 5 turbo",
  501. model: "glm-5-turbo",
  502. expectedInput: 1.2e-6,
  503. expectedOutput: floatPtr(4e-6),
  504. expectedCacheRead: floatPtr(0.24e-6),
  505. },
  506. {
  507. name: "glm 4.7",
  508. model: "glm-4.7",
  509. expectedInput: 0.6e-6,
  510. expectedOutput: floatPtr(2.2e-6),
  511. expectedCacheRead: floatPtr(0.11e-6),
  512. },
  513. {
  514. name: "glm 4.6",
  515. model: "glm-4.6",
  516. expectedInput: 0.6e-6,
  517. expectedOutput: floatPtr(2.2e-6),
  518. expectedCacheRead: floatPtr(0.11e-6),
  519. },
  520. {
  521. name: "glm 4.5",
  522. model: "glm-4.5",
  523. expectedInput: 0.6e-6,
  524. expectedOutput: floatPtr(2.2e-6),
  525. expectedCacheRead: floatPtr(0.11e-6),
  526. },
  527. {
  528. name: "glm 4.5-x premium",
  529. model: "glm-4.5-x",
  530. expectedInput: 2.2e-6,
  531. expectedOutput: floatPtr(8.9e-6),
  532. expectedCacheRead: floatPtr(0.45e-6),
  533. },
  534. {
  535. name: "glm 4.5-air lightweight",
  536. model: "glm-4.5-air",
  537. expectedInput: 0.2e-6,
  538. expectedOutput: floatPtr(1.1e-6),
  539. expectedCacheRead: floatPtr(0.03e-6),
  540. },
  541. {
  542. name: "glm 4.7-flashx",
  543. model: "glm-4.7-flashx",
  544. expectedInput: 0.07e-6,
  545. expectedOutput: floatPtr(0.4e-6),
  546. expectedCacheRead: floatPtr(0.01e-6),
  547. },
  548. {
  549. name: "glm 4.5-flash free tier",
  550. model: "glm-4.5-flash",
  551. expectedInput: 0, // Free tier on z.ai
  552. expectedOutput: floatPtr(0),
  553. expectedCacheRead: floatPtr(0),
  554. },
  555. {
  556. name: "glm 4.7-flash free tier",
  557. model: "glm-4.7-flash",
  558. expectedInput: 0,
  559. expectedOutput: floatPtr(0),
  560. expectedCacheRead: floatPtr(0),
  561. },
  562. {
  563. name: "glm 4-32b legacy",
  564. model: "glm-4-32b-0414-128k",
  565. expectedInput: 0.1e-6,
  566. expectedOutput: floatPtr(0.1e-6),
  567. },
  568. // 关键:5.1 / 5.2 / 5.3 必须先于 5 匹配(避免被 glm-5 抢走)
  569. {
  570. name: "glm 5.3-flash vs glm 5.3 ordering (verbatim 5.3-flash)",
  571. model: "glm-5.3-flash",
  572. expectedInput: 0.15e-6, // = glm-5.3-flash 价格(不是 glm-5.3 的 1.4e-6,更不是 glm-5 的 1e-6)
  573. expectedOutput: floatPtr(0.5e-6),
  574. expectedCacheRead: floatPtr(0.03e-6),
  575. },
  576. {
  577. name: "glm 5.3 vs glm 5 ordering (verbatim 5.3)",
  578. model: "glm-5.3",
  579. expectedInput: 1.4e-6, // = glm-5.3 价格(不是 glm-5 的 1e-6)
  580. expectedOutput: floatPtr(4.4e-6),
  581. expectedCacheRead: floatPtr(0.26e-6),
  582. },
  583. {
  584. name: "glm 5.1 vs glm 5 ordering (verbatim 5.1)",
  585. model: "glm-5.1",
  586. expectedInput: 1.4e-6, // = glm-5.1 价格
  587. expectedOutput: floatPtr(4.4e-6),
  588. expectedCacheRead: floatPtr(0.26e-6),
  589. },
  590. {
  591. name: "glm 5.2 vs glm 5 ordering (verbatim 5.2)",
  592. model: "glm-5.2",
  593. expectedInput: 1.4e-6, // = glm-5.2 价格(不是 glm-5 的 1e-6)
  594. expectedOutput: floatPtr(4.4e-6),
  595. expectedCacheRead: floatPtr(0.26e-6),
  596. },
  597. {
  598. name: "glm 4.5-air vs glm 4.5 ordering",
  599. model: "glm-4.5-air",
  600. expectedInput: 0.2e-6, // = glm-4.5-air 价格(不是 glm-4.5 的 0.6e-6)
  601. expectedOutput: floatPtr(1.1e-6),
  602. expectedCacheRead: floatPtr(0.03e-6),
  603. },
  604. // ---- 月之暗面 Kimi ----
  605. {
  606. name: "kimi k3 flagship",
  607. model: "kimi-k3",
  608. expectedInput: 3e-6,
  609. expectedOutput: floatPtr(15e-6),
  610. expectedCacheRead: floatPtr(0.30e-6),
  611. },
  612. {
  613. name: "kimi code bare alias k3",
  614. model: "k3",
  615. expectedInput: 3e-6,
  616. expectedOutput: floatPtr(15e-6),
  617. expectedCacheRead: floatPtr(0.30e-6),
  618. },
  619. {
  620. name: "kimi code bare alias k3-256k",
  621. model: "k3-256k",
  622. expectedInput: 3e-6,
  623. expectedOutput: floatPtr(15e-6),
  624. expectedCacheRead: floatPtr(0.30e-6),
  625. },
  626. {
  627. name: "kimi k3 path suffix moonshot",
  628. model: "moonshot/kimi-k3",
  629. expectedInput: 3e-6,
  630. expectedOutput: floatPtr(15e-6),
  631. expectedCacheRead: floatPtr(0.30e-6),
  632. },
  633. {
  634. name: "kimi code bare path suffix",
  635. model: "kimi-code/k3",
  636. expectedInput: 3e-6,
  637. expectedOutput: floatPtr(15e-6),
  638. expectedCacheRead: floatPtr(0.30e-6),
  639. },
  640. {
  641. name: "kimi k2.6 flagship",
  642. model: "kimi-k2.6",
  643. expectedInput: 0.95e-6,
  644. expectedOutput: floatPtr(4e-6),
  645. expectedCacheRead: floatPtr(0.15e-6),
  646. },
  647. {
  648. name: "kimi for coding explicit alias",
  649. model: "kimi-for-coding",
  650. expectedInput: 0.95e-6,
  651. expectedOutput: floatPtr(4e-6),
  652. expectedCacheRead: floatPtr(0.15e-6),
  653. },
  654. {
  655. name: "kimi k2.5",
  656. model: "kimi-k2.5",
  657. expectedInput: 0.60e-6,
  658. expectedOutput: floatPtr(3e-6),
  659. expectedCacheRead: floatPtr(0.098e-6),
  660. },
  661. {
  662. name: "kimi k2-thinking",
  663. model: "kimi-k2-thinking",
  664. expectedInput: 0.56e-6,
  665. expectedOutput: floatPtr(2.24e-6),
  666. expectedCacheRead: floatPtr(0.14e-6),
  667. },
  668. {
  669. name: "kimi k2 base",
  670. model: "kimi-k2",
  671. expectedInput: 0.56e-6,
  672. expectedOutput: floatPtr(2.24e-6),
  673. expectedCacheRead: floatPtr(0.14e-6),
  674. },
  675. // 关键:k2.6 / k2.5 / k2-thinking 必须先于 k2 匹配
  676. {
  677. name: "kimi k2.6 vs k2 ordering",
  678. model: "kimi-k2.6",
  679. expectedInput: 0.95e-6, // = k2.6 不是 k2 的 0.56e-6
  680. expectedOutput: floatPtr(4e-6),
  681. expectedCacheRead: floatPtr(0.15e-6),
  682. },
  683. {
  684. name: "kimi k2 thinking hyphenated variant",
  685. model: "kimi-k2-thinking-preview",
  686. expectedInput: 0.56e-6,
  687. expectedOutput: floatPtr(2.24e-6),
  688. expectedCacheRead: floatPtr(0.14e-6),
  689. },
  690. // ---- MiniMax M 系列 ----
  691. {
  692. name: "minimax m3",
  693. model: "minimax-m3",
  694. expectedInput: 0.60e-6,
  695. expectedOutput: floatPtr(2.40e-6),
  696. expectedCacheRead: floatPtr(0.12e-6),
  697. },
  698. {
  699. name: "minimax m3 long ctx boundary keep standard tier",
  700. model: "minimax-m3-long", // 仍按 standard tier (≤512K)
  701. expectedInput: 0.60e-6,
  702. expectedOutput: floatPtr(2.40e-6),
  703. expectedCacheRead: floatPtr(0.12e-6),
  704. },
  705. {
  706. name: "minimax m2.7",
  707. model: "minimax-m2.7",
  708. expectedInput: 0.30e-6,
  709. expectedOutput: floatPtr(1.20e-6),
  710. expectedCacheRead: floatPtr(0.06e-6),
  711. },
  712. {
  713. name: "minimax m2.7 highspeed",
  714. model: "minimax-m2.7-highspeed",
  715. expectedInput: 0.60e-6,
  716. expectedOutput: floatPtr(2.40e-6),
  717. expectedCacheRead: floatPtr(0.06e-6),
  718. },
  719. {
  720. name: "minimax m2.5",
  721. model: "minimax-m2.5",
  722. expectedInput: 0.30e-6,
  723. expectedOutput: floatPtr(1.20e-6),
  724. expectedCacheRead: floatPtr(0.03e-6),
  725. },
  726. {
  727. name: "minimax m2 legacy",
  728. model: "minimax-m2",
  729. expectedInput: 0.30e-6,
  730. expectedOutput: floatPtr(1.20e-6),
  731. expectedCacheRead: floatPtr(0.03e-6),
  732. },
  733. // ---- 火山方舟 豆包 Embedding(多模态向量化)----
  734. {
  735. name: "doubao embedding vision text rate",
  736. model: "doubao-embedding-vision",
  737. expectedInput: 0.098e-6,
  738. expectedOutput: floatPtr(0),
  739. },
  740. {
  741. name: "doubao embedding vision versioned alias",
  742. model: "doubao-embedding-vision-251215",
  743. expectedInput: 0.098e-6,
  744. },
  745. // ---- 负向用例 ----
  746. {name: "qwen unknown no fallback", model: "qwen-max", expectNilPricing: true},
  747. // doubao-pro / doubao-embedding(纯文本)不在白名单,不回退;仅 doubao-embedding-vision 显式命中。
  748. {name: "doubao unknown no fallback", model: "doubao-pro", expectNilPricing: true},
  749. {name: "doubao text embedding no fallback", model: "doubao-embedding-text-240515", expectNilPricing: true},
  750. {name: "hunyuan unknown no fallback", model: "hunyuan-t1", expectNilPricing: true},
  751. {name: "moonshot v1 not covered", model: "moonshot-v1-8k", expectNilPricing: true},
  752. // bare k3 仅精确/后缀匹配:相似未知型号不得因含 "k3" 误命中。
  753. {name: "k3-like unknown no fallback", model: "foo-k3-bar", expectNilPricing: true},
  754. // 路径最后一段不是 /k3:foo-k3 不得因 HasSuffix("/k3") 或 Contains 误命中。
  755. {name: "path segment not bare k3 no fallback", model: "vendor/foo-k3", expectNilPricing: true},
  756. // kimi-k3 非 Contains:kimi-k30 / 内嵌 foo-kimi-k3-bar 不得误命中。
  757. {name: "kimi-k30 unknown no fallback", model: "kimi-k30", expectNilPricing: true},
  758. {name: "embedded kimi-k3 unknown no fallback", model: "foo-kimi-k3-bar", expectNilPricing: true},
  759. // kimi-k3[1m] 是 Claude Code 上下文选择语法,不是 Kimi API 模型 ID,不命中 fallback。
  760. {name: "kimi-k3[1m] not an API model id no fallback", model: "kimi-k3[1m]", expectNilPricing: true},
  761. {name: "path kimi-k3[1m] not an API model id no fallback", model: "moonshot/kimi-k3[1m]", expectNilPricing: true},
  762. // kimi-k2-0905 / kimi-k2-0711 官方未公布独立价,走 kimi-k2 隐性回退(接受)——
  763. // 如未来官方公布独立价,需在 getFallbackPricing 加显式分支。
  764. {
  765. name: "kimi k2-0905-preview implicit fallback to k2",
  766. model: "kimi-k2-0905-preview",
  767. expectedInput: 0.56e-6,
  768. expectedOutput: floatPtr(2.24e-6),
  769. expectedCacheRead: floatPtr(0.14e-6),
  770. },
  771. }
  772. for _, tt := range tests {
  773. t.Run(tt.name, func(t *testing.T) {
  774. pricing := svc.getFallbackPricing(tt.model)
  775. if tt.expectNilPricing {
  776. require.Nil(t, pricing)
  777. return
  778. }
  779. require.NotNil(t, pricing)
  780. require.InDelta(t, tt.expectedInput, pricing.InputPricePerToken, 1e-12)
  781. if tt.expectedOutput != nil {
  782. require.InDelta(t, *tt.expectedOutput, pricing.OutputPricePerToken, 1e-12,
  783. "OutputPricePerToken mismatch for %s", tt.model)
  784. }
  785. if tt.expectedCacheRead != nil {
  786. require.InDelta(t, *tt.expectedCacheRead, pricing.CacheReadPricePerToken, 1e-14,
  787. "CacheReadPricePerToken mismatch for %s", tt.model)
  788. }
  789. })
  790. }
  791. }
  792. // doubao-embedding-vision 是首个图文不同价的 embedding:文本 ¥0.7/MTok、图片 ¥1.8/MTok。
  793. // 验证回退表同时携带文本与图片两档单价,且能被带版本后缀 / 大小写别名命中。
  794. func TestGetModelPricing_DoubaoEmbeddingVisionImageInputRate(t *testing.T) {
  795. svc := newTestBillingService()
  796. for _, model := range []string{
  797. "doubao-embedding-vision",
  798. "doubao-embedding-vision-251215",
  799. "Doubao-Embedding-Vision",
  800. } {
  801. pricing, err := svc.GetModelPricing(model)
  802. require.NoError(t, err, "model %s should resolve fallback pricing", model)
  803. require.NotNil(t, pricing)
  804. require.InDelta(t, 0.098e-6, pricing.InputPricePerToken, 1e-12, "text input rate for %s", model)
  805. require.InDelta(t, 0.252e-6, pricing.ImageInputPricePerToken, 1e-12, "image input rate for %s", model)
  806. require.Zero(t, pricing.OutputPricePerToken, "embedding has no output cost for %s", model)
  807. }
  808. }
  809. // 验证双档计费:InputCost = 文本token×文本价(不含图片),ImageInputCost = 图片token×图片价;
  810. // 且 ImageInputTokens=0 时走原单价路径,ImageInputTokens>InputTokens 时不负计文本。
  811. func TestCalculateCost_DoubaoEmbeddingVisionDifferentialInput(t *testing.T) {
  812. svc := newTestBillingService()
  813. // 图文混合:prompt_tokens=1340,其中 image_tokens=28、text_tokens=1312。
  814. mixed := UsageTokens{InputTokens: 1340, ImageInputTokens: 28}
  815. cost, err := svc.CalculateCost("doubao-embedding-vision", mixed, 1.0)
  816. require.NoError(t, err)
  817. wantText := float64(1312) * 0.098e-6
  818. wantImage := float64(28) * 0.252e-6
  819. require.InDelta(t, wantText, cost.InputCost, 1e-15, "InputCost 仅计文本输入")
  820. require.InDelta(t, wantImage, cost.ImageInputCost, 1e-15, "ImageInputCost 单独计图片输入")
  821. require.InDelta(t, wantText+wantImage, cost.TotalCost, 1e-15, "TotalCost 口径不变")
  822. require.Zero(t, cost.OutputCost)
  823. // 纯文本:全部按文本档计费,与原单价路径一致,无图片输入费用。
  824. textOnly := UsageTokens{InputTokens: 1340}
  825. costText, err := svc.CalculateCost("doubao-embedding-vision", textOnly, 1.0)
  826. require.NoError(t, err)
  827. require.InDelta(t, float64(1340)*0.098e-6, costText.InputCost, 1e-15)
  828. require.Zero(t, costText.ImageInputCost)
  829. // 健壮性:ImageInputTokens 超过 InputTokens 时,文本置 0、计费 token 不超过 InputTokens。
  830. weird := UsageTokens{InputTokens: 10, ImageInputTokens: 50}
  831. costWeird, err := svc.CalculateCost("doubao-embedding-vision", weird, 1.0)
  832. require.NoError(t, err)
  833. require.Zero(t, costWeird.InputCost, "全为图片输入时文本费用为 0")
  834. require.InDelta(t, float64(10)*0.252e-6, costWeird.ImageInputCost, 1e-15)
  835. require.InDelta(t, float64(10)*0.252e-6, costWeird.TotalCost, 1e-15)
  836. }
  837. // 复现 issue #4386:gpt-image-2 /v1/images/edits 带 1 张输入图。
  838. // 上游 usage:input_tokens=371(image_tokens=352 + text_tokens=19),
  839. // output_tokens=439(全部图片输出)。官方定价:文本输入 $5/1M、图片输入 $8/1M、
  840. // 文本输出 $10/1M、图片输出 $30/1M。修复前图片输入被并入文本价,单次偏低 ~6.6%。
  841. func TestComputeTokenBreakdown_GptImage2ImageEditIssue4386(t *testing.T) {
  842. svc := newTestBillingService()
  843. pricing := &ModelPricing{
  844. InputPricePerToken: 5e-6,
  845. ImageInputPricePerToken: 8e-6,
  846. OutputPricePerToken: 10e-6,
  847. ImageOutputPricePerToken: 30e-6,
  848. ImageOutputPriceExplicit: true,
  849. }
  850. tokens := UsageTokens{
  851. InputTokens: 371,
  852. ImageInputTokens: 352,
  853. OutputTokens: 439,
  854. ImageOutputTokens: 439,
  855. }
  856. cost := svc.computeTokenBreakdown(pricing, tokens, 1.0, "", false)
  857. wantTextInput := float64(19) * 5e-6 // 0.000095
  858. wantImageInput := float64(352) * 8e-6 // 0.002816
  859. wantImageOutput := float64(439) * 30e-6 // 0.013170
  860. require.InDelta(t, wantTextInput, cost.InputCost, 1e-15, "InputCost 仅含文本输入")
  861. require.InDelta(t, wantImageInput, cost.ImageInputCost, 1e-15, "图片输入按 $8/1M 独立计费")
  862. require.Zero(t, cost.OutputCost, "输出全部为图片,文本输出费用为 0")
  863. require.InDelta(t, wantImageOutput, cost.ImageOutputCost, 1e-15)
  864. require.InDelta(t, 0.016081, cost.TotalCost, 1e-9, "总额应为 $0.016081(修复前为 $0.015025)")
  865. }
  866. func TestCalculateImageCost(t *testing.T) {
  867. svc := newTestBillingService()
  868. price := 0.134
  869. cfg := &ImagePriceConfig{Price1K: &price}
  870. cost := svc.CalculateImageCost("gpt-image-1", "1K", 3, cfg, 1.0)
  871. require.InDelta(t, 0.134*3, cost.TotalCost, 1e-10)
  872. require.InDelta(t, 0.134*3, cost.ActualCost, 1e-10)
  873. }
  874. func TestCalculateVideoCostUsesSeparateConfig(t *testing.T) {
  875. svc := newTestBillingService()
  876. imagePrice := 0.4
  877. videoPrice := 0.08
  878. imageCost := svc.CalculateImageCost("grok-imagine-video", "2K", 1, &ImagePriceConfig{Price2K: &imagePrice}, 1.0)
  879. videoCost := svc.CalculateVideoCost("grok-imagine-video", "480p", 1, 10, &VideoPriceConfig{Price480P: &videoPrice}, 0.5)
  880. require.InDelta(t, 0.4, imageCost.TotalCost, 1e-10)
  881. require.InDelta(t, 0.8, videoCost.TotalCost, 1e-10)
  882. require.InDelta(t, 0.4, videoCost.ActualCost, 1e-10)
  883. require.Equal(t, string(BillingModeVideo), videoCost.BillingMode)
  884. }
  885. func TestCalculateVideoCostBillsPerSecond(t *testing.T) {
  886. svc := newTestBillingService()
  887. oneSecond := svc.CalculateVideoCost("grok-imagine-video", "720p", 1, 1, nil, 1.0)
  888. fifteenSeconds := svc.CalculateVideoCost("grok-imagine-video", "720p", 1, 15, nil, 1.0)
  889. // duration <=0 时按上游默认 8 秒计费,超出上限按 15 秒收敛。
  890. defaultDuration := svc.CalculateVideoCost("grok-imagine-video", "720p", 1, 0, nil, 1.0)
  891. clampedDuration := svc.CalculateVideoCost("grok-imagine-video", "720p", 1, 999, nil, 1.0)
  892. require.InDelta(t, 0.07, oneSecond.TotalCost, 1e-10)
  893. require.InDelta(t, 0.07*15, fifteenSeconds.TotalCost, 1e-10)
  894. require.InDelta(t, 0.07*8, defaultDuration.TotalCost, 1e-10)
  895. require.InDelta(t, 0.07*15, clampedDuration.TotalCost, 1e-10)
  896. }
  897. func TestCalculateGrokImagineImageCostUsesDefaultRateCard(t *testing.T) {
  898. svc := newTestBillingService()
  899. standard1K := svc.CalculateImageCost("grok-imagine-image", "1K", 1, nil, 1.0)
  900. standard2K := svc.CalculateImageCost("grok-imagine-image", "2K", 1, nil, 1.0)
  901. quality1K := svc.CalculateImageCost("grok-imagine-image-quality", "1K", 1, nil, 1.0)
  902. quality2K := svc.CalculateImageCost("grok-imagine-image-quality", "2K", 1, nil, 1.0)
  903. require.InDelta(t, 0.02, standard1K.TotalCost, 1e-10)
  904. require.InDelta(t, 0.02, standard2K.TotalCost, 1e-10)
  905. require.InDelta(t, 0.05, quality1K.TotalCost, 1e-10)
  906. require.InDelta(t, 0.07, quality2K.TotalCost, 1e-10)
  907. }
  908. func TestCalculateGrokImagineVideoCostUsesDefaultRateCard(t *testing.T) {
  909. svc := newTestBillingService()
  910. // 默认价目为 xAI 官方每秒价格,按 1 秒时长验证每秒单价。
  911. standard480P := svc.CalculateVideoCost("grok-imagine-video", "480p", 1, 1, nil, 1.0)
  912. standard720P := svc.CalculateVideoCost("grok-imagine-video", "720p", 1, 1, nil, 1.0)
  913. video15_480P := svc.CalculateVideoCost("grok-imagine-video-1.5", "480p", 1, 1, nil, 1.0)
  914. video15_720P := svc.CalculateVideoCost("grok-imagine-video-1.5", "720p", 1, 1, nil, 1.0)
  915. video15_1080P := svc.CalculateVideoCost("grok-imagine-video-1.5", "1080p", 1, 1, nil, 1.0)
  916. require.InDelta(t, 0.05, standard480P.TotalCost, 1e-10)
  917. require.InDelta(t, 0.07, standard720P.TotalCost, 1e-10)
  918. require.InDelta(t, 0.08, video15_480P.TotalCost, 1e-10)
  919. require.InDelta(t, 0.14, video15_720P.TotalCost, 1e-10)
  920. require.InDelta(t, 0.25, video15_1080P.TotalCost, 1e-10)
  921. }
  922. func TestIsModelSupported(t *testing.T) {
  923. svc := newTestBillingService()
  924. require.True(t, svc.IsModelSupported("claude-sonnet-4"))
  925. require.True(t, svc.IsModelSupported("Claude-Opus-4.5"))
  926. require.True(t, svc.IsModelSupported("claude-3-haiku"))
  927. require.False(t, svc.IsModelSupported("gpt-4o"))
  928. require.False(t, svc.IsModelSupported("gemini-pro"))
  929. }
  930. func TestCalculateCost_ZeroTokens(t *testing.T) {
  931. svc := newTestBillingService()
  932. cost, err := svc.CalculateCost("claude-sonnet-4", UsageTokens{}, 1.0)
  933. require.NoError(t, err)
  934. require.Equal(t, 0.0, cost.TotalCost)
  935. require.Equal(t, 0.0, cost.ActualCost)
  936. }
  937. func TestCalculateCostWithConfig(t *testing.T) {
  938. cfg := &config.Config{}
  939. cfg.Default.RateMultiplier = 1.5
  940. svc := NewBillingService(cfg, nil)
  941. tokens := UsageTokens{InputTokens: 1000, OutputTokens: 500}
  942. cost, err := svc.CalculateCostWithConfig("claude-sonnet-4", tokens)
  943. require.NoError(t, err)
  944. expected, _ := svc.CalculateCost("claude-sonnet-4", tokens, 1.5)
  945. require.InDelta(t, expected.ActualCost, cost.ActualCost, 1e-10)
  946. }
  947. func TestCalculateCostWithConfig_ZeroMultiplier(t *testing.T) {
  948. cfg := &config.Config{}
  949. cfg.Default.RateMultiplier = 0
  950. svc := NewBillingService(cfg, nil)
  951. tokens := UsageTokens{InputTokens: 1000}
  952. cost, err := svc.CalculateCostWithConfig("claude-sonnet-4", tokens)
  953. require.NoError(t, err)
  954. // 倍率 <=0 时默认 1.0
  955. expected, _ := svc.CalculateCost("claude-sonnet-4", tokens, 1.0)
  956. require.InDelta(t, expected.ActualCost, cost.ActualCost, 1e-10)
  957. }
  958. func TestGetEstimatedCost(t *testing.T) {
  959. svc := newTestBillingService()
  960. est, err := svc.GetEstimatedCost("claude-sonnet-4", 1000, 500)
  961. require.NoError(t, err)
  962. require.True(t, est > 0)
  963. }
  964. func TestListSupportedModels(t *testing.T) {
  965. svc := newTestBillingService()
  966. models := svc.ListSupportedModels()
  967. require.NotEmpty(t, models)
  968. require.GreaterOrEqual(t, len(models), 6)
  969. }
  970. func TestGetPricingServiceStatus_NilService(t *testing.T) {
  971. svc := newTestBillingService()
  972. status := svc.GetPricingServiceStatus()
  973. require.NotNil(t, status)
  974. require.Equal(t, "using fallback", status["last_updated"])
  975. }
  976. func TestForceUpdatePricing_NilService(t *testing.T) {
  977. svc := newTestBillingService()
  978. err := svc.ForceUpdatePricing()
  979. require.Error(t, err)
  980. require.Contains(t, err.Error(), "not initialized")
  981. }
  982. func TestGetModelPricing_Grok45OfficialFallback(t *testing.T) {
  983. svc := newTestBillingService()
  984. for _, model := range []string{"grok-4.5", "grok-4.5-latest"} {
  985. model := model
  986. t.Run(model, func(t *testing.T) {
  987. pricing, err := svc.GetModelPricing(model)
  988. require.NoError(t, err)
  989. require.InDelta(t, 2e-6, pricing.InputPricePerToken, 1e-12)
  990. require.InDelta(t, 6e-6, pricing.OutputPricePerToken, 1e-12)
  991. require.InDelta(t, 0.3e-6, pricing.CacheReadPricePerToken, 1e-12)
  992. require.False(t, pricing.SupportsCacheBreakdown)
  993. })
  994. }
  995. }
  996. func TestGetModelPricing_GrokBareAliasesUseGrok46(t *testing.T) {
  997. svc := newTestBillingService()
  998. for _, model := range []string{"grok", "grok-latest"} {
  999. pricing, err := svc.GetModelPricing(model)
  1000. require.NoError(t, err)
  1001. require.InDelta(t, 2e-6, pricing.InputPricePerToken, 1e-12)
  1002. require.InDelta(t, 0.5e-6, pricing.CacheReadPricePerToken, 1e-12)
  1003. require.InDelta(t, 6e-6, pricing.OutputPricePerToken, 1e-12)
  1004. }
  1005. }
  1006. func TestGetModelPricing_Grok46OfficialFallback(t *testing.T) {
  1007. svc := newTestBillingService()
  1008. for _, model := range []string{"grok-4.6", "grok-4.6-latest"} {
  1009. model := model
  1010. t.Run(model, func(t *testing.T) {
  1011. pricing, err := svc.GetModelPricing(model)
  1012. require.NoError(t, err)
  1013. require.InDelta(t, 2e-6, pricing.InputPricePerToken, 1e-12)
  1014. require.InDelta(t, 6e-6, pricing.OutputPricePerToken, 1e-12)
  1015. require.InDelta(t, 0.5e-6, pricing.CacheReadPricePerToken, 1e-12)
  1016. require.Equal(t, 200000, pricing.LongContextInputThreshold)
  1017. require.InDelta(t, 2.0, pricing.LongContextInputMultiplier, 1e-12)
  1018. require.InDelta(t, 2.0, pricing.LongContextOutputMultiplier, 1e-12)
  1019. require.False(t, pricing.SupportsCacheBreakdown)
  1020. })
  1021. }
  1022. }
  1023. func TestGetModelPricing_GrokOfficialFamilyCards(t *testing.T) {
  1024. svc := newTestBillingService()
  1025. for _, tc := range []struct {
  1026. model string
  1027. input, cached, output float64
  1028. }{
  1029. {"grok-4.3", 1.25e-6, 0.2e-6, 2.5e-6},
  1030. {"grok-4.20-0309-reasoning", 1.25e-6, 0.2e-6, 2.5e-6},
  1031. {"grok-build-0.1", 1e-6, 0.2e-6, 2e-6},
  1032. } {
  1033. p, err := svc.GetModelPricing(tc.model)
  1034. require.NoError(t, err, tc.model)
  1035. require.InDelta(t, tc.input, p.InputPricePerToken, 1e-12)
  1036. require.InDelta(t, tc.cached, p.CacheReadPricePerToken, 1e-12)
  1037. require.InDelta(t, tc.output, p.OutputPricePerToken, 1e-12)
  1038. require.Equal(t, 200000, p.LongContextInputThreshold)
  1039. }
  1040. }
  1041. func TestCalculateCostUnified_GroupLongContextToggleUsesPresetLadder(t *testing.T) {
  1042. svc := newTestBillingService()
  1043. resolver := NewModelPricingResolver(nil, svc)
  1044. tokens := UsageTokens{InputTokens: 250000, OutputTokens: 1000}
  1045. off := &Group{LongContextPricingEnabled: false}
  1046. disabled, err := svc.CalculateCostUnified(CostInput{
  1047. Model: "grok-4.5", Group: off, Tokens: tokens, RateMultiplier: 1, Resolver: resolver,
  1048. })
  1049. require.NoError(t, err)
  1050. on := &Group{LongContextPricingEnabled: true}
  1051. enabled, err := svc.CalculateCostUnified(CostInput{
  1052. Model: "grok-4.5", Group: on, Tokens: tokens, RateMultiplier: 1, Resolver: resolver,
  1053. })
  1054. require.NoError(t, err)
  1055. require.False(t, disabled.LongContextBillingApplied)
  1056. require.True(t, enabled.LongContextBillingApplied)
  1057. require.InDelta(t, disabled.InputCost*2, enabled.InputCost, 1e-12)
  1058. require.InDelta(t, disabled.OutputCost*2, enabled.OutputCost, 1e-12)
  1059. }
  1060. func TestGetModelPricing_UnknownGrokTextFallsBackToGrok46(t *testing.T) {
  1061. svc := newTestBillingService()
  1062. baseline, err := svc.GetModelPricing("grok-4.6")
  1063. require.NoError(t, err)
  1064. for _, model := range []string{"grok-5", "grok-5-latest", "x-ai/grok-7", "grok-4.7-beta"} {
  1065. pricing, err := svc.GetModelPricing(model)
  1066. require.NoError(t, err, "model %s", model)
  1067. require.InDelta(t, baseline.InputPricePerToken, pricing.InputPricePerToken, 1e-12, model)
  1068. require.InDelta(t, baseline.OutputPricePerToken, pricing.OutputPricePerToken, 1e-12, model)
  1069. require.InDelta(t, baseline.CacheReadPricePerToken, pricing.CacheReadPricePerToken, 1e-12, model)
  1070. }
  1071. // Per-unit media ids must not inherit the text card just because they carry
  1072. // a version number; they are billed by the image/video/audio paths instead.
  1073. for _, model := range []string{"grok-2-image-1212", "grok-2-audio", "grok-5-video", "x-ai/grok-6-image"} {
  1074. require.False(t, isGrokUnknownTextFamilyModel(model), "model %s", model)
  1075. }
  1076. // Multimodal chat models stay token billed.
  1077. require.True(t, isGrokUnknownTextFamilyModel("grok-2-vision-1212"))
  1078. for _, model := range []string{
  1079. "grok-imagine-image-3.0",
  1080. "grok-imagine-video-2",
  1081. "grok-voice-latest",
  1082. "grok-web-search",
  1083. "grok-x-search",
  1084. "grok-speech-1",
  1085. } {
  1086. _, err := svc.GetModelPricing(model)
  1087. require.Error(t, err, "non-text grok family %s must not inherit grok-4.5 token rates", model)
  1088. require.ErrorIs(t, err, ErrModelPricingUnavailable)
  1089. }
  1090. // Known cards stay on their own rate, not the 4.5 family floor.
  1091. build, err := svc.GetModelPricing("grok-build-0.1")
  1092. require.NoError(t, err)
  1093. require.InDelta(t, 1e-6, build.InputPricePerToken, 1e-12)
  1094. }
  1095. func TestGetModelPricing_GrokCatalogFallbacks(t *testing.T) {
  1096. svc := newTestBillingService()
  1097. tests := []struct {
  1098. name string
  1099. models []string
  1100. input float64
  1101. cacheRead float64
  1102. output float64
  1103. }{
  1104. {
  1105. name: "Grok 4.3 family",
  1106. models: []string{
  1107. "grok-4.3",
  1108. "grok-4.20-0309-reasoning",
  1109. "grok-4.20-0309-non-reasoning",
  1110. "grok-4.20-multi-agent-0309",
  1111. "grok-4.20-reasoning",
  1112. "grok-4.20-non-reasoning",
  1113. },
  1114. input: 1.25e-6,
  1115. cacheRead: 0.2e-6,
  1116. output: 2.5e-6,
  1117. },
  1118. {
  1119. name: "Grok coding and Composer family",
  1120. models: []string{
  1121. "grok-build",
  1122. "grok-build-0.1",
  1123. "grok-composer",
  1124. "grok-composer-2.5-fast",
  1125. "composer-2.5",
  1126. },
  1127. input: 1e-6,
  1128. cacheRead: 0.2e-6,
  1129. output: 2e-6,
  1130. },
  1131. }
  1132. for _, tt := range tests {
  1133. t.Run(tt.name, func(t *testing.T) {
  1134. for _, model := range tt.models {
  1135. pricing, err := svc.GetModelPricing(model)
  1136. require.NoError(t, err, "model %s", model)
  1137. require.InDelta(t, tt.input, pricing.InputPricePerToken, 1e-12, "model %s input", model)
  1138. require.InDelta(t, tt.cacheRead, pricing.CacheReadPricePerToken, 1e-12, "model %s cached input", model)
  1139. require.InDelta(t, tt.output, pricing.OutputPricePerToken, 1e-12, "model %s output", model)
  1140. }
  1141. })
  1142. }
  1143. }
  1144. func TestCalculateCost_SupportsCacheBreakdown(t *testing.T) {
  1145. svc := &BillingService{
  1146. cfg: &config.Config{},
  1147. fallbackPrices: map[string]*ModelPricing{
  1148. "claude-sonnet-4": {
  1149. InputPricePerToken: 3e-6,
  1150. OutputPricePerToken: 15e-6,
  1151. SupportsCacheBreakdown: true,
  1152. CacheCreation5mPrice: 4e-6, // per token
  1153. CacheCreation1hPrice: 5e-6, // per token
  1154. },
  1155. },
  1156. }
  1157. tokens := UsageTokens{
  1158. InputTokens: 1000,
  1159. OutputTokens: 500,
  1160. CacheCreation5mTokens: 100000,
  1161. CacheCreation1hTokens: 50000,
  1162. }
  1163. cost, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0)
  1164. require.NoError(t, err)
  1165. expected5m := float64(tokens.CacheCreation5mTokens) * 4e-6
  1166. expected1h := float64(tokens.CacheCreation1hTokens) * 5e-6
  1167. require.InDelta(t, expected5m+expected1h, cost.CacheCreationCost, 1e-10)
  1168. }
  1169. func TestComputeCacheCreationCost_CapsContradictoryBreakdownAtAggregate(t *testing.T) {
  1170. svc := &BillingService{}
  1171. pricing := &ModelPricing{
  1172. SupportsCacheBreakdown: true,
  1173. CacheCreation5mPrice: 1,
  1174. CacheCreation1hPrice: 1,
  1175. }
  1176. tokens := UsageTokens{
  1177. CacheCreationTokens: 463184,
  1178. CacheCreation5mTokens: 463184,
  1179. CacheCreation1hTokens: 463184,
  1180. }
  1181. cost := svc.computeCacheCreationCost(pricing, tokens, 0, 1)
  1182. require.Equal(t, float64(tokens.CacheCreationTokens), cost,
  1183. "billed cache-creation token equivalent must not exceed the positive aggregate")
  1184. }
  1185. func TestNormalizeCacheCreationBreakdown_BillingSafetyInvariant(t *testing.T) {
  1186. tests := []struct {
  1187. name string
  1188. tokens UsageTokens
  1189. want5m int
  1190. want1h int
  1191. }{
  1192. {
  1193. name: "preserves ratio when capping",
  1194. tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: 90, CacheCreation1hTokens: 60},
  1195. want5m: 60,
  1196. want1h: 40,
  1197. },
  1198. {
  1199. name: "details below aggregate unchanged",
  1200. tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: 30, CacheCreation1hTokens: 60},
  1201. want5m: 30,
  1202. want1h: 60,
  1203. },
  1204. {
  1205. name: "absent 5m detail unchanged",
  1206. tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation1hTokens: 60},
  1207. want5m: 0,
  1208. want1h: 60,
  1209. },
  1210. {
  1211. name: "absent 1h detail unchanged",
  1212. tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: 30},
  1213. want5m: 30,
  1214. want1h: 0,
  1215. },
  1216. {
  1217. name: "negative detail clamped",
  1218. tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: -50, CacheCreation1hTokens: 60},
  1219. want5m: 0,
  1220. want1h: 60,
  1221. },
  1222. {
  1223. name: "negative detail cannot hide oversized positive detail",
  1224. tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: -50, CacheCreation1hTokens: 150},
  1225. want5m: 0,
  1226. want1h: 100,
  1227. },
  1228. {
  1229. name: "integer boundary details capped without overflow",
  1230. tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: int(^uint(0) >> 1), CacheCreation1hTokens: int(^uint(0) >> 1)},
  1231. want5m: 50,
  1232. want1h: 50,
  1233. },
  1234. {
  1235. name: "integer boundary aggregate avoids float conversion overflow",
  1236. tokens: UsageTokens{CacheCreationTokens: int(^uint(0) >> 1), CacheCreation5mTokens: int(^uint(0) >> 1), CacheCreation1hTokens: 1},
  1237. want5m: int(^uint(0) >> 1),
  1238. want1h: 0,
  1239. },
  1240. {
  1241. name: "zero aggregate unchanged",
  1242. tokens: UsageTokens{CacheCreation5mTokens: 90, CacheCreation1hTokens: 60},
  1243. want5m: 90,
  1244. want1h: 60,
  1245. },
  1246. }
  1247. for _, tt := range tests {
  1248. t.Run(tt.name, func(t *testing.T) {
  1249. got5m, got1h := normalizeCacheCreationBreakdown(tt.tokens)
  1250. require.Equal(t, tt.want5m, got5m)
  1251. require.Equal(t, tt.want1h, got1h)
  1252. })
  1253. }
  1254. }
  1255. func TestComputeCacheCreationCost_PreservesZeroDetailFallback(t *testing.T) {
  1256. svc := &BillingService{}
  1257. pricing := &ModelPricing{
  1258. SupportsCacheBreakdown: true,
  1259. CacheCreation5mPrice: 4e-6,
  1260. CacheCreation1hPrice: 5e-6,
  1261. }
  1262. tests := []struct {
  1263. name string
  1264. tokens UsageTokens
  1265. }{
  1266. {name: "zero details", tokens: UsageTokens{CacheCreationTokens: 100}},
  1267. {name: "one negative detail", tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: -25}},
  1268. {name: "both negative details", tokens: UsageTokens{CacheCreationTokens: 100, CacheCreation5mTokens: -25, CacheCreation1hTokens: -75}},
  1269. }
  1270. for _, tt := range tests {
  1271. t.Run(tt.name, func(t *testing.T) {
  1272. cost := svc.computeCacheCreationCost(pricing, tt.tokens, 0, 1)
  1273. require.InDelta(t, 100*4e-6, cost, 1e-12)
  1274. })
  1275. }
  1276. }
  1277. func TestCalculateCost_LargeTokenCount(t *testing.T) {
  1278. svc := newTestBillingService()
  1279. tokens := UsageTokens{
  1280. InputTokens: 1_000_000,
  1281. OutputTokens: 1_000_000,
  1282. }
  1283. cost, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0)
  1284. require.NoError(t, err)
  1285. // Input: 1M * 3e-6 = $3, Output: 1M * 15e-6 = $15
  1286. require.InDelta(t, 3.0, cost.InputCost, 1e-6)
  1287. require.InDelta(t, 15.0, cost.OutputCost, 1e-6)
  1288. require.False(t, math.IsNaN(cost.TotalCost))
  1289. require.False(t, math.IsInf(cost.TotalCost, 0))
  1290. }
  1291. func TestServiceTierCostMultiplier(t *testing.T) {
  1292. require.InDelta(t, 2.0, serviceTierCostMultiplier("priority"), 1e-12)
  1293. require.InDelta(t, 2.0, serviceTierCostMultiplier(" Priority "), 1e-12)
  1294. require.InDelta(t, 2.0, serviceTierCostMultiplier("ultrafast"), 1e-12)
  1295. require.InDelta(t, 0.5, serviceTierCostMultiplier("flex"), 1e-12)
  1296. require.InDelta(t, 1.0, serviceTierCostMultiplier(""), 1e-12)
  1297. require.InDelta(t, 1.0, serviceTierCostMultiplier("default"), 1e-12)
  1298. }
  1299. func TestCalculateCostWithServiceTier_OpenAIPriorityUsesPriorityPricing(t *testing.T) {
  1300. svc := newTestBillingService()
  1301. tokens := UsageTokens{InputTokens: 100, OutputTokens: 50, CacheReadTokens: 20}
  1302. baseCost, err := svc.CalculateCost("gpt-5.1-codex", tokens, 1.0)
  1303. require.NoError(t, err)
  1304. priorityCost, err := svc.CalculateCostWithServiceTier("gpt-5.1-codex", tokens, 1.0, "priority")
  1305. require.NoError(t, err)
  1306. require.InDelta(t, baseCost.InputCost*2, priorityCost.InputCost, 1e-10)
  1307. require.InDelta(t, baseCost.OutputCost*2, priorityCost.OutputCost, 1e-10)
  1308. require.InDelta(t, baseCost.CacheReadCost*2, priorityCost.CacheReadCost, 1e-10)
  1309. require.InDelta(t, baseCost.TotalCost*2, priorityCost.TotalCost, 1e-10)
  1310. }
  1311. func TestCalculateCostWithServiceTier_FlexAppliesHalfMultiplier(t *testing.T) {
  1312. svc := newTestBillingService()
  1313. tokens := UsageTokens{InputTokens: 100, OutputTokens: 50, CacheCreationTokens: 40, CacheReadTokens: 20}
  1314. baseCost, err := svc.CalculateCost("gpt-5.4", tokens, 1.0)
  1315. require.NoError(t, err)
  1316. flexCost, err := svc.CalculateCostWithServiceTier("gpt-5.4", tokens, 1.0, "flex")
  1317. require.NoError(t, err)
  1318. require.InDelta(t, baseCost.InputCost*0.5, flexCost.InputCost, 1e-10)
  1319. require.InDelta(t, baseCost.OutputCost*0.5, flexCost.OutputCost, 1e-10)
  1320. require.InDelta(t, baseCost.CacheCreationCost*0.5, flexCost.CacheCreationCost, 1e-10)
  1321. require.InDelta(t, baseCost.CacheReadCost*0.5, flexCost.CacheReadCost, 1e-10)
  1322. require.InDelta(t, baseCost.TotalCost*0.5, flexCost.TotalCost, 1e-10)
  1323. }
  1324. func TestCalculateCostWithServiceTier_Gpt54MiniPriorityFallsBackToTierMultiplier(t *testing.T) {
  1325. svc := newTestBillingService()
  1326. tokens := UsageTokens{InputTokens: 120, OutputTokens: 30, CacheCreationTokens: 12, CacheReadTokens: 8}
  1327. baseCost, err := svc.CalculateCost("gpt-5.4-mini", tokens, 1.0)
  1328. require.NoError(t, err)
  1329. priorityCost, err := svc.CalculateCostWithServiceTier("gpt-5.4-mini", tokens, 1.0, "priority")
  1330. require.NoError(t, err)
  1331. require.InDelta(t, baseCost.InputCost*2, priorityCost.InputCost, 1e-10)
  1332. require.InDelta(t, baseCost.OutputCost*2, priorityCost.OutputCost, 1e-10)
  1333. require.InDelta(t, baseCost.CacheCreationCost*2, priorityCost.CacheCreationCost, 1e-10)
  1334. require.InDelta(t, baseCost.CacheReadCost*2, priorityCost.CacheReadCost, 1e-10)
  1335. require.InDelta(t, baseCost.TotalCost*2, priorityCost.TotalCost, 1e-10)
  1336. }
  1337. func TestCalculateCostWithServiceTier_Gpt54NanoFlexAppliesHalfMultiplier(t *testing.T) {
  1338. svc := newTestBillingService()
  1339. tokens := UsageTokens{InputTokens: 100, OutputTokens: 50, CacheCreationTokens: 40, CacheReadTokens: 20}
  1340. baseCost, err := svc.CalculateCost("gpt-5.4-nano", tokens, 1.0)
  1341. require.NoError(t, err)
  1342. flexCost, err := svc.CalculateCostWithServiceTier("gpt-5.4-nano", tokens, 1.0, "flex")
  1343. require.NoError(t, err)
  1344. require.InDelta(t, baseCost.InputCost*0.5, flexCost.InputCost, 1e-10)
  1345. require.InDelta(t, baseCost.OutputCost*0.5, flexCost.OutputCost, 1e-10)
  1346. require.InDelta(t, baseCost.CacheCreationCost*0.5, flexCost.CacheCreationCost, 1e-10)
  1347. require.InDelta(t, baseCost.CacheReadCost*0.5, flexCost.CacheReadCost, 1e-10)
  1348. require.InDelta(t, baseCost.TotalCost*0.5, flexCost.TotalCost, 1e-10)
  1349. }
  1350. func TestCalculateCostWithServiceTier_PriorityFallsBackToTierMultiplierWithoutExplicitPriorityPrice(t *testing.T) {
  1351. svc := newTestBillingService()
  1352. tokens := UsageTokens{InputTokens: 120, OutputTokens: 30, CacheCreationTokens: 12, CacheReadTokens: 8}
  1353. baseCost, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0)
  1354. require.NoError(t, err)
  1355. priorityCost, err := svc.CalculateCostWithServiceTier("claude-sonnet-4", tokens, 1.0, "priority")
  1356. require.NoError(t, err)
  1357. require.InDelta(t, baseCost.InputCost*2, priorityCost.InputCost, 1e-10)
  1358. require.InDelta(t, baseCost.OutputCost*2, priorityCost.OutputCost, 1e-10)
  1359. require.InDelta(t, baseCost.CacheCreationCost*2, priorityCost.CacheCreationCost, 1e-10)
  1360. require.InDelta(t, baseCost.CacheReadCost*2, priorityCost.CacheReadCost, 1e-10)
  1361. require.InDelta(t, baseCost.TotalCost*2, priorityCost.TotalCost, 1e-10)
  1362. }
  1363. func TestBillingServiceGetModelPricing_UsesDynamicPriorityFields(t *testing.T) {
  1364. pricingSvc := &PricingService{
  1365. pricingData: map[string]*LiteLLMModelPricing{
  1366. "gpt-5.4": {
  1367. InputCostPerToken: 2.5e-6,
  1368. InputCostPerTokenPriority: 5e-6,
  1369. OutputCostPerToken: 15e-6,
  1370. OutputCostPerTokenPriority: 30e-6,
  1371. CacheCreationInputTokenCost: 2.5e-6,
  1372. CacheReadInputTokenCost: 0.25e-6,
  1373. CacheReadInputTokenCostPriority: 0.5e-6,
  1374. LongContextInputTokenThreshold: 272000,
  1375. LongContextInputCostMultiplier: 2.0,
  1376. LongContextOutputCostMultiplier: 1.5,
  1377. },
  1378. },
  1379. }
  1380. svc := NewBillingService(&config.Config{}, pricingSvc)
  1381. pricing, err := svc.GetModelPricing("gpt-5.4")
  1382. require.NoError(t, err)
  1383. require.InDelta(t, 2.5e-6, pricing.InputPricePerToken, 1e-12)
  1384. require.InDelta(t, 5e-6, pricing.InputPricePerTokenPriority, 1e-12)
  1385. require.InDelta(t, 15e-6, pricing.OutputPricePerToken, 1e-12)
  1386. require.InDelta(t, 30e-6, pricing.OutputPricePerTokenPriority, 1e-12)
  1387. require.InDelta(t, 0.25e-6, pricing.CacheReadPricePerToken, 1e-12)
  1388. require.InDelta(t, 0.5e-6, pricing.CacheReadPricePerTokenPriority, 1e-12)
  1389. require.Equal(t, 272000, pricing.LongContextInputThreshold)
  1390. require.InDelta(t, 2.0, pricing.LongContextInputMultiplier, 1e-12)
  1391. require.InDelta(t, 1.5, pricing.LongContextOutputMultiplier, 1e-12)
  1392. }
  1393. func TestBillingServiceGetModelPricing_OpenAIFallbackGpt52Variants(t *testing.T) {
  1394. svc := newTestBillingService()
  1395. gpt52, err := svc.GetModelPricing("gpt-5.2")
  1396. require.NoError(t, err)
  1397. require.NotNil(t, gpt52)
  1398. require.InDelta(t, 1.75e-6, gpt52.InputPricePerToken, 1e-12)
  1399. require.InDelta(t, 3.5e-6, gpt52.InputPricePerTokenPriority, 1e-12)
  1400. gpt52Codex, err := svc.GetModelPricing("gpt-5.2-codex")
  1401. require.NoError(t, err)
  1402. require.NotNil(t, gpt52Codex)
  1403. require.InDelta(t, 1.75e-6, gpt52Codex.InputPricePerToken, 1e-12)
  1404. require.InDelta(t, 3.5e-6, gpt52Codex.InputPricePerTokenPriority, 1e-12)
  1405. require.InDelta(t, 28e-6, gpt52Codex.OutputPricePerTokenPriority, 1e-12)
  1406. }
  1407. func TestCalculateCostWithServiceTier_PriorityFallsBackToTierMultiplierWhenExplicitPriceMissing(t *testing.T) {
  1408. svc := NewBillingService(&config.Config{}, &PricingService{
  1409. pricingData: map[string]*LiteLLMModelPricing{
  1410. "custom-no-priority": {
  1411. InputCostPerToken: 1e-6,
  1412. OutputCostPerToken: 2e-6,
  1413. CacheCreationInputTokenCost: 0.5e-6,
  1414. CacheReadInputTokenCost: 0.25e-6,
  1415. },
  1416. },
  1417. })
  1418. tokens := UsageTokens{InputTokens: 100, OutputTokens: 50, CacheCreationTokens: 40, CacheReadTokens: 20}
  1419. baseCost, err := svc.CalculateCost("custom-no-priority", tokens, 1.0)
  1420. require.NoError(t, err)
  1421. priorityCost, err := svc.CalculateCostWithServiceTier("custom-no-priority", tokens, 1.0, "priority")
  1422. require.NoError(t, err)
  1423. require.InDelta(t, baseCost.InputCost*2, priorityCost.InputCost, 1e-10)
  1424. require.InDelta(t, baseCost.OutputCost*2, priorityCost.OutputCost, 1e-10)
  1425. require.InDelta(t, baseCost.CacheCreationCost*2, priorityCost.CacheCreationCost, 1e-10)
  1426. require.InDelta(t, baseCost.CacheReadCost*2, priorityCost.CacheReadCost, 1e-10)
  1427. require.InDelta(t, baseCost.TotalCost*2, priorityCost.TotalCost, 1e-10)
  1428. }
  1429. func TestGetModelPricing_OpenAIGpt52FallbacksExposePriorityPrices(t *testing.T) {
  1430. svc := newTestBillingService()
  1431. gpt52, err := svc.GetModelPricing("gpt-5.2")
  1432. require.NoError(t, err)
  1433. require.InDelta(t, 1.75e-6, gpt52.InputPricePerToken, 1e-12)
  1434. require.InDelta(t, 3.5e-6, gpt52.InputPricePerTokenPriority, 1e-12)
  1435. require.InDelta(t, 14e-6, gpt52.OutputPricePerToken, 1e-12)
  1436. require.InDelta(t, 28e-6, gpt52.OutputPricePerTokenPriority, 1e-12)
  1437. gpt52Codex, err := svc.GetModelPricing("gpt-5.2-codex")
  1438. require.NoError(t, err)
  1439. require.InDelta(t, 1.75e-6, gpt52Codex.InputPricePerToken, 1e-12)
  1440. require.InDelta(t, 3.5e-6, gpt52Codex.InputPricePerTokenPriority, 1e-12)
  1441. require.InDelta(t, 14e-6, gpt52Codex.OutputPricePerToken, 1e-12)
  1442. require.InDelta(t, 28e-6, gpt52Codex.OutputPricePerTokenPriority, 1e-12)
  1443. }
  1444. func TestGetModelPricing_MapsDynamicPriorityFieldsIntoBillingPricing(t *testing.T) {
  1445. svc := NewBillingService(&config.Config{}, &PricingService{
  1446. pricingData: map[string]*LiteLLMModelPricing{
  1447. "dynamic-tier-model": {
  1448. InputCostPerToken: 1e-6,
  1449. InputCostPerTokenPriority: 2e-6,
  1450. OutputCostPerToken: 3e-6,
  1451. OutputCostPerTokenPriority: 6e-6,
  1452. CacheCreationInputTokenCost: 4e-6,
  1453. CacheCreationInputTokenCostAbove1hr: 5e-6,
  1454. CacheReadInputTokenCost: 7e-7,
  1455. CacheReadInputTokenCostPriority: 8e-7,
  1456. LongContextInputTokenThreshold: 999,
  1457. LongContextInputCostMultiplier: 1.5,
  1458. LongContextOutputCostMultiplier: 1.25,
  1459. },
  1460. },
  1461. })
  1462. pricing, err := svc.GetModelPricing("dynamic-tier-model")
  1463. require.NoError(t, err)
  1464. require.InDelta(t, 1e-6, pricing.InputPricePerToken, 1e-12)
  1465. require.InDelta(t, 2e-6, pricing.InputPricePerTokenPriority, 1e-12)
  1466. require.InDelta(t, 3e-6, pricing.OutputPricePerToken, 1e-12)
  1467. require.InDelta(t, 6e-6, pricing.OutputPricePerTokenPriority, 1e-12)
  1468. require.InDelta(t, 4e-6, pricing.CacheCreation5mPrice, 1e-12)
  1469. require.InDelta(t, 5e-6, pricing.CacheCreation1hPrice, 1e-12)
  1470. require.True(t, pricing.SupportsCacheBreakdown)
  1471. require.InDelta(t, 7e-7, pricing.CacheReadPricePerToken, 1e-12)
  1472. require.InDelta(t, 8e-7, pricing.CacheReadPricePerTokenPriority, 1e-12)
  1473. require.Equal(t, 999, pricing.LongContextInputThreshold)
  1474. require.InDelta(t, 1.5, pricing.LongContextInputMultiplier, 1e-12)
  1475. require.InDelta(t, 1.25, pricing.LongContextOutputMultiplier, 1e-12)
  1476. }
  1477. // ---------------------------------------------------------------------------
  1478. // GetModelPricingWithChannel
  1479. // ---------------------------------------------------------------------------
  1480. func TestGetModelPricingWithChannel_NilChannelPricing_ReturnsOriginal(t *testing.T) {
  1481. svc := newTestBillingService()
  1482. pricing, err := svc.GetModelPricingWithChannel("claude-sonnet-4", nil)
  1483. require.NoError(t, err)
  1484. require.NotNil(t, pricing)
  1485. // Should be identical to GetModelPricing
  1486. original, err := svc.GetModelPricing("claude-sonnet-4")
  1487. require.NoError(t, err)
  1488. require.InDelta(t, original.InputPricePerToken, pricing.InputPricePerToken, 1e-12)
  1489. require.InDelta(t, original.OutputPricePerToken, pricing.OutputPricePerToken, 1e-12)
  1490. require.InDelta(t, original.CacheCreationPricePerToken, pricing.CacheCreationPricePerToken, 1e-12)
  1491. require.InDelta(t, original.CacheReadPricePerToken, pricing.CacheReadPricePerToken, 1e-12)
  1492. }
  1493. func TestGetModelPricingWithChannel_OverrideInputPriceOnly(t *testing.T) {
  1494. svc := newTestBillingService()
  1495. chPricing := &ChannelModelPricing{
  1496. InputPrice: testPtrFloat64(99e-6),
  1497. }
  1498. pricing, err := svc.GetModelPricingWithChannel("claude-sonnet-4", chPricing)
  1499. require.NoError(t, err)
  1500. // InputPrice overridden. claude-sonnet-4 has no catalog priority price, so
  1501. // the priority slot is zeroed and serviceTierCostMultiplier owns the surcharge.
  1502. require.InDelta(t, 99e-6, pricing.InputPricePerToken, 1e-12)
  1503. require.Zero(t, pricing.InputPricePerTokenPriority)
  1504. // OutputPrice unchanged (claude-sonnet-4 fallback = 15e-6)
  1505. require.InDelta(t, 15e-6, pricing.OutputPricePerToken, 1e-12)
  1506. }
  1507. func TestGetModelPricingWithChannel_OverrideOutputPriceOnly(t *testing.T) {
  1508. svc := newTestBillingService()
  1509. chPricing := &ChannelModelPricing{
  1510. OutputPrice: testPtrFloat64(88e-6),
  1511. }
  1512. pricing, err := svc.GetModelPricingWithChannel("claude-sonnet-4", chPricing)
  1513. require.NoError(t, err)
  1514. // OutputPrice overridden; no catalog priority price to scale, so the slot is zeroed.
  1515. require.InDelta(t, 88e-6, pricing.OutputPricePerToken, 1e-12)
  1516. require.Zero(t, pricing.OutputPricePerTokenPriority)
  1517. // InputPrice unchanged (claude-sonnet-4 fallback = 3e-6)
  1518. require.InDelta(t, 3e-6, pricing.InputPricePerToken, 1e-12)
  1519. }
  1520. func TestGetModelPricingWithChannel_OverrideAllFields(t *testing.T) {
  1521. svc := newTestBillingService()
  1522. chPricing := &ChannelModelPricing{
  1523. InputPrice: testPtrFloat64(10e-6),
  1524. OutputPrice: testPtrFloat64(20e-6),
  1525. CacheWritePrice: testPtrFloat64(5e-6),
  1526. CacheReadPrice: testPtrFloat64(1e-6),
  1527. ImageOutputPrice: testPtrFloat64(50e-6),
  1528. }
  1529. pricing, err := svc.GetModelPricingWithChannel("claude-sonnet-4", chPricing)
  1530. require.NoError(t, err)
  1531. require.InDelta(t, 10e-6, pricing.InputPricePerToken, 1e-12)
  1532. require.InDelta(t, 20e-6, pricing.OutputPricePerToken, 1e-12)
  1533. require.InDelta(t, 5e-6, pricing.CacheCreationPricePerToken, 1e-12)
  1534. require.InDelta(t, 5e-6, pricing.CacheCreation5mPrice, 1e-12)
  1535. require.InDelta(t, 5e-6, pricing.CacheCreation1hPrice, 1e-12)
  1536. require.InDelta(t, 1e-6, pricing.CacheReadPricePerToken, 1e-12)
  1537. require.InDelta(t, 50e-6, pricing.ImageOutputPricePerToken, 1e-12)
  1538. // claude-sonnet-4 carries no catalog Fast/Priority tier, so every priority
  1539. // slot stays zero and computeTokenBreakdown falls back to the 2x default.
  1540. require.Zero(t, pricing.InputPricePerTokenPriority)
  1541. require.Zero(t, pricing.OutputPricePerTokenPriority)
  1542. require.Zero(t, pricing.CacheReadPricePerTokenPriority)
  1543. }
  1544. func TestGetModelPricingWithChannel_CacheWritePriceAffects5mAnd1h(t *testing.T) {
  1545. svc := newTestBillingService()
  1546. chPricing := &ChannelModelPricing{
  1547. CacheWritePrice: testPtrFloat64(7e-6),
  1548. }
  1549. pricing, err := svc.GetModelPricingWithChannel("claude-sonnet-4", chPricing)
  1550. require.NoError(t, err)
  1551. // CacheWritePrice should set all three: CacheCreationPricePerToken, 5m, and 1h
  1552. require.InDelta(t, 7e-6, pricing.CacheCreationPricePerToken, 1e-12)
  1553. require.InDelta(t, 7e-6, pricing.CacheCreation5mPrice, 1e-12)
  1554. require.InDelta(t, 7e-6, pricing.CacheCreation1hPrice, 1e-12)
  1555. }
  1556. func TestGetModelPricingWithChannel_CacheWriteTTLPricesCanDiffer(t *testing.T) {
  1557. svc := newTestBillingService()
  1558. pricing, err := svc.GetModelPricingWithChannel("claude-fable-5-1", &ChannelModelPricing{
  1559. CacheWritePrice: testPtrFloat64(13e-6),
  1560. CacheWrite1hPrice: testPtrFloat64(21e-6),
  1561. })
  1562. require.NoError(t, err)
  1563. require.True(t, pricing.SupportsCacheBreakdown)
  1564. require.InDelta(t, 13e-6, pricing.CacheCreationPricePerToken, 1e-12)
  1565. require.InDelta(t, 13e-6, pricing.CacheCreation5mPrice, 1e-12)
  1566. require.InDelta(t, 21e-6, pricing.CacheCreation1hPrice, 1e-12)
  1567. }
  1568. func TestGetModelPricing_Fable51FallbackPricing(t *testing.T) {
  1569. svc := newTestBillingService()
  1570. pricing, err := svc.GetModelPricing("claude-fable-5-1")
  1571. require.NoError(t, err)
  1572. require.InDelta(t, 10e-6, pricing.InputPricePerToken, 1e-12)
  1573. require.InDelta(t, 50e-6, pricing.OutputPricePerToken, 1e-12)
  1574. require.InDelta(t, 12.5e-6, pricing.CacheCreation5mPrice, 1e-12)
  1575. require.InDelta(t, 20e-6, pricing.CacheCreation1hPrice, 1e-12)
  1576. require.InDelta(t, 0.25e-6, pricing.CacheReadPricePerToken, 1e-12)
  1577. require.NotNil(t, pricing.MaxReasoningEffortMultiplier)
  1578. require.Equal(t, 3.0, *pricing.MaxReasoningEffortMultiplier)
  1579. }
  1580. func TestGetModelPricingWithChannel_CacheReadPriceAffectsPriority(t *testing.T) {
  1581. svc := newTestBillingService()
  1582. chPricing := &ChannelModelPricing{
  1583. CacheReadPrice: testPtrFloat64(2e-6),
  1584. }
  1585. pricing, err := svc.GetModelPricingWithChannel("claude-sonnet-4", chPricing)
  1586. require.NoError(t, err)
  1587. // CacheReadPrice sets the standard slot; the priority slot is zeroed because
  1588. // claude-sonnet-4 has no catalog tier ratio to preserve.
  1589. require.InDelta(t, 2e-6, pricing.CacheReadPricePerToken, 1e-12)
  1590. require.Zero(t, pricing.CacheReadPricePerTokenPriority)
  1591. }
  1592. // 目录带 tier 价时,渠道覆盖必须按目录比例换算 priority 价,而不是归零。
  1593. func TestGetModelPricingWithChannel_PreservesCatalogPriorityRatio(t *testing.T) {
  1594. svc := newTestBillingService()
  1595. // gpt-5.4 目录价:input 2.5/5(2x),output 15/30(2x)。
  1596. pricing, err := svc.GetModelPricingWithChannel("gpt-5.4", &ChannelModelPricing{
  1597. InputPrice: testPtrFloat64(4e-6),
  1598. OutputPrice: testPtrFloat64(30e-6),
  1599. })
  1600. require.NoError(t, err)
  1601. require.InDelta(t, 4e-6, pricing.InputPricePerToken, 1e-12)
  1602. require.InDelta(t, 8e-6, pricing.InputPricePerTokenPriority, 1e-12)
  1603. require.InDelta(t, 30e-6, pricing.OutputPricePerToken, 1e-12)
  1604. require.InDelta(t, 60e-6, pricing.OutputPricePerTokenPriority, 1e-12)
  1605. }
  1606. func TestGetModelPricingWithChannel_UnknownModelReturnsError(t *testing.T) {
  1607. svc := newTestBillingService()
  1608. chPricing := &ChannelModelPricing{
  1609. InputPrice: testPtrFloat64(1e-6),
  1610. }
  1611. pricing, err := svc.GetModelPricingWithChannel("totally-unknown-model", chPricing)
  1612. require.Error(t, err)
  1613. require.Nil(t, pricing)
  1614. require.Contains(t, err.Error(), "pricing not found")
  1615. }
  1616. func TestGetModelPricingWithChannel_NilImageOutputPriceZerosAndMarksExplicit(t *testing.T) {
  1617. svc := newTestBillingService()
  1618. chPricing := &ChannelModelPricing{
  1619. InputPrice: testPtrFloat64(10e-6),
  1620. OutputPrice: testPtrFloat64(20e-6),
  1621. // ImageOutputPrice intentionally nil
  1622. }
  1623. pricing, err := svc.GetModelPricingWithChannel("claude-sonnet-4", chPricing)
  1624. require.NoError(t, err)
  1625. require.Equal(t, 0.0, pricing.ImageOutputPricePerToken)
  1626. require.True(t, pricing.ImageOutputPriceExplicit)
  1627. }
  1628. func TestComputeTokenBreakdown_ExplicitZeroImagePrice_NoFallback(t *testing.T) {
  1629. svc := newTestBillingService()
  1630. pricing := &ModelPricing{
  1631. InputPricePerToken: 3e-6,
  1632. OutputPricePerToken: 15e-6,
  1633. ImageOutputPricePerToken: 0,
  1634. ImageOutputPriceExplicit: true,
  1635. }
  1636. tokens := UsageTokens{
  1637. InputTokens: 100,
  1638. OutputTokens: 200,
  1639. ImageOutputTokens: 50,
  1640. }
  1641. bd := svc.computeTokenBreakdown(pricing, tokens, 1.0, "", false)
  1642. // ImageOutputTokens should NOT fall back to outputPrice
  1643. require.Equal(t, 0.0, bd.ImageOutputCost)
  1644. // textOutputTokens = 200 - 50 = 150
  1645. require.InDelta(t, 150*15e-6, bd.OutputCost, 1e-12)
  1646. }
  1647. func TestComputeTokenBreakdown_NonExplicitZeroImagePrice_FallsBackToOutput(t *testing.T) {
  1648. svc := newTestBillingService()
  1649. pricing := &ModelPricing{
  1650. InputPricePerToken: 3e-6,
  1651. OutputPricePerToken: 15e-6,
  1652. ImageOutputPricePerToken: 0,
  1653. ImageOutputPriceExplicit: false,
  1654. }
  1655. tokens := UsageTokens{
  1656. InputTokens: 100,
  1657. OutputTokens: 200,
  1658. ImageOutputTokens: 50,
  1659. }
  1660. bd := svc.computeTokenBreakdown(pricing, tokens, 1.0, "", false)
  1661. // Should fall back to outputPrice since not explicit
  1662. require.InDelta(t, 50*15e-6, bd.ImageOutputCost, 1e-12)
  1663. // textOutputTokens = 200 - 50 = 150
  1664. require.InDelta(t, 150*15e-6, bd.OutputCost, 1e-12)
  1665. }