pricing_service_test.go 47 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103
  1. package service
  2. import (
  3. "encoding/json"
  4. "os"
  5. "path/filepath"
  6. "testing"
  7. "time"
  8. "github.com/Wei-Shaw/sub2api/internal/config"
  9. "github.com/stretchr/testify/require"
  10. )
  11. func TestPricingSchedulerBlankRemoteURLDoesNotStart(t *testing.T) {
  12. svc := NewPricingService(&config.Config{Pricing: config.PricingConfig{RemoteURL: " \t "}}, nil)
  13. defer svc.Stop()
  14. svc.startUpdateScheduler()
  15. done := make(chan struct{})
  16. go func() {
  17. svc.wg.Wait()
  18. close(done)
  19. }()
  20. select {
  21. case <-done:
  22. case <-time.After(100 * time.Millisecond):
  23. t.Fatal("blank remote URL must not start scheduler")
  24. }
  25. }
  26. func TestPricingNonEmptyInvalidRemoteURLStillReturnsValidationError(t *testing.T) {
  27. svc := NewPricingService(&config.Config{Pricing: config.PricingConfig{
  28. RemoteURL: "://invalid",
  29. DataDir: t.TempDir(),
  30. }}, nil)
  31. err := svc.ForceUpdate()
  32. require.Error(t, err)
  33. require.Contains(t, err.Error(), "invalid pricing url")
  34. }
  35. func TestParsePricingData_ParsesPriorityAndServiceTierFields(t *testing.T) {
  36. svc := &PricingService{}
  37. body := []byte(`{
  38. "gpt-5.4": {
  39. "input_cost_per_token": 0.0000025,
  40. "input_cost_per_token_priority": 0.000005,
  41. "output_cost_per_token": 0.000015,
  42. "output_cost_per_token_priority": 0.00003,
  43. "cache_creation_input_token_cost": 0.0000025,
  44. "cache_creation_input_token_cost_priority": 0.000005,
  45. "cache_read_input_token_cost": 0.00000025,
  46. "cache_read_input_token_cost_priority": 0.0000005,
  47. "long_context_input_token_threshold": 272000,
  48. "long_context_input_cost_multiplier": 2,
  49. "long_context_output_cost_multiplier": 1.5,
  50. "supports_service_tier": true,
  51. "supports_prompt_caching": true,
  52. "litellm_provider": "openai",
  53. "mode": "chat"
  54. }
  55. }`)
  56. data, err := svc.parsePricingData(body)
  57. require.NoError(t, err)
  58. pricing := data["gpt-5.4"]
  59. require.NotNil(t, pricing)
  60. require.InDelta(t, 5e-6, pricing.InputCostPerTokenPriority, 1e-12)
  61. require.InDelta(t, 3e-5, pricing.OutputCostPerTokenPriority, 1e-12)
  62. require.InDelta(t, 5e-6, pricing.CacheCreationInputTokenCostPriority, 1e-12)
  63. require.InDelta(t, 5e-7, pricing.CacheReadInputTokenCostPriority, 1e-12)
  64. require.Equal(t, 272000, pricing.LongContextInputTokenThreshold)
  65. require.InDelta(t, 2.0, pricing.LongContextInputCostMultiplier, 1e-12)
  66. require.InDelta(t, 1.5, pricing.LongContextOutputCostMultiplier, 1e-12)
  67. require.True(t, pricing.SupportsServiceTier)
  68. }
  69. const gpt6AstraCatalogJSON = `{
  70. "gpt-6-astra": {
  71. "litellm_provider": "openai",
  72. "mode": "chat",
  73. "input_cost_per_token": 1e-05,
  74. "input_cost_per_token_priority": 2e-05,
  75. "output_cost_per_token": 5e-05,
  76. "output_cost_per_token_priority": 1e-04,
  77. "cache_creation_input_token_cost": 1.25e-05,
  78. "cache_creation_input_token_cost_priority": 2.5e-05,
  79. "cache_read_input_token_cost": 1e-06,
  80. "cache_read_input_token_cost_priority": 2e-06,
  81. "input_cost_per_token_above_272k_tokens": 2e-05,
  82. "output_cost_per_token_above_272k_tokens": 7.5e-05,
  83. "cache_creation_input_token_cost_above_272k_tokens": 2.5e-05,
  84. "cache_read_input_token_cost_above_272k_tokens": 2e-06
  85. }
  86. }`
  87. func TestBillingServiceGPT6AstraUsesOfficialPricingAcrossTiersAndLongContext(t *testing.T) {
  88. svc := NewBillingService(&config.Config{}, newStubPricingServiceFromJSON(t, gpt6AstraCatalogJSON))
  89. boundaryTokens := UsageTokens{InputTokens: 100_000, CacheCreationTokens: 100_000, CacheReadTokens: 72_000, OutputTokens: 10}
  90. boundary, err := svc.CalculateCost("gpt-6-astra", boundaryTokens, 1)
  91. require.NoError(t, err)
  92. require.False(t, boundary.LongContextBillingApplied)
  93. require.InDelta(t, 100_000*10e-6, boundary.InputCost, 1e-12)
  94. require.InDelta(t, 100_000*12.5e-6, boundary.CacheCreationCost, 1e-12)
  95. require.InDelta(t, 72_000*1e-6, boundary.CacheReadCost, 1e-12)
  96. require.InDelta(t, 10*50e-6, boundary.OutputCost, 1e-12)
  97. tokens := UsageTokens{InputTokens: 100_000, CacheCreationTokens: 100_000, CacheReadTokens: 73_000, OutputTokens: 10}
  98. tiers := []struct {
  99. name string
  100. serviceTier string
  101. priceScale float64
  102. }{
  103. {name: "standard", priceScale: 1},
  104. {name: "fast", serviceTier: "priority", priceScale: 2},
  105. {name: "flex", serviceTier: "flex", priceScale: 0.5},
  106. }
  107. for _, tier := range tiers {
  108. t.Run(tier.name, func(t *testing.T) {
  109. cost, err := svc.CalculateCostWithServiceTier("gpt-6-astra", tokens, 1, tier.serviceTier)
  110. require.NoError(t, err)
  111. require.True(t, cost.LongContextBillingApplied)
  112. require.InDelta(t, 100_000*10e-6*tier.priceScale*2, cost.InputCost, 1e-12)
  113. require.InDelta(t, 100_000*12.5e-6*tier.priceScale*2, cost.CacheCreationCost, 1e-12)
  114. require.InDelta(t, 73_000*1e-6*tier.priceScale*2, cost.CacheReadCost, 1e-12)
  115. require.InDelta(t, 10*50e-6*tier.priceScale*1.5, cost.OutputCost, 1e-12)
  116. })
  117. }
  118. }
  119. func TestGPT6AstraDedicatedFallbacksUseOfficialRates(t *testing.T) {
  120. tests := []struct {
  121. name string
  122. svc *BillingService
  123. }{
  124. {name: "pricing_service", svc: NewBillingService(&config.Config{}, &PricingService{pricingData: map[string]*LiteLLMModelPricing{}})},
  125. {name: "billing_service", svc: NewBillingService(&config.Config{}, nil)},
  126. }
  127. for _, tt := range tests {
  128. t.Run(tt.name, func(t *testing.T) {
  129. pricing, err := tt.svc.GetModelPricing("gpt-6-astra")
  130. require.NoError(t, err)
  131. require.InDelta(t, 10e-6, pricing.InputPricePerToken, 1e-12)
  132. require.InDelta(t, 20e-6, pricing.InputPricePerTokenPriority, 1e-12)
  133. require.InDelta(t, 50e-6, pricing.OutputPricePerToken, 1e-12)
  134. require.InDelta(t, 100e-6, pricing.OutputPricePerTokenPriority, 1e-12)
  135. require.InDelta(t, 12.5e-6, pricing.CacheCreationPricePerToken, 1e-12)
  136. require.InDelta(t, 25e-6, pricing.CacheCreationPricePerTokenPriority, 1e-12)
  137. require.InDelta(t, 1e-6, pricing.CacheReadPricePerToken, 1e-12)
  138. require.InDelta(t, 2e-6, pricing.CacheReadPricePerTokenPriority, 1e-12)
  139. require.Equal(t, 272_000, pricing.LongContextInputThreshold)
  140. require.InDelta(t, 2.0, pricing.LongContextInputMultiplier, 1e-12)
  141. require.InDelta(t, 1.5, pricing.LongContextOutputMultiplier, 1e-12)
  142. })
  143. }
  144. }
  145. func TestPricingServiceBareGPT6AliasUsesAstra(t *testing.T) {
  146. astraPricing := &LiteLLMModelPricing{InputCostPerToken: 123e-6, OutputCostPerToken: 456e-6}
  147. pricingSvc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{"gpt-6-astra": astraPricing}}
  148. for _, model := range []string{"gpt-6", "openai/gpt-6"} {
  149. pricing := pricingSvc.GetModelPricing(model)
  150. require.Same(t, astraPricing, pricing)
  151. }
  152. }
  153. func TestBillingService_GPT56CacheWritePricingUsesOfficialMultiplier(t *testing.T) {
  154. tests := []struct {
  155. model string
  156. input float64
  157. inputPriority float64
  158. output float64
  159. outputPriority float64
  160. cacheRead float64
  161. cacheReadPriority float64
  162. }{
  163. {model: "gpt-5.6-sol", input: 5e-6, inputPriority: 10e-6, output: 30e-6, outputPriority: 60e-6, cacheRead: 0.5e-6, cacheReadPriority: 1e-6},
  164. {model: "gpt-5.6-terra", input: 2e-6, inputPriority: 4e-6, output: 12e-6, outputPriority: 24e-6, cacheRead: 0.2e-6, cacheReadPriority: 0.4e-6},
  165. {model: "gpt-5.6-luna", input: 0.2e-6, inputPriority: 0.4e-6, output: 1.2e-6, outputPriority: 2.4e-6, cacheRead: 0.02e-6, cacheReadPriority: 0.04e-6},
  166. }
  167. for _, tt := range tests {
  168. t.Run(tt.model, func(t *testing.T) {
  169. pricingSvc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{
  170. tt.model: {
  171. InputCostPerToken: tt.input,
  172. InputCostPerTokenPriority: tt.inputPriority,
  173. OutputCostPerToken: tt.output,
  174. OutputCostPerTokenPriority: tt.outputPriority,
  175. CacheReadInputTokenCost: tt.cacheRead,
  176. CacheReadInputTokenCostPriority: tt.cacheReadPriority,
  177. },
  178. }}
  179. svc := NewBillingService(&config.Config{}, pricingSvc)
  180. pricing, err := svc.GetModelPricing(tt.model)
  181. require.NoError(t, err)
  182. require.InDelta(t, tt.input*1.25, pricing.CacheCreationPricePerToken, 1e-12)
  183. require.InDelta(t, tt.inputPriority*1.25, pricing.CacheCreationPricePerTokenPriority, 1e-12)
  184. // 阶梯由目录数据驱动:条目无 above/long_context 字段时不再由策略强补。
  185. require.Zero(t, pricing.LongContextInputThreshold)
  186. tokens := UsageTokens{InputTokens: 700, OutputTokens: 50, CacheCreationTokens: 200, CacheReadTokens: 100}
  187. standard, err := svc.CalculateCostWithServiceTier(tt.model, tokens, 1, "")
  188. require.NoError(t, err)
  189. require.InDelta(t, 200*tt.input*1.25, standard.CacheCreationCost, 1e-12)
  190. priority, err := svc.CalculateCostWithServiceTier(tt.model, tokens, 1, "priority")
  191. require.NoError(t, err)
  192. require.InDelta(t, 200*tt.inputPriority*1.25, priority.CacheCreationCost, 1e-12)
  193. flex, err := svc.CalculateCostWithServiceTier(tt.model, tokens, 1, "flex")
  194. require.NoError(t, err)
  195. require.InDelta(t, 200*tt.input*1.25*0.5, flex.CacheCreationCost, 1e-12)
  196. })
  197. }
  198. }
  199. // gpt56LadderCatalogJSON 三个 5.6 模型的目录条目:above_272k 绝对价 + priority 平价,
  200. // cache_write 缺失由策略按 1.25 倍输入价补齐。
  201. const gpt56LadderCatalogJSON = `{
  202. "gpt-5.6-sol": {"litellm_provider": "openai", "mode": "chat",
  203. "input_cost_per_token": 5e-06, "input_cost_per_token_priority": 1e-05,
  204. "output_cost_per_token": 3e-05, "output_cost_per_token_priority": 6e-05,
  205. "cache_read_input_token_cost": 5e-07, "cache_read_input_token_cost_priority": 1e-06,
  206. "input_cost_per_token_above_272k_tokens": 1e-05,
  207. "output_cost_per_token_above_272k_tokens": 4.5e-05,
  208. "cache_read_input_token_cost_above_272k_tokens": 1e-06},
  209. "gpt-5.6-terra": {"litellm_provider": "openai", "mode": "chat",
  210. "input_cost_per_token": 2e-06, "input_cost_per_token_priority": 4e-06,
  211. "output_cost_per_token": 1.2e-05, "output_cost_per_token_priority": 2.4e-05,
  212. "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_priority": 4e-07,
  213. "input_cost_per_token_above_272k_tokens": 4e-06,
  214. "output_cost_per_token_above_272k_tokens": 1.8e-05,
  215. "cache_read_input_token_cost_above_272k_tokens": 4e-07},
  216. "gpt-5.6-luna": {"litellm_provider": "openai", "mode": "chat",
  217. "input_cost_per_token": 2e-07, "input_cost_per_token_priority": 4e-07,
  218. "output_cost_per_token": 1.2e-06, "output_cost_per_token_priority": 2.4e-06,
  219. "cache_read_input_token_cost": 2e-08, "cache_read_input_token_cost_priority": 4e-08,
  220. "input_cost_per_token_above_272k_tokens": 4e-07,
  221. "output_cost_per_token_above_272k_tokens": 1.8e-06,
  222. "cache_read_input_token_cost_above_272k_tokens": 4e-08}
  223. }`
  224. func TestBillingService_GPT56UsesLongContextPricingAcrossModelsAndTiers(t *testing.T) {
  225. models := []struct {
  226. name string
  227. input, cached float64
  228. cacheWrite, output float64
  229. }{
  230. {name: "gpt-5.6-sol", input: 5e-6, cached: 0.5e-6, cacheWrite: 6.25e-6, output: 30e-6},
  231. {name: "gpt-5.6-terra", input: 2e-6, cached: 0.2e-6, cacheWrite: 2.5e-6, output: 12e-6},
  232. {name: "gpt-5.6-luna", input: 0.2e-6, cached: 0.02e-6, cacheWrite: 0.25e-6, output: 1.2e-6},
  233. }
  234. tiers := []struct {
  235. name string
  236. priceScale float64
  237. }{
  238. {name: "standard", priceScale: 1},
  239. {name: "priority", priceScale: 2},
  240. {name: "flex", priceScale: 0.5},
  241. }
  242. tokens := UsageTokens{
  243. InputTokens: 100000,
  244. CacheCreationTokens: 100000,
  245. CacheReadTokens: 73000,
  246. OutputTokens: 10,
  247. }
  248. for _, model := range models {
  249. for _, tier := range tiers {
  250. t.Run(model.name+"/"+tier.name, func(t *testing.T) {
  251. svc := NewBillingService(&config.Config{}, newStubPricingServiceFromJSON(t, gpt56LadderCatalogJSON))
  252. serviceTier := ""
  253. if tier.name != "standard" {
  254. serviceTier = tier.name
  255. }
  256. cost, err := svc.CalculateCostWithServiceTier(model.name, tokens, 1, serviceTier)
  257. require.NoError(t, err)
  258. require.InDelta(t, float64(tokens.InputTokens)*model.input*tier.priceScale*2, cost.InputCost, 1e-12)
  259. require.InDelta(t, float64(tokens.CacheCreationTokens)*model.cacheWrite*tier.priceScale*2, cost.CacheCreationCost, 1e-12)
  260. require.InDelta(t, float64(tokens.CacheReadTokens)*model.cached*tier.priceScale*2, cost.CacheReadCost, 1e-12)
  261. require.InDelta(t, float64(tokens.OutputTokens)*model.output*tier.priceScale*1.5, cost.OutputCost, 1e-12)
  262. })
  263. }
  264. }
  265. }
  266. func TestBillingService_GPT56LongContextBoundaryIsExclusive(t *testing.T) {
  267. svc := NewBillingService(&config.Config{}, newStubPricingServiceFromJSON(t, gpt56LadderCatalogJSON))
  268. tokens := UsageTokens{InputTokens: 100000, CacheCreationTokens: 100000, CacheReadTokens: 72000, OutputTokens: 10}
  269. cost, err := svc.CalculateCost("gpt-5.6-sol", tokens, 1)
  270. require.NoError(t, err)
  271. require.InDelta(t, 100000*5e-6, cost.InputCost, 1e-12)
  272. require.InDelta(t, 100000*6.25e-6, cost.CacheCreationCost, 1e-12)
  273. require.InDelta(t, 72000*0.5e-6, cost.CacheReadCost, 1e-12)
  274. require.InDelta(t, 10*30e-6, cost.OutputCost, 1e-12)
  275. }
  276. func TestPricingService_BareGPT56AliasDeterministicallyUsesSol(t *testing.T) {
  277. pricingSvc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{
  278. "gpt-5.6-sol": {InputCostPerToken: 5e-6},
  279. "gpt-5.6-terra": {InputCostPerToken: 2e-6},
  280. "gpt-5.6-luna": {InputCostPerToken: 0.2e-6},
  281. "gpt-5.4": {InputCostPerToken: 2.5e-6},
  282. }}
  283. for i := 0; i < 100; i++ {
  284. for _, alias := range []string{"gpt-5.6", "openai/gpt-5.6"} {
  285. pricing := pricingSvc.GetModelPricing(alias)
  286. require.NotNil(t, pricing)
  287. require.InDelta(t, 5e-6, pricing.InputCostPerToken, 1e-12, "iteration=%d alias=%s", i, alias)
  288. }
  289. }
  290. billingSvc := NewBillingService(&config.Config{}, pricingSvc)
  291. for _, alias := range []string{"gpt-5.6", "openai/gpt-5.6"} {
  292. pricing, err := billingSvc.GetModelPricing(alias)
  293. require.NoError(t, err)
  294. require.InDelta(t, 5e-6, pricing.InputPricePerToken, 1e-12)
  295. require.InDelta(t, 6.25e-6, pricing.CacheCreationPricePerToken, 1e-12)
  296. }
  297. }
  298. func TestDefaultPricingIncludesOfficialGPT56Rates(t *testing.T) {
  299. data, err := os.ReadFile(filepath.Join("..", "..", "resources", "model-pricing", "model_prices_and_context_window.json"))
  300. require.NoError(t, err)
  301. pricingSvc := &PricingService{}
  302. pricingData, err := pricingSvc.parsePricingData(data)
  303. require.NoError(t, err)
  304. pricingSvc.pricingData = pricingData
  305. billingSvc := NewBillingService(&config.Config{}, pricingSvc)
  306. tests := []struct {
  307. model string
  308. input, cached, cacheWrite, output float64
  309. inputPriority, cachedPriority, cacheWritePriority, outputPriority float64
  310. }{
  311. {model: "gpt-5.6-sol", input: 5e-6, cached: 0.5e-6, cacheWrite: 6.25e-6, output: 30e-6, inputPriority: 10e-6, cachedPriority: 1e-6, cacheWritePriority: 12.5e-6, outputPriority: 60e-6},
  312. {model: "gpt-5.6-terra", input: 2e-6, cached: 0.2e-6, cacheWrite: 2.5e-6, output: 12e-6, inputPriority: 4e-6, cachedPriority: 0.4e-6, cacheWritePriority: 5e-6, outputPriority: 24e-6},
  313. {model: "gpt-5.6-luna", input: 0.2e-6, cached: 0.02e-6, cacheWrite: 0.25e-6, output: 1.2e-6, inputPriority: 0.4e-6, cachedPriority: 0.04e-6, cacheWritePriority: 0.5e-6, outputPriority: 2.4e-6},
  314. }
  315. for _, tt := range tests {
  316. t.Run(tt.model, func(t *testing.T) {
  317. pricing, err := billingSvc.GetModelPricing(tt.model)
  318. require.NoError(t, err)
  319. require.InDelta(t, tt.input, pricing.InputPricePerToken, 1e-12)
  320. require.InDelta(t, tt.cached, pricing.CacheReadPricePerToken, 1e-12)
  321. require.InDelta(t, tt.cacheWrite, pricing.CacheCreationPricePerToken, 1e-12)
  322. require.InDelta(t, tt.output, pricing.OutputPricePerToken, 1e-12)
  323. require.InDelta(t, tt.inputPriority, pricing.InputPricePerTokenPriority, 1e-12)
  324. require.InDelta(t, tt.cachedPriority, pricing.CacheReadPricePerTokenPriority, 1e-12)
  325. require.InDelta(t, tt.cacheWritePriority, pricing.CacheCreationPricePerTokenPriority, 1e-12)
  326. require.InDelta(t, tt.outputPriority, pricing.OutputPricePerTokenPriority, 1e-12)
  327. require.Equal(t, 272000, pricing.LongContextInputThreshold)
  328. require.InDelta(t, 2.0, pricing.LongContextInputMultiplier, 1e-12)
  329. require.InDelta(t, 1.5, pricing.LongContextOutputMultiplier, 1e-12)
  330. })
  331. }
  332. }
  333. func TestGPT56DedicatedFallbacksUseOfficialRates(t *testing.T) {
  334. tests := []struct {
  335. model string
  336. input, cached, cacheWrite, output float64
  337. }{
  338. {model: "gpt-5.6-sol", input: 5e-6, cached: 0.5e-6, cacheWrite: 6.25e-6, output: 30e-6},
  339. {model: "gpt-5.6-terra", input: 2e-6, cached: 0.2e-6, cacheWrite: 2.5e-6, output: 12e-6},
  340. {model: "gpt-5.6-luna", input: 0.2e-6, cached: 0.02e-6, cacheWrite: 0.25e-6, output: 1.2e-6},
  341. }
  342. for _, tt := range tests {
  343. t.Run(tt.model+"/pricing_service", func(t *testing.T) {
  344. pricingSvc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{
  345. "gpt-5.1-codex": {InputCostPerToken: 1.25e-6},
  346. }}
  347. svc := NewBillingService(&config.Config{}, pricingSvc)
  348. pricing, err := svc.GetModelPricing(tt.model + "-preview")
  349. require.NoError(t, err)
  350. assertGPT56FallbackPricing(t, pricing, tt.input, tt.cached, tt.cacheWrite, tt.output)
  351. })
  352. t.Run(tt.model+"/billing_service", func(t *testing.T) {
  353. svc := NewBillingService(&config.Config{}, nil)
  354. pricing, err := svc.GetModelPricing(tt.model)
  355. require.NoError(t, err)
  356. assertGPT56FallbackPricing(t, pricing, tt.input, tt.cached, tt.cacheWrite, tt.output)
  357. })
  358. }
  359. }
  360. func assertGPT56FallbackPricing(t *testing.T, pricing *ModelPricing, input, cached, cacheWrite, output float64) {
  361. t.Helper()
  362. require.InDelta(t, input, pricing.InputPricePerToken, 1e-12)
  363. require.InDelta(t, cached, pricing.CacheReadPricePerToken, 1e-12)
  364. require.InDelta(t, cacheWrite, pricing.CacheCreationPricePerToken, 1e-12)
  365. require.InDelta(t, output, pricing.OutputPricePerToken, 1e-12)
  366. // 静态兜底只兜基础价;阶梯由目录数据(above_272k 折算或显式字段)驱动。
  367. require.Zero(t, pricing.LongContextInputThreshold)
  368. }
  369. func TestParsePricingData_KeepsImageOnlyPricing(t *testing.T) {
  370. svc := &PricingService{}
  371. body := []byte(`{
  372. "image-only-model": {
  373. "output_cost_per_image": 0.034,
  374. "litellm_provider": "vertex_ai-language-models",
  375. "mode": "image_generation"
  376. }
  377. }`)
  378. data, err := svc.parsePricingData(body)
  379. require.NoError(t, err)
  380. pricing := data["image-only-model"]
  381. require.NotNil(t, pricing)
  382. require.InDelta(t, 0.034, pricing.OutputCostPerImage, 1e-12)
  383. require.Equal(t, "image_generation", pricing.Mode)
  384. // 仅有图片价的条目必须标记 token 价缺失,供 token 计费路径 fail-closed。
  385. require.True(t, pricing.TokenPricingAbsent)
  386. }
  387. func TestBillingService_GetModelPricing_FailsClosedForImageOnlyEntries(t *testing.T) {
  388. pricingSvc := &PricingService{}
  389. data, err := pricingSvc.parsePricingData([]byte(`{
  390. "imagen-9.0-generate": {
  391. "output_cost_per_image": 0.04,
  392. "litellm_provider": "vertex_ai-image-models",
  393. "mode": "image_generation"
  394. },
  395. "gemini-image-with-token-price": {
  396. "input_cost_per_token": 0.0,
  397. "output_cost_per_token": 0.0,
  398. "output_cost_per_image": 0.034,
  399. "litellm_provider": "vertex_ai-language-models",
  400. "mode": "image_generation"
  401. }
  402. }`))
  403. require.NoError(t, err)
  404. pricingSvc.pricingData = data
  405. billingSvc := NewBillingService(&config.Config{}, pricingSvc)
  406. // image-only 条目不得进入 token 计费(否则 token 流量按 $0 计费),
  407. // 必须落到 fallback / ErrModelPricingUnavailable 的 fail-closed 路径。
  408. _, err = billingSvc.GetModelPricing("imagen-9.0-generate")
  409. require.ErrorIs(t, err, ErrModelPricingUnavailable)
  410. // 显式 0 token 价的免费条目保持历史行为:正常返回。
  411. pricing, err := billingSvc.GetModelPricing("gemini-image-with-token-price")
  412. require.NoError(t, err)
  413. require.Zero(t, pricing.InputPricePerToken)
  414. // 图片计费路径不受影响:仍能读到 image-only 条目的图片单价。
  415. raw := pricingSvc.GetModelPricing("imagen-9.0-generate")
  416. require.NotNil(t, raw)
  417. require.InDelta(t, 0.04, raw.OutputCostPerImage, 1e-12)
  418. }
  419. func TestPricingService_MergesFallbackOnlyModels(t *testing.T) {
  420. dir := t.TempDir()
  421. fallbackFile := filepath.Join(dir, "fallback.json")
  422. require.NoError(t, os.WriteFile(fallbackFile, []byte(`{
  423. "remote-model": {
  424. "input_cost_per_token": 0.000001,
  425. "litellm_provider": "test",
  426. "mode": "chat"
  427. },
  428. "gemini-3.1-flash-lite-image": {
  429. "output_cost_per_image": 0.034,
  430. "litellm_provider": "vertex_ai-language-models",
  431. "mode": "image_generation"
  432. }
  433. }`), 0644))
  434. svc := &PricingService{cfg: &config.Config{}}
  435. svc.cfg.Pricing.FallbackFile = fallbackFile
  436. remoteData, err := svc.parsePricingData([]byte(`{
  437. "remote-model": {
  438. "input_cost_per_token": 0.000002,
  439. "litellm_provider": "test",
  440. "mode": "chat"
  441. }
  442. }`))
  443. require.NoError(t, err)
  444. merged := svc.mergeFallbackPricingData(remoteData)
  445. require.InDelta(t, 0.000002, merged["remote-model"].InputCostPerToken, 1e-12)
  446. require.NotNil(t, merged["gemini-3.1-flash-lite-image"])
  447. require.InDelta(t, 0.034, merged["gemini-3.1-flash-lite-image"].OutputCostPerImage, 1e-12)
  448. }
  449. func TestGetModelPricing_Gpt53CodexSparkUsesGpt51CodexPricing(t *testing.T) {
  450. sparkPricing := &LiteLLMModelPricing{InputCostPerToken: 1}
  451. gpt53Pricing := &LiteLLMModelPricing{InputCostPerToken: 9}
  452. svc := &PricingService{
  453. pricingData: map[string]*LiteLLMModelPricing{
  454. "gpt-5.1-codex": sparkPricing,
  455. "gpt-5.3": gpt53Pricing,
  456. },
  457. }
  458. got := svc.GetModelPricing("gpt-5.3-codex-spark")
  459. require.Same(t, sparkPricing, got)
  460. }
  461. func TestGetModelPricing_Gpt53CodexFallbackStillUsesGpt52Codex(t *testing.T) {
  462. gpt52CodexPricing := &LiteLLMModelPricing{InputCostPerToken: 2}
  463. svc := &PricingService{
  464. pricingData: map[string]*LiteLLMModelPricing{
  465. "gpt-5.2-codex": gpt52CodexPricing,
  466. },
  467. }
  468. got := svc.GetModelPricing("gpt-5.3-codex")
  469. require.Same(t, gpt52CodexPricing, got)
  470. }
  471. func TestGetModelPricing_OpenAIFallbackMatchedLoggedAsInfo(t *testing.T) {
  472. logSink, restore := captureStructuredLog(t)
  473. defer restore()
  474. gpt52CodexPricing := &LiteLLMModelPricing{InputCostPerToken: 2}
  475. svc := &PricingService{
  476. pricingData: map[string]*LiteLLMModelPricing{
  477. "gpt-5.2-codex": gpt52CodexPricing,
  478. },
  479. }
  480. got := svc.GetModelPricing("gpt-5.3-codex")
  481. require.Same(t, gpt52CodexPricing, got)
  482. require.True(t, logSink.ContainsMessageAtLevel("[Pricing] OpenAI fallback matched gpt-5.3-codex -> gpt-5.2-codex", "info"))
  483. require.False(t, logSink.ContainsMessageAtLevel("[Pricing] OpenAI fallback matched gpt-5.3-codex -> gpt-5.2-codex", "warn"))
  484. }
  485. func TestGetModelPricing_Gpt54UsesStaticFallbackWhenRemoteMissing(t *testing.T) {
  486. svc := &PricingService{
  487. pricingData: map[string]*LiteLLMModelPricing{
  488. "gpt-5.1-codex": &LiteLLMModelPricing{InputCostPerToken: 1.25e-6},
  489. },
  490. }
  491. got := svc.GetModelPricing("gpt-5.4")
  492. require.NotNil(t, got)
  493. require.InDelta(t, 2.5e-6, got.InputCostPerToken, 1e-12)
  494. require.InDelta(t, 1.5e-5, got.OutputCostPerToken, 1e-12)
  495. require.InDelta(t, 2.5e-7, got.CacheReadInputTokenCost, 1e-12)
  496. // 静态兜底只兜基础价,不携带长上下文阶梯(阶梯由目录数据驱动)。
  497. require.Zero(t, got.LongContextInputTokenThreshold)
  498. }
  499. func TestGetModelPricing_OpenAICompactAliasUsesStaticFallback(t *testing.T) {
  500. svc := &PricingService{
  501. pricingData: map[string]*LiteLLMModelPricing{
  502. "gpt-5.1-codex": {InputCostPerToken: 1.25e-6},
  503. },
  504. }
  505. got := svc.GetModelPricing("openai/gpt5.5")
  506. require.NotNil(t, got)
  507. require.InDelta(t, 2.5e-6, got.InputCostPerToken, 1e-12)
  508. require.InDelta(t, 1.5e-5, got.OutputCostPerToken, 1e-12)
  509. }
  510. func TestPricingService_GeminiFlashThinkingTiersUseBasePricing(t *testing.T) {
  511. basePricing := &LiteLLMModelPricing{
  512. InputCostPerToken: 1.5e-6,
  513. OutputCostPerToken: 7.5e-6,
  514. CacheReadInputTokenCost: 0.15e-6,
  515. }
  516. for _, baseModel := range []string{"gemini-3.6-flash", "gemini-3.7-flash", "gemini-3.8-flash"} {
  517. svc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{baseModel: basePricing}}
  518. for _, tier := range []string{"", "-high", "-low", "-medium", "-tiered"} {
  519. model := baseModel + tier
  520. t.Run(model, func(t *testing.T) {
  521. require.Same(t, basePricing, svc.GetModelPricing(model))
  522. require.Same(t, basePricing, svc.GetIdentifiedModelPricing("models/"+model))
  523. })
  524. }
  525. }
  526. }
  527. func TestPricingService_GeminiFlashTierSpecificPricingTakesPrecedence(t *testing.T) {
  528. basePricing := &LiteLLMModelPricing{InputCostPerToken: 1.5e-6}
  529. tierPricing := &LiteLLMModelPricing{InputCostPerToken: 2e-6}
  530. for _, baseModel := range []string{"gemini-3.6-flash", "gemini-3.7-flash", "gemini-3.8-flash"} {
  531. t.Run(baseModel, func(t *testing.T) {
  532. svc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{
  533. baseModel: basePricing,
  534. baseModel + "-low": tierPricing,
  535. }}
  536. require.Same(t, tierPricing, svc.GetModelPricing("models/"+baseModel+"-low"))
  537. })
  538. }
  539. }
  540. func TestBillingService_Gemini36FlashThinkingTierFallbacksAreBillable(t *testing.T) {
  541. svc := NewBillingService(&config.Config{}, nil)
  542. tokens := UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000, CacheReadTokens: 1_000_000}
  543. for _, model := range []string{
  544. "gemini-3.6-flash",
  545. "gemini-3.6-flash-high",
  546. "gemini-3.6-flash-low",
  547. "gemini-3.6-flash-medium",
  548. "gemini-3.6-flash-tiered",
  549. } {
  550. t.Run(model, func(t *testing.T) {
  551. cost, err := svc.CalculateCost(model, tokens, 1)
  552. require.NoError(t, err)
  553. require.InDelta(t, 1.5, cost.InputCost, 1e-12)
  554. require.InDelta(t, 7.5, cost.OutputCost, 1e-12)
  555. require.InDelta(t, 0.15, cost.CacheReadCost, 1e-12)
  556. require.InDelta(t, 9.15, cost.TotalCost, 1e-12)
  557. })
  558. }
  559. }
  560. func TestDefaultPricingIncludesGemini36FlashRates(t *testing.T) {
  561. data, err := os.ReadFile(filepath.Join("..", "..", "resources", "model-pricing", "model_prices_and_context_window.json"))
  562. require.NoError(t, err)
  563. pricingSvc := &PricingService{}
  564. pricingData, err := pricingSvc.parsePricingData(data)
  565. require.NoError(t, err)
  566. pricingSvc.pricingData = pricingData
  567. billingSvc := NewBillingService(&config.Config{}, pricingSvc)
  568. for _, model := range []string{"gemini-3.6-flash", "gemini-3.6-flash-low", "gemini-3.6-flash-high"} {
  569. t.Run(model, func(t *testing.T) {
  570. pricing, err := billingSvc.GetModelPricing(model)
  571. require.NoError(t, err)
  572. require.InDelta(t, 1.5e-6, pricing.InputPricePerToken, 1e-12)
  573. require.InDelta(t, 7.5e-6, pricing.OutputPricePerToken, 1e-12)
  574. require.InDelta(t, 0.15e-6, pricing.CacheReadPricePerToken, 1e-12)
  575. })
  576. }
  577. }
  578. func TestDefaultPricingUsesCurrentCodexAutoReviewBaseRates(t *testing.T) {
  579. data, err := os.ReadFile(filepath.Join("..", "..", "resources", "model-pricing", "model_prices_and_context_window.json"))
  580. require.NoError(t, err)
  581. svc := &PricingService{}
  582. pricingData, err := svc.parsePricingData(data)
  583. require.NoError(t, err)
  584. svc.pricingData = pricingData
  585. got := svc.GetModelPricing("codex-auto-review")
  586. require.NotNil(t, got)
  587. require.InDelta(t, 0.2e-6, got.InputCostPerToken, 1e-12)
  588. require.InDelta(t, 1.2e-6, got.OutputCostPerToken, 1e-12)
  589. require.InDelta(t, 0.02e-6, got.CacheReadInputTokenCost, 1e-12)
  590. // Auto-review is an internal Codex model. Do not infer public GPT-5.6 API
  591. // service-tier, cache-write, or long-context pricing without an upstream
  592. // usage contract for this dedicated model.
  593. require.Zero(t, got.InputCostPerTokenPriority)
  594. require.Zero(t, got.OutputCostPerTokenPriority)
  595. require.Zero(t, got.CacheReadInputTokenCostPriority)
  596. require.Zero(t, got.CacheCreationInputTokenCost)
  597. require.Zero(t, got.CacheCreationInputTokenCostPriority)
  598. require.Zero(t, got.LongContextInputTokenThreshold)
  599. }
  600. func TestGetModelPricing_Gpt54MiniUsesDedicatedStaticFallbackWhenRemoteMissing(t *testing.T) {
  601. svc := &PricingService{
  602. pricingData: map[string]*LiteLLMModelPricing{
  603. "gpt-5.1-codex": {InputCostPerToken: 1.25e-6},
  604. },
  605. }
  606. got := svc.GetModelPricing("gpt-5.4-mini")
  607. require.NotNil(t, got)
  608. require.InDelta(t, 7.5e-7, got.InputCostPerToken, 1e-12)
  609. require.InDelta(t, 4.5e-6, got.OutputCostPerToken, 1e-12)
  610. require.InDelta(t, 7.5e-8, got.CacheReadInputTokenCost, 1e-12)
  611. require.Zero(t, got.LongContextInputTokenThreshold)
  612. }
  613. func TestGetModelPricing_Gpt54NanoUsesDedicatedStaticFallbackWhenRemoteMissing(t *testing.T) {
  614. svc := &PricingService{
  615. pricingData: map[string]*LiteLLMModelPricing{
  616. "gpt-5.1-codex": {InputCostPerToken: 1.25e-6},
  617. },
  618. }
  619. got := svc.GetModelPricing("gpt-5.4-nano")
  620. require.NotNil(t, got)
  621. require.InDelta(t, 2e-7, got.InputCostPerToken, 1e-12)
  622. require.InDelta(t, 1.25e-6, got.OutputCostPerToken, 1e-12)
  623. require.InDelta(t, 2e-8, got.CacheReadInputTokenCost, 1e-12)
  624. require.Zero(t, got.LongContextInputTokenThreshold)
  625. }
  626. func TestGetModelPricing_ImageModelDoesNotFallbackToTextModel(t *testing.T) {
  627. imagePricing := &LiteLLMModelPricing{InputCostPerToken: 3}
  628. textPricing := &LiteLLMModelPricing{InputCostPerToken: 9}
  629. svc := &PricingService{
  630. pricingData: map[string]*LiteLLMModelPricing{
  631. "gpt-image-2": imagePricing,
  632. "gpt-5.4": textPricing,
  633. },
  634. }
  635. got := svc.GetModelPricing("gpt-image-3")
  636. require.Same(t, imagePricing, got)
  637. }
  638. func TestParsePricingData_PreservesPriorityAndServiceTierFields(t *testing.T) {
  639. raw := map[string]any{
  640. "gpt-5.4": map[string]any{
  641. "input_cost_per_token": 2.5e-6,
  642. "input_cost_per_token_priority": 5e-6,
  643. "output_cost_per_token": 15e-6,
  644. "output_cost_per_token_priority": 30e-6,
  645. "cache_read_input_token_cost": 0.25e-6,
  646. "cache_read_input_token_cost_priority": 0.5e-6,
  647. "supports_service_tier": true,
  648. "supports_prompt_caching": true,
  649. "litellm_provider": "openai",
  650. "mode": "chat",
  651. },
  652. }
  653. body, err := json.Marshal(raw)
  654. require.NoError(t, err)
  655. svc := &PricingService{}
  656. pricingMap, err := svc.parsePricingData(body)
  657. require.NoError(t, err)
  658. pricing := pricingMap["gpt-5.4"]
  659. require.NotNil(t, pricing)
  660. require.InDelta(t, 2.5e-6, pricing.InputCostPerToken, 1e-12)
  661. require.InDelta(t, 5e-6, pricing.InputCostPerTokenPriority, 1e-12)
  662. require.InDelta(t, 15e-6, pricing.OutputCostPerToken, 1e-12)
  663. require.InDelta(t, 30e-6, pricing.OutputCostPerTokenPriority, 1e-12)
  664. require.InDelta(t, 0.25e-6, pricing.CacheReadInputTokenCost, 1e-12)
  665. require.InDelta(t, 0.5e-6, pricing.CacheReadInputTokenCostPriority, 1e-12)
  666. require.True(t, pricing.SupportsServiceTier)
  667. }
  668. func TestParsePricingData_PreservesServiceTierPriorityFields(t *testing.T) {
  669. svc := &PricingService{}
  670. pricingData, err := svc.parsePricingData([]byte(`{
  671. "gpt-5.4": {
  672. "input_cost_per_token": 0.0000025,
  673. "input_cost_per_token_priority": 0.000005,
  674. "output_cost_per_token": 0.000015,
  675. "output_cost_per_token_priority": 0.00003,
  676. "cache_read_input_token_cost": 0.00000025,
  677. "cache_read_input_token_cost_priority": 0.0000005,
  678. "supports_service_tier": true,
  679. "litellm_provider": "openai",
  680. "mode": "chat"
  681. }
  682. }`))
  683. require.NoError(t, err)
  684. pricing := pricingData["gpt-5.4"]
  685. require.NotNil(t, pricing)
  686. require.InDelta(t, 0.0000025, pricing.InputCostPerToken, 1e-12)
  687. require.InDelta(t, 0.000005, pricing.InputCostPerTokenPriority, 1e-12)
  688. require.InDelta(t, 0.000015, pricing.OutputCostPerToken, 1e-12)
  689. require.InDelta(t, 0.00003, pricing.OutputCostPerTokenPriority, 1e-12)
  690. require.InDelta(t, 0.00000025, pricing.CacheReadInputTokenCost, 1e-12)
  691. require.InDelta(t, 0.0000005, pricing.CacheReadInputTokenCostPriority, 1e-12)
  692. require.True(t, pricing.SupportsServiceTier)
  693. }
  694. // ---------------------------------------------------------------------------
  695. // ListModelNamesByProvider
  696. // ---------------------------------------------------------------------------
  697. func TestListModelNamesByProvider_ReturnsMatchingModels(t *testing.T) {
  698. svc := &PricingService{
  699. pricingData: map[string]*LiteLLMModelPricing{
  700. "claude-opus-4-5-20251101": {LiteLLMProvider: "anthropic", InputCostPerToken: 1.5e-5},
  701. "claude-sonnet-4-5": {LiteLLMProvider: "anthropic", InputCostPerToken: 3e-6},
  702. "gpt-4o": {LiteLLMProvider: "openai", InputCostPerToken: 5e-6},
  703. "gemini-2.5-pro": {LiteLLMProvider: "google", InputCostPerToken: 1.25e-6},
  704. },
  705. }
  706. got := svc.ListModelNamesByProvider("anthropic")
  707. require.ElementsMatch(t, []string{"claude-opus-4-5-20251101", "claude-sonnet-4-5"}, got)
  708. // Must be sorted
  709. require.Equal(t, "claude-opus-4-5-20251101", got[0])
  710. require.Equal(t, "claude-sonnet-4-5", got[1])
  711. }
  712. func TestListModelNamesByProvider_CaseInsensitive(t *testing.T) {
  713. svc := &PricingService{
  714. pricingData: map[string]*LiteLLMModelPricing{
  715. "gpt-4o": {LiteLLMProvider: "OpenAI", InputCostPerToken: 5e-6},
  716. },
  717. }
  718. got := svc.ListModelNamesByProvider("openai")
  719. require.Equal(t, []string{"gpt-4o"}, got)
  720. got2 := svc.ListModelNamesByProvider("OPENAI")
  721. require.Equal(t, []string{"gpt-4o"}, got2)
  722. }
  723. func TestListModelNamesByProvider_NoMatch(t *testing.T) {
  724. svc := &PricingService{
  725. pricingData: map[string]*LiteLLMModelPricing{
  726. "gpt-4o": {LiteLLMProvider: "openai", InputCostPerToken: 5e-6},
  727. },
  728. }
  729. got := svc.ListModelNamesByProvider("anthropic")
  730. require.NotNil(t, got)
  731. require.Empty(t, got)
  732. }
  733. func TestListModelNamesByProvider_EmptyCatalog(t *testing.T) {
  734. svc := &PricingService{
  735. pricingData: map[string]*LiteLLMModelPricing{},
  736. }
  737. got := svc.ListModelNamesByProvider("openai")
  738. require.NotNil(t, got)
  739. require.Empty(t, got)
  740. }
  741. // --- above_XXXk 绝对价字段折算为阈值+倍率 ---
  742. func TestParsePricingData_DerivesLongContextFromAboveTierFields(t *testing.T) {
  743. svc := &PricingService{}
  744. data, err := svc.parsePricingData([]byte(`{
  745. "gpt-above": {"litellm_provider": "openai", "mode": "chat",
  746. "input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05,
  747. "cache_read_input_token_cost": 5e-07,
  748. "input_cost_per_token_above_272k_tokens": 1e-05,
  749. "output_cost_per_token_above_272k_tokens": 4.5e-05,
  750. "cache_read_input_token_cost_above_272k_tokens": 1e-06,
  751. "input_cost_per_token_above_272k_tokens_flex": 5e-06,
  752. "output_cost_per_token_above_272k_tokens_flex": 2.25e-05},
  753. "gemini-above": {"litellm_provider": "vertex_ai-language-models", "mode": "chat",
  754. "input_cost_per_token": 1.25e-06, "output_cost_per_token": 1e-05,
  755. "input_cost_per_token_above_200k_tokens": 2.5e-06,
  756. "output_cost_per_token_above_200k_tokens": 1.5e-05},
  757. "explicit-wins": {"litellm_provider": "openai", "mode": "chat",
  758. "input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05,
  759. "long_context_input_cost_multiplier": 1,
  760. "long_context_output_cost_multiplier": 1,
  761. "input_cost_per_token_above_272k_tokens": 1e-05,
  762. "output_cost_per_token_above_272k_tokens": 4.5e-05},
  763. "no-surcharge": {"litellm_provider": "openai", "mode": "chat",
  764. "input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05,
  765. "input_cost_per_token_above_272k_tokens": 5e-06,
  766. "output_cost_per_token_above_272k_tokens": 3e-05},
  767. "cache-only-above": {"litellm_provider": "openai", "mode": "chat",
  768. "input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05,
  769. "cache_read_input_token_cost_above_272k_tokens": 1e-06},
  770. "multi-threshold": {"litellm_provider": "openai", "mode": "chat",
  771. "input_cost_per_token": 1e-06, "output_cost_per_token": 2e-06,
  772. "input_cost_per_token_above_128k_tokens": 2e-06,
  773. "input_cost_per_token_above_272k_tokens": 4e-06}
  774. }`))
  775. require.NoError(t, err)
  776. openai := data["gpt-above"]
  777. require.Equal(t, 272000, openai.LongContextInputTokenThreshold, "阈值取自字段名(_flex 变体不参与)")
  778. require.InDelta(t, 2.0, openai.LongContextInputCostMultiplier, 1e-12)
  779. require.InDelta(t, 1.5, openai.LongContextOutputCostMultiplier, 1e-12)
  780. gemini := data["gemini-above"]
  781. require.Equal(t, 200000, gemini.LongContextInputTokenThreshold)
  782. require.InDelta(t, 2.0, gemini.LongContextInputCostMultiplier, 1e-12)
  783. require.InDelta(t, 1.5, gemini.LongContextOutputCostMultiplier, 1e-12)
  784. explicit := data["explicit-wins"]
  785. require.Zero(t, explicit.LongContextInputTokenThreshold, "显式 long_context_* 字段优先,不做折算")
  786. require.InDelta(t, 1.0, explicit.LongContextInputCostMultiplier, 1e-12)
  787. require.Zero(t, data["no-surcharge"].LongContextInputTokenThreshold, "above 价不高于基础价视为无附加费")
  788. require.Zero(t, data["cache-only-above"].LongContextInputTokenThreshold, "仅 cache 侧 above 字段不构成阶梯")
  789. require.Equal(t, 128000, data["multi-threshold"].LongContextInputTokenThreshold, "多阈值取最小")
  790. }
  791. func TestGetModelPricing_XAIThresholdInclusive(t *testing.T) {
  792. svc := NewBillingService(&config.Config{}, newStubPricingServiceFromJSON(t, `{
  793. "grok-4.5": {"litellm_provider": "xai", "mode": "chat",
  794. "input_cost_per_token": 2e-06, "output_cost_per_token": 6e-06,
  795. "input_cost_per_token_above_200k_tokens": 4e-06,
  796. "output_cost_per_token_above_200k_tokens": 1.2e-05}
  797. }`))
  798. pricing, err := svc.GetModelPricing("grok-4.5")
  799. require.NoError(t, err)
  800. require.Equal(t, 200000, pricing.LongContextInputThreshold)
  801. require.True(t, pricing.LongContextThresholdInclusive, "xAI 阈值语义为达到即进高档")
  802. }
  803. // F3:显式 long_context 字段以"字段存在"为准——显式 0 也能压住 above 折算,关闭阶梯。
  804. func TestParsePricingData_ExplicitZeroThresholdDisablesLadder(t *testing.T) {
  805. svc := &PricingService{}
  806. data, err := svc.parsePricingData([]byte(`{
  807. "gpt-5.5": {"litellm_provider": "openai", "mode": "chat",
  808. "input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05,
  809. "long_context_input_token_threshold": 0,
  810. "input_cost_per_token_above_272k_tokens": 1e-05,
  811. "output_cost_per_token_above_272k_tokens": 4.5e-05}
  812. }`))
  813. require.NoError(t, err)
  814. require.Zero(t, data["gpt-5.5"].LongContextInputTokenThreshold)
  815. require.Zero(t, data["gpt-5.5"].LongContextInputCostMultiplier)
  816. }
  817. // cache 侧 above 档随输入倍率计费、不单独折算;缺基础价的 cache above 字段无法参与计费,
  818. // 该缓存分项按 0 计,属于数据契约违规,必须有哨兵 WARN。服务档变体缺基础价时回落
  819. // 标准基础价,不算孤儿。
  820. func TestParsePricingData_WarnsOrphanCacheTierFields(t *testing.T) {
  821. logSink, restore := captureStructuredLog(t)
  822. defer restore()
  823. svc := &PricingService{}
  824. data, err := svc.parsePricingData([]byte(`{
  825. "gemini-orphan": {"litellm_provider": "vertex_ai-language-models", "mode": "chat",
  826. "input_cost_per_token": 1.25e-06, "output_cost_per_token": 1e-05,
  827. "cache_read_input_token_cost": 1.25e-07,
  828. "input_cost_per_token_above_200k_tokens": 2.5e-06,
  829. "output_cost_per_token_above_200k_tokens": 1.5e-05,
  830. "cache_read_input_token_cost_above_200k_tokens": 2.5e-07,
  831. "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07},
  832. "gemini-complete": {"litellm_provider": "vertex_ai-language-models", "mode": "chat",
  833. "input_cost_per_token": 1.25e-06, "output_cost_per_token": 1e-05,
  834. "cache_read_input_token_cost": 1.25e-07,
  835. "cache_creation_input_token_cost": 1.25e-06,
  836. "input_cost_per_token_above_200k_tokens": 2.5e-06,
  837. "output_cost_per_token_above_200k_tokens": 1.5e-05,
  838. "cache_read_input_token_cost_above_200k_tokens": 2.5e-07,
  839. "cache_creation_input_token_cost_above_200k_tokens": 2.5e-06},
  840. "priority-variant-without-own-base": {"litellm_provider": "openai", "mode": "chat",
  841. "input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05,
  842. "cache_read_input_token_cost": 5e-07,
  843. "input_cost_per_token_above_272k_tokens": 1e-05,
  844. "output_cost_per_token_above_272k_tokens": 4.5e-05,
  845. "cache_read_input_token_cost_above_272k_tokens_priority": 2e-06},
  846. "priority-variant-orphan": {"litellm_provider": "openai", "mode": "chat",
  847. "input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05,
  848. "cache_creation_input_token_cost_above_272k_tokens_priority": 2.5e-05},
  849. "hourly-tier-with-5m-base": {"litellm_provider": "anthropic", "mode": "chat",
  850. "input_cost_per_token": 3e-06, "output_cost_per_token": 1.5e-05,
  851. "cache_creation_input_token_cost": 3.75e-06,
  852. "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 1.2e-05},
  853. "hourly-tier-orphan": {"litellm_provider": "anthropic", "mode": "chat",
  854. "input_cost_per_token": 3e-06, "output_cost_per_token": 1.5e-05,
  855. "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 1.2e-05}
  856. }`))
  857. require.NoError(t, err)
  858. require.Equal(t, 200000, data["gemini-orphan"].LongContextInputTokenThreshold, "孤儿 cache 字段不影响 input/output 阶梯折算")
  859. require.Zero(t, data["gemini-orphan"].CacheCreationInputTokenCost)
  860. require.InDelta(t, 1.25e-6, data["gemini-complete"].CacheCreationInputTokenCost, 1e-12)
  861. require.True(t, logSink.ContainsMessageAtLevel("gemini-orphan(cache_creation_input_token_cost_above_200k_tokens)", "warn"))
  862. require.True(t, logSink.ContainsMessage("priority-variant-orphan(cache_creation_input_token_cost_above_272k_tokens_priority)"))
  863. require.True(t, logSink.ContainsMessage("hourly-tier-orphan(cache_creation_input_token_cost_above_1hr_above_200k_tokens)"))
  864. require.False(t, logSink.ContainsMessage("gemini-complete"))
  865. require.False(t, logSink.ContainsMessage("priority-variant-without-own-base"))
  866. require.False(t, logSink.ContainsMessage("hourly-tier-with-5m-base"), "1h 档缺 above_1hr 基础价时计费回落 5m 价,不算孤儿")
  867. }
  868. // 基础价与 above 档来自不同价格版本时(如基础价被手工 pin、above 档随上游更新)会折算出
  869. // 只有一侧带附加费的阶梯,必须有哨兵 WARN;显式 long_context_* 字段是部署方意图,不告警。
  870. func TestParsePricingData_WarnsLopsidedLongContextLadder(t *testing.T) {
  871. logSink, restore := captureStructuredLog(t)
  872. defer restore()
  873. svc := &PricingService{}
  874. data, err := svc.parsePricingData([]byte(`{
  875. "mixed-versions": {"litellm_provider": "openai", "mode": "chat",
  876. "input_cost_per_token": 5e-06, "output_cost_per_token": 3e-05,
  877. "input_cost_per_token_above_272k_tokens": 8e-06,
  878. "output_cost_per_token_above_272k_tokens": 3e-05},
  879. "consistent": {"litellm_provider": "openai", "mode": "chat",
  880. "input_cost_per_token": 4e-06, "output_cost_per_token": 2e-05,
  881. "input_cost_per_token_above_272k_tokens": 8e-06,
  882. "output_cost_per_token_above_272k_tokens": 3e-05},
  883. "explicit-input-only": {"litellm_provider": "openai", "mode": "chat",
  884. "input_cost_per_token": 4e-06, "output_cost_per_token": 2e-05,
  885. "long_context_input_token_threshold": 272000,
  886. "long_context_input_cost_multiplier": 2}
  887. }`))
  888. require.NoError(t, err)
  889. require.Equal(t, 272000, data["mixed-versions"].LongContextInputTokenThreshold, "单侧阶梯仍按折算结果计费,只告警不丢弃")
  890. require.InDelta(t, 1.6, data["mixed-versions"].LongContextInputCostMultiplier, 1e-12)
  891. require.True(t, logSink.ContainsMessageAtLevel("mixed-versions(input x1.60, output x1.00)", "warn"))
  892. require.False(t, logSink.ContainsMessage("consistent"))
  893. require.False(t, logSink.ContainsMessage("explicit-input-only"))
  894. }
  895. // 出厂回退快照必须满足数据契约:没有孤儿 cache above 字段、没有单侧阶梯,且 Gemini pro 系的
  896. // 缓存写入基础价等于标准输入价(含 priority 变体)。快照是随目录同步刷新的文本,这里防止刷新时静默回退。
  897. func TestDefaultCatalogSnapshot_CacheTierContract(t *testing.T) {
  898. logSink, restore := captureStructuredLog(t)
  899. defer restore()
  900. body, err := os.ReadFile(filepath.Join("..", "..", "resources", "model-pricing", "model_prices_and_context_window.json"))
  901. require.NoError(t, err)
  902. var rawEntries map[string]json.RawMessage
  903. require.NoError(t, json.Unmarshal(body, &rawEntries))
  904. for name, raw := range rawEntries {
  905. require.Empty(t, orphanCacheTierFields(raw), "快照条目 %s 带孤儿 cache above 字段", name)
  906. }
  907. svc := &PricingService{}
  908. data, err := svc.parsePricingData(body)
  909. require.NoError(t, err)
  910. require.False(t, logSink.ContainsMessage("carry cache above-tier prices"), "快照不应触发孤儿 cache 字段哨兵")
  911. require.False(t, logSink.ContainsMessage("one-sided long-context ladder"), "快照不应触发单侧阶梯哨兵")
  912. for _, model := range []string{
  913. "gemini-2.5-pro", "gemini-3-pro-preview", "gemini-3.1-pro-preview",
  914. "gemini-3.1-pro-high", "gemini-3.1-pro-low", "gemini-3.1-pro-preview-customtools",
  915. } {
  916. pricing := data[model]
  917. require.NotNil(t, pricing, model)
  918. require.Positive(t, pricing.InputCostPerToken, model)
  919. require.InDelta(t, pricing.InputCostPerToken, pricing.CacheCreationInputTokenCost, 1e-15, "%s 缓存写入基础价应等于标准输入价", model)
  920. require.Equal(t, 200000, pricing.LongContextInputTokenThreshold, model)
  921. if pricing.InputCostPerTokenPriority > 0 {
  922. require.InDelta(t, pricing.InputCostPerTokenPriority, pricing.CacheCreationInputTokenCostPriority, 1e-15, "%s priority 缓存写入价应等于 priority 输入价", model)
  923. }
  924. }
  925. }
  926. // F1:显式字段只写了一侧倍率时,缺失侧按 1 计而不是乘 0 免费。
  927. func TestCalculateCost_PartialLongContextMultiplierDefaultsToOne(t *testing.T) {
  928. tokens := UsageTokens{InputTokens: 300000, OutputTokens: 1000, CacheReadTokens: 10000}
  929. t.Run("only input multiplier", func(t *testing.T) {
  930. svc := NewBillingService(&config.Config{}, newStubPricingServiceFromJSON(t, `{
  931. "partial-in": {"litellm_provider": "openai", "mode": "chat",
  932. "input_cost_per_token": 2e-06, "output_cost_per_token": 1e-05,
  933. "cache_read_input_token_cost": 2e-07,
  934. "long_context_input_token_threshold": 272000,
  935. "long_context_input_cost_multiplier": 2.0}
  936. }`))
  937. cost, err := svc.CalculateCost("partial-in", tokens, 1.0)
  938. require.NoError(t, err)
  939. require.True(t, cost.LongContextBillingApplied)
  940. require.InDelta(t, 300000*2e-6*2, cost.InputCost, 1e-10)
  941. require.InDelta(t, 1000*1e-5, cost.OutputCost, 1e-10, "缺失的 output 倍率按 1 计,不得为 0")
  942. require.InDelta(t, 10000*2e-7*2, cost.CacheReadCost, 1e-10)
  943. })
  944. t.Run("only output multiplier", func(t *testing.T) {
  945. svc := NewBillingService(&config.Config{}, newStubPricingServiceFromJSON(t, `{
  946. "partial-out": {"litellm_provider": "openai", "mode": "chat",
  947. "input_cost_per_token": 2e-06, "output_cost_per_token": 1e-05,
  948. "cache_read_input_token_cost": 2e-07,
  949. "long_context_input_token_threshold": 272000,
  950. "long_context_output_cost_multiplier": 1.5}
  951. }`))
  952. cost, err := svc.CalculateCost("partial-out", tokens, 1.0)
  953. require.NoError(t, err)
  954. require.True(t, cost.LongContextBillingApplied)
  955. require.InDelta(t, 300000*2e-6, cost.InputCost, 1e-10, "缺失的 input 倍率按 1 计,不得为 0")
  956. require.InDelta(t, 1000*1e-5*1.5, cost.OutputCost, 1e-10)
  957. require.InDelta(t, 10000*2e-7, cost.CacheReadCost, 1e-10, "cache_read 跟随 input 倍率,同样按 1 计")
  958. })
  959. }
  960. // 行为声明:目录带 above_200k 的 Claude sonnet 条目同样获得数据驱动的整单阶梯
  961. // (与 Anthropic 官方 1M 长上下文定价一致),受分组长上下文开关约束。
  962. func TestCalculateCost_ClaudeSonnetCatalogLadderIsDataDriven(t *testing.T) {
  963. svc := NewBillingService(&config.Config{}, newStubPricingServiceFromJSON(t, `{
  964. "claude-sonnet-4-5": {"litellm_provider": "anthropic", "mode": "chat",
  965. "input_cost_per_token": 3e-06, "output_cost_per_token": 1.5e-05,
  966. "cache_read_input_token_cost": 3e-07,
  967. "input_cost_per_token_above_200k_tokens": 6e-06,
  968. "output_cost_per_token_above_200k_tokens": 2.25e-05,
  969. "cache_read_input_token_cost_above_200k_tokens": 6e-07}
  970. }`))
  971. pricing, err := svc.GetModelPricing("claude-sonnet-4-5")
  972. require.NoError(t, err)
  973. require.Equal(t, 200000, pricing.LongContextInputThreshold)
  974. require.False(t, pricing.LongContextThresholdInclusive, "anthropic 为严格大于")
  975. over := UsageTokens{InputTokens: 250000, OutputTokens: 1000}
  976. cost, err := svc.CalculateCost("claude-sonnet-4-5", over, 1.0)
  977. require.NoError(t, err)
  978. require.True(t, cost.LongContextBillingApplied)
  979. require.InDelta(t, 250000*3e-6*2, cost.InputCost, 1e-10)
  980. require.InDelta(t, 1000*1.5e-5*1.5, cost.OutputCost, 1e-10)
  981. under := UsageTokens{InputTokens: 200000, OutputTokens: 1000}
  982. cost, err = svc.CalculateCost("claude-sonnet-4-5", under, 1.0)
  983. require.NoError(t, err)
  984. require.False(t, cost.LongContextBillingApplied, "恰好 200000 不进高档(严格大于)")
  985. }