calculator_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. package inferencecost
  2. import (
  3. "math"
  4. "testing"
  5. )
  6. func defaultConfig() *Config {
  7. return &Config{
  8. AllocationMode: AllocationModeComputeTime,
  9. OutputTokenCostMultiplier: 2.5,
  10. }
  11. }
  12. func newCalc(cfg *Config) *Calculator { return NewCalculator(cfg) }
  13. // floatEq returns true if a and b differ by less than 1e-9.
  14. func floatEq(a, b float64) bool { return math.Abs(a-b) < 1e-9 }
  15. // ---- blended per-million-tokens ----
  16. func TestCalculator_BlendedCostPerMillionTokens(t *testing.T) {
  17. cfg := defaultConfig()
  18. m := &InferenceCost{
  19. AllocationTotalCost: 4.0,
  20. UsageTotalCost: 1.0,
  21. PromptTokens: 800_000,
  22. GenerationTokens: 200_000,
  23. TotalTokens: 1_000_000,
  24. EffectiveInputTokens: 800_000,
  25. // no timing data → multiplier fallback
  26. }
  27. newCalc(cfg).CalculateCosts([]*InferenceCost{m})
  28. if !floatEq(m.CostPerMillionTokens[CostBasisAllocation], 4.0) {
  29. t.Errorf("allocation blended want 4.0 got %f", m.CostPerMillionTokens[CostBasisAllocation])
  30. }
  31. if !floatEq(m.CostPerMillionTokens[CostBasisUsage], 1.0) {
  32. t.Errorf("usage blended want 1.0 got %f", m.CostPerMillionTokens[CostBasisUsage])
  33. }
  34. }
  35. func TestCalculator_BlendedZeroTokens(t *testing.T) {
  36. m := &InferenceCost{AllocationTotalCost: 1.0, UsageTotalCost: 0.5}
  37. newCalc(defaultConfig()).CalculateCosts([]*InferenceCost{m})
  38. if m.CostPerMillionTokens[CostBasisAllocation] != 0 {
  39. t.Error("expected zero blended cost when TotalTokens == 0")
  40. }
  41. }
  42. // ---- compute-time split ----
  43. func TestCalculator_ComputeTimeSplit_BothBases(t *testing.T) {
  44. cfg := defaultConfig()
  45. m := &InferenceCost{
  46. AllocationTotalCost: 4.0,
  47. UsageTotalCost: 1.0,
  48. PromptTokens: 600_000,
  49. GenerationTokens: 400_000,
  50. TotalTokens: 1_000_000,
  51. EffectiveInputTokens: 600_000, // no cache correction
  52. InputProcessingTime: 70.0,
  53. OutputProcessingTime: 30.0,
  54. }
  55. newCalc(cfg).CalculateCosts([]*InferenceCost{m})
  56. // inputFraction = 0.7, outputFraction = 0.3
  57. // usage: inputCost=0.7, outputCost=0.3
  58. wantUsageInput := 0.7 / 600_000 * 1_000_000
  59. wantUsageOutput := 0.3 / 400_000 * 1_000_000
  60. wantAllocInput := (4.0 * 0.7) / 600_000 * 1_000_000
  61. wantAllocOutput := (4.0 * 0.3) / 400_000 * 1_000_000
  62. if !floatEq(m.InputCostPerMillionTokens[CostBasisUsage], wantUsageInput) {
  63. t.Errorf("usage input want %f got %f", wantUsageInput, m.InputCostPerMillionTokens[CostBasisUsage])
  64. }
  65. if !floatEq(m.OutputCostPerMillionTokens[CostBasisUsage], wantUsageOutput) {
  66. t.Errorf("usage output want %f got %f", wantUsageOutput, m.OutputCostPerMillionTokens[CostBasisUsage])
  67. }
  68. if !floatEq(m.InputCostPerMillionTokens[CostBasisAllocation], wantAllocInput) {
  69. t.Errorf("alloc input want %f got %f", wantAllocInput, m.InputCostPerMillionTokens[CostBasisAllocation])
  70. }
  71. if !floatEq(m.OutputCostPerMillionTokens[CostBasisAllocation], wantAllocOutput) {
  72. t.Errorf("alloc output want %f got %f", wantAllocOutput, m.OutputCostPerMillionTokens[CostBasisAllocation])
  73. }
  74. if m.AllocationMethod != AllocationMethodComputeTime {
  75. t.Errorf("expected compute_time (no block size), got %s", m.AllocationMethod)
  76. }
  77. }
  78. func TestCalculator_ComputeTimeSplit_InputOutputSumToTotal(t *testing.T) {
  79. cfg := defaultConfig()
  80. m := &InferenceCost{
  81. AllocationTotalCost: 10.0,
  82. UsageTotalCost: 3.0,
  83. PromptTokens: 500_000,
  84. GenerationTokens: 500_000,
  85. TotalTokens: 1_000_000,
  86. EffectiveInputTokens: 500_000,
  87. InputProcessingTime: 60.0,
  88. OutputProcessingTime: 40.0,
  89. }
  90. newCalc(cfg).CalculateCosts([]*InferenceCost{m})
  91. // input_cost + output_cost must equal total for each basis.
  92. // Back-compute dollar amounts from the per-million rates using PromptTokens.
  93. for _, basis := range []CostBasis{CostBasisUsage, CostBasisAllocation} {
  94. var totalCost float64
  95. if basis == CostBasisUsage {
  96. totalCost = m.UsageTotalCost
  97. } else {
  98. totalCost = m.AllocationTotalCost
  99. }
  100. inputCost := m.InputCostPerMillionTokens[basis] / 1_000_000 * m.PromptTokens
  101. outputCost := m.OutputCostPerMillionTokens[basis] / 1_000_000 * m.GenerationTokens
  102. if !floatEq(inputCost+outputCost, totalCost) {
  103. t.Errorf("basis=%s: input+output=%.6f want %.6f", basis, inputCost+outputCost, totalCost)
  104. }
  105. }
  106. }
  107. // ---- KV cache savings fraction ----
  108. func TestCalculator_CacheSavingsFraction(t *testing.T) {
  109. cfg := &Config{
  110. AllocationMode: AllocationModeComputeTime,
  111. OutputTokenCostMultiplier: 2.5,
  112. }
  113. // 8 cached tokens out of 20 prompt tokens → 40% savings
  114. m := &InferenceCost{
  115. AllocationTotalCost: 1.0,
  116. UsageTotalCost: 1.0,
  117. PromptTokens: 20,
  118. GenerationTokens: 10,
  119. TotalTokens: 30,
  120. CachedTokens: 8,
  121. CacheConfigKnown: true,
  122. PrefixCachingEnabled: true,
  123. EffectiveInputTokens: 12,
  124. InputProcessingTime: 60,
  125. OutputProcessingTime: 40,
  126. }
  127. newCalc(cfg).CalculateCosts([]*InferenceCost{m})
  128. // inputCostPerMillionTokens uses PromptTokens (20) as denominator.
  129. wantInputCPM := (1.0 * 0.6 / 20) * 1_000_000
  130. got := m.InputCostPerMillionTokens[CostBasisUsage]
  131. if !floatEq(got, wantInputCPM) {
  132. t.Errorf("input CPM want %f got %f", wantInputCPM, got)
  133. }
  134. // CacheSavingsFraction = 8/20 = 0.4
  135. if !floatEq(m.CacheSavingsFraction, 0.4) {
  136. t.Errorf("CacheSavingsFraction want 0.4 got %f", m.CacheSavingsFraction)
  137. }
  138. // Method collapses to compute_time regardless of cache hits.
  139. if m.AllocationMethod != AllocationMethodComputeTime {
  140. t.Errorf("expected compute_time, got %s", m.AllocationMethod)
  141. }
  142. }
  143. func TestCalculator_CacheCorrection_Disabled_WhenConfigUnknown(t *testing.T) {
  144. cfg := &Config{
  145. AllocationMode: AllocationModeComputeTime,
  146. OutputTokenCostMultiplier: 2.5,
  147. }
  148. // CacheConfigKnown=false simulates vllm:cache_config_info being unavailable.
  149. m := &InferenceCost{
  150. AllocationTotalCost: 1.0,
  151. UsageTotalCost: 1.0,
  152. PromptTokens: 20,
  153. GenerationTokens: 10,
  154. TotalTokens: 30,
  155. CachedTokens: 0,
  156. CacheConfigKnown: false,
  157. EffectiveInputTokens: 20,
  158. InputProcessingTime: 60,
  159. OutputProcessingTime: 40,
  160. }
  161. newCalc(cfg).CalculateCosts([]*InferenceCost{m})
  162. if m.AllocationMethod != AllocationMethodComputeTime {
  163. t.Errorf("expected compute_time when cache config unknown, got %s", m.AllocationMethod)
  164. }
  165. wantInput := (1.0 * 0.6 / 20) * 1_000_000
  166. if !floatEq(m.InputCostPerMillionTokens[CostBasisUsage], wantInput) {
  167. t.Errorf("want %f got %f", wantInput, m.InputCostPerMillionTokens[CostBasisUsage])
  168. }
  169. if m.CacheSavingsFraction != 0 {
  170. t.Errorf("CacheSavingsFraction want 0 when config unknown, got %f", m.CacheSavingsFraction)
  171. }
  172. }
  173. func TestCalculator_PrefixCachingOff_WhenConfigKnownAndDisabled(t *testing.T) {
  174. cfg := &Config{
  175. AllocationMode: AllocationModeComputeTime,
  176. OutputTokenCostMultiplier: 2.5,
  177. }
  178. m := &InferenceCost{
  179. AllocationTotalCost: 1.0,
  180. UsageTotalCost: 1.0,
  181. PromptTokens: 100,
  182. GenerationTokens: 50,
  183. TotalTokens: 150,
  184. CachedTokens: 0,
  185. CacheConfigKnown: true,
  186. PrefixCachingEnabled: false, // explicitly disabled
  187. EffectiveInputTokens: 100,
  188. InputProcessingTime: 70,
  189. OutputProcessingTime: 30,
  190. }
  191. newCalc(cfg).CalculateCosts([]*InferenceCost{m})
  192. if m.AllocationMethod != AllocationMethodPrefixCachingOff {
  193. t.Errorf("expected prefix_caching_off, got %s", m.AllocationMethod)
  194. }
  195. }
  196. func TestCalculator_CacheCorrection_Disabled_WhenNoCacheHits(t *testing.T) {
  197. cfg := &Config{
  198. AllocationMode: AllocationModeComputeTime,
  199. OutputTokenCostMultiplier: 2.5,
  200. }
  201. m := &InferenceCost{
  202. AllocationTotalCost: 1.0,
  203. UsageTotalCost: 1.0,
  204. PromptTokens: 100,
  205. GenerationTokens: 50,
  206. TotalTokens: 150,
  207. CachedTokens: 0, // no hits in this window
  208. CacheConfigKnown: true,
  209. PrefixCachingEnabled: true, // caching is on, just no hits occurred
  210. EffectiveInputTokens: 100,
  211. InputProcessingTime: 70,
  212. OutputProcessingTime: 30,
  213. }
  214. newCalc(cfg).CalculateCosts([]*InferenceCost{m})
  215. if m.AllocationMethod != AllocationMethodComputeTime {
  216. t.Errorf("expected compute_time when prefix caching enabled but no hits in window, got %s", m.AllocationMethod)
  217. }
  218. }
  219. // ---- multiplier fallback ----
  220. func TestCalculator_MultiplierFallback_BothBases(t *testing.T) {
  221. cfg := &Config{
  222. AllocationMode: AllocationModeComputeTime,
  223. OutputTokenCostMultiplier: 2.5,
  224. }
  225. // No timing data → multiplier fallback
  226. m := &InferenceCost{
  227. AllocationTotalCost: 5.0,
  228. UsageTotalCost: 2.0,
  229. PromptTokens: 800_000,
  230. GenerationTokens: 200_000,
  231. TotalTokens: 1_000_000,
  232. EffectiveInputTokens: 800_000,
  233. }
  234. newCalc(cfg).CalculateCosts([]*InferenceCost{m})
  235. if m.AllocationMethod != AllocationMethodMultiplier {
  236. t.Errorf("expected multiplier method, got %s", m.AllocationMethod)
  237. }
  238. // weightedTokens = 800000 + 200000*2.5 = 1300000
  239. // usage: inputCPT = 2.0/1300000; inputCPM = inputCPT*1e6
  240. // alloc: inputCPT = 5.0/1300000
  241. for _, tc := range []struct {
  242. basis CostBasis
  243. totalCost float64
  244. }{
  245. {CostBasisUsage, 2.0},
  246. {CostBasisAllocation, 5.0},
  247. } {
  248. weighted := 800_000.0 + 200_000.0*2.5
  249. wantInput := (tc.totalCost / weighted) * 1_000_000
  250. wantOutput := wantInput * 2.5
  251. if !floatEq(m.InputCostPerMillionTokens[tc.basis], wantInput) {
  252. t.Errorf("basis=%s input want %f got %f", tc.basis, wantInput, m.InputCostPerMillionTokens[tc.basis])
  253. }
  254. if !floatEq(m.OutputCostPerMillionTokens[tc.basis], wantOutput) {
  255. t.Errorf("basis=%s output want %f got %f", tc.basis, wantOutput, m.OutputCostPerMillionTokens[tc.basis])
  256. }
  257. }
  258. }
  259. func TestCalculator_MultiplierFallback_ZeroTokens(t *testing.T) {
  260. m := &InferenceCost{AllocationTotalCost: 1.0, UsageTotalCost: 0.5}
  261. // EffectiveInputTokens and GenerationTokens are both 0
  262. cfg := &Config{AllocationMode: AllocationModeMultiplier, OutputTokenCostMultiplier: 2.5}
  263. newCalc(cfg).CalculateCosts([]*InferenceCost{m})
  264. if m.InputCostPerMillionTokens[CostBasisUsage] != 0 ||
  265. m.OutputCostPerMillionTokens[CostBasisAllocation] != 0 {
  266. t.Error("expected zero derived costs when tokens are zero")
  267. }
  268. }
  269. func TestCalculator_IncompleteTimingData_FallsBackToMultiplier(t *testing.T) {
  270. cfg := &Config{
  271. AllocationMode: AllocationModeComputeTime,
  272. OutputTokenCostMultiplier: 2.5,
  273. }
  274. m := &InferenceCost{
  275. AllocationTotalCost: 5.0,
  276. UsageTotalCost: 2.0,
  277. PromptTokens: 800_000,
  278. GenerationTokens: 200_000,
  279. TotalTokens: 1_000_000,
  280. EffectiveInputTokens: 800_000,
  281. InputProcessingTime: 60,
  282. OutputProcessingTime: 0, // incomplete timing data
  283. }
  284. newCalc(cfg).CalculateCosts([]*InferenceCost{m})
  285. if m.AllocationMethod != AllocationMethodMultiplier {
  286. t.Fatalf("expected multiplier fallback for incomplete timing data, got %s", m.AllocationMethod)
  287. }
  288. weighted := 800_000.0 + 200_000.0*2.5
  289. wantInput := (2.0 / weighted) * 1_000_000
  290. wantOutput := wantInput * 2.5
  291. if !floatEq(m.InputCostPerMillionTokens[CostBasisUsage], wantInput) {
  292. t.Errorf("usage input want %f got %f", wantInput, m.InputCostPerMillionTokens[CostBasisUsage])
  293. }
  294. if !floatEq(m.OutputCostPerMillionTokens[CostBasisUsage], wantOutput) {
  295. t.Errorf("usage output want %f got %f", wantOutput, m.OutputCostPerMillionTokens[CostBasisUsage])
  296. }
  297. }