collector_test.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  1. package inferencecost
  2. import (
  3. "context"
  4. "testing"
  5. "time"
  6. "github.com/opencost/opencost/core/pkg/opencost"
  7. "github.com/opencost/opencost/core/pkg/source"
  8. )
  9. // mockQuerier implements AllocationQuerier for testing.
  10. type mockQuerier struct {
  11. set *opencost.AllocationSet
  12. err error
  13. // For dual-query tests, return different sets on subsequent calls
  14. callCount int
  15. sets []*opencost.AllocationSet
  16. }
  17. func (m *mockQuerier) ComputeAllocation(start, end time.Time) (*opencost.AllocationSet, error) {
  18. if m.err != nil {
  19. return nil, m.err
  20. }
  21. // If multiple sets are provided, return them in sequence
  22. if len(m.sets) > 0 {
  23. if m.callCount < len(m.sets) {
  24. set := m.sets[m.callCount]
  25. m.callCount++
  26. return set, nil
  27. }
  28. // Return last set for any additional calls
  29. return m.sets[len(m.sets)-1], nil
  30. }
  31. // Otherwise return the single set
  32. return m.set, nil
  33. }
  34. // Helper function to create a mock metrics querier with custom inference metric responses
  35. func newMockMetricsQuerierWithInferenceMetrics(
  36. promptTokens map[string]float64,
  37. generationTokens map[string]float64,
  38. inputTime map[string]float64,
  39. outputTime map[string]float64,
  40. cachedTokens map[string]float64,
  41. cacheConfigs map[string]*source.InferenceCacheConfig,
  42. ) *source.MockMetricsQuerier {
  43. mock := source.NewMockMetricsQuerier()
  44. // Set up inference metric overrides
  45. if promptTokens != nil {
  46. mock.SetOverride(source.QueryInferencePromptTokens, []*source.InferenceTokensResult{
  47. {Values: promptTokens},
  48. })
  49. }
  50. if generationTokens != nil {
  51. mock.SetOverride(source.QueryInferenceGenerationTokens, []*source.InferenceTokensResult{
  52. {Values: generationTokens},
  53. })
  54. }
  55. if inputTime != nil {
  56. mock.SetOverride(source.QueryInferenceInputProcessingTime, []*source.InferenceProcessingTimeResult{
  57. {Values: inputTime},
  58. })
  59. }
  60. if outputTime != nil {
  61. mock.SetOverride(source.QueryInferenceOutputProcessingTime, []*source.InferenceProcessingTimeResult{
  62. {Values: outputTime},
  63. })
  64. }
  65. if cachedTokens != nil {
  66. mock.SetOverride(source.QueryInferenceCachedTokens, []*source.InferenceTokensResult{
  67. {Values: cachedTokens},
  68. })
  69. }
  70. if cacheConfigs != nil {
  71. mock.SetOverride(source.QueryInferenceCacheConfig, []*source.InferenceCacheConfigResult{
  72. {Configs: cacheConfigs},
  73. })
  74. }
  75. return mock
  76. }
  77. func makeAllocation(name string, gpuCost, cpuCost, ramCost, gpuCostIdle, cpuCostIdle, ramCostIdle float64, labels map[string]string, namespace string) *opencost.Allocation {
  78. a := &opencost.Allocation{
  79. Name: name,
  80. GPUCost: gpuCost,
  81. CPUCost: cpuCost,
  82. RAMCost: ramCost,
  83. // Idle fields stored directly — they are added into TotalCost by OpenCost
  84. // when idle is distributed via ShareWeighted.
  85. GPUCostIdle: gpuCostIdle,
  86. CPUCostIdle: cpuCostIdle,
  87. RAMCostIdle: ramCostIdle,
  88. Properties: &opencost.AllocationProperties{
  89. Namespace: namespace,
  90. Labels: opencost.AllocationLabels(labels),
  91. },
  92. }
  93. return a
  94. }
  95. func baseConfig() *Config {
  96. return &Config{
  97. PrometheusURL: "http://fake-prometheus:9090",
  98. CollectionInterval: 5 * time.Minute,
  99. ModelLabel: "llm-d.ai/model",
  100. SharedInfraLabel: "llm-d.ai/inference-shared",
  101. SharedInfraLabelValue: "true",
  102. AllocationMode: AllocationModeComputeTime,
  103. OutputTokenCostMultiplier: 2.5,
  104. }
  105. }
  106. // TestCollector_ExtractAllocationResults verifies that extractAllocationResults
  107. // correctly extracts allocation and usage costs from AllocationSets.
  108. func TestCollector_ExtractAllocationResults(t *testing.T) {
  109. now := time.Now()
  110. cfg := baseConfig()
  111. c := &Collector{config: cfg}
  112. // Test allocation cost extraction (with idle)
  113. allocWithIdle := &opencost.Allocation{
  114. Name: "llama-3",
  115. GPUCost: 3.0,
  116. CPUCost: 0.5,
  117. RAMCost: 0.5,
  118. Properties: &opencost.AllocationProperties{
  119. Namespace: "llm-prod",
  120. },
  121. }
  122. asWithIdle := opencost.NewAllocationSet(now.Add(-5*time.Minute), now)
  123. asWithIdle.Set(allocWithIdle)
  124. resultsAlloc, err := c.extractAllocationResults(asWithIdle, true)
  125. if err != nil {
  126. t.Fatalf("extractAllocationResults (allocation) failed: %v", err)
  127. }
  128. key := modelNamespaceKey("llama-3", "llm-prod")
  129. r, ok := resultsAlloc[key]
  130. if !ok {
  131. t.Fatal("expected allocation result for llama-3/llm-prod")
  132. }
  133. if !floatEq(r.allocationTotalCost, 4.0) {
  134. t.Errorf("allocationTotalCost want 4.0 got %f", r.allocationTotalCost)
  135. }
  136. if r.usageTotalCost != 0 {
  137. t.Errorf("usageTotalCost should be 0 in allocation query, got %f", r.usageTotalCost)
  138. }
  139. // Test usage cost extraction (without idle)
  140. allocWithoutIdle := &opencost.Allocation{
  141. Name: "llama-3",
  142. GPUCost: 2.0,
  143. CPUCost: 0.3,
  144. RAMCost: 0.3,
  145. Properties: &opencost.AllocationProperties{
  146. Namespace: "llm-prod",
  147. },
  148. }
  149. asWithoutIdle := opencost.NewAllocationSet(now.Add(-5*time.Minute), now)
  150. asWithoutIdle.Set(allocWithoutIdle)
  151. resultsUsage, err := c.extractAllocationResults(asWithoutIdle, false)
  152. if err != nil {
  153. t.Fatalf("extractAllocationResults (usage) failed: %v", err)
  154. }
  155. r2, ok := resultsUsage[key]
  156. if !ok {
  157. t.Fatal("expected usage result for llama-3/llm-prod")
  158. }
  159. if !floatEq(r2.usageTotalCost, 2.6) {
  160. t.Errorf("usageTotalCost want 2.6 got %f", r2.usageTotalCost)
  161. }
  162. if r2.allocationTotalCost != 0 {
  163. t.Errorf("allocationTotalCost should be 0 in usage query, got %f", r2.allocationTotalCost)
  164. }
  165. }
  166. // TestCollector_UsageCost_ExcludesIdle verifies the mathematical relationship
  167. // between allocation and usage costs when idle is present.
  168. func TestCollector_UsageCost_ExcludesIdle(t *testing.T) {
  169. // With ShareWeighted: AllocationTotalCost = 4.0 (GPU 3.0 + CPU 0.5 + RAM 0.5)
  170. // With ShareNone: UsageCost = 2.6 (excludes idle: 1.0 + 0.2 + 0.2 = 1.4)
  171. allocTotal := 4.0
  172. idleGPU, idleCPU, idleRAM := 1.0, 0.2, 0.2
  173. expectedUsageCost := allocTotal - (idleGPU + idleCPU + idleRAM)
  174. if !floatEq(expectedUsageCost, 2.6) {
  175. t.Errorf("expected usage cost 2.6 got %f", expectedUsageCost)
  176. }
  177. if expectedUsageCost >= allocTotal {
  178. t.Error("usage cost should be less than allocation cost when idle is present")
  179. }
  180. }
  181. // TestCollector_CombineMetrics_DerivesCachedTokens verifies that combineMetrics
  182. // passes CachedTokens through directly and derives EffectiveInputTokens correctly.
  183. func TestCollector_CombineMetrics_DerivesCachedTokens(t *testing.T) {
  184. cfg := baseConfig()
  185. allocCosts := map[string]*allocationResult{
  186. "llama-3:llm-prod": {allocationTotalCost: 4.0, usageTotalCost: 2.6, namespace: "llm-prod"},
  187. }
  188. promptTokens := map[string]float64{"llama-3:llm-prod": 20}
  189. genTokens := map[string]float64{"llama-3:llm-prod": 10}
  190. inputTime := map[string]float64{}
  191. outputTime := map[string]float64{}
  192. // vllm:prefix_cache_hits_total reports tokens directly (not blocks).
  193. cachedTokens := map[string]float64{"llama-3:llm-prod": 8}
  194. cacheConfigs := map[string]*cacheConfig{"llama-3:llm-prod": {prefixCachingEnabled: true}}
  195. c := &Collector{config: cfg}
  196. now := time.Now()
  197. results := c.combineMetrics(allocCosts, promptTokens, genTokens, inputTime, outputTime, cachedTokens, cacheConfigs, now.Add(-1*time.Hour), now)
  198. if len(results) != 1 {
  199. t.Fatalf("expected 1 result, got %d", len(results))
  200. }
  201. m := results[0]
  202. if !floatEq(m.CachedTokens, 8) {
  203. t.Errorf("CachedTokens want 8 got %f", m.CachedTokens)
  204. }
  205. if !floatEq(m.EffectiveInputTokens, 12) {
  206. t.Errorf("EffectiveInputTokens want 12 got %f", m.EffectiveInputTokens)
  207. }
  208. }
  209. // TestCollector_CombineMetrics_NoCacheHits_FallsBackToPromptTokens verifies that
  210. // EffectiveInputTokens equals PromptTokens when no cache hits are reported.
  211. func TestCollector_CombineMetrics_NoCacheHits_FallsBackToPromptTokens(t *testing.T) {
  212. cfg := baseConfig()
  213. allocCosts := map[string]*allocationResult{
  214. "llama-3:llm-prod": {allocationTotalCost: 1.0, usageTotalCost: 1.0, namespace: "llm-prod"},
  215. }
  216. promptTokens := map[string]float64{"llama-3:llm-prod": 1000}
  217. genTokens := map[string]float64{"llama-3:llm-prod": 500}
  218. // cachedTokens map is empty — simulates metric being unavailable
  219. cacheHits := map[string]float64{}
  220. cacheConfigs := map[string]*cacheConfig{"llama-3:llm-prod": {prefixCachingEnabled: true}}
  221. c := &Collector{config: cfg}
  222. now := time.Now()
  223. results := c.combineMetrics(allocCosts, promptTokens, genTokens,
  224. map[string]float64{}, map[string]float64{}, cacheHits, cacheConfigs, now.Add(-1*time.Hour), now)
  225. if len(results) != 1 {
  226. t.Fatalf("expected 1 result, got %d", len(results))
  227. }
  228. m := results[0]
  229. if !floatEq(m.EffectiveInputTokens, 1000) {
  230. t.Errorf("EffectiveInputTokens should fall back to PromptTokens=1000, got %f", m.EffectiveInputTokens)
  231. }
  232. }
  233. // TestReconcileTokenKeys_OrgPrefixMismatch verifies that a metric key with a
  234. // fully-qualified org/model name is re-keyed to match the allocation key that
  235. // uses only the short name, and that keys which already match are left unchanged.
  236. func TestReconcileTokenKeys_OrgPrefixMismatch(t *testing.T) {
  237. allocCosts := map[string]*allocationResult{
  238. "MiniMax-M2.7:llm-d-pic": {allocationTotalCost: 489.0, namespace: "llm-d-pic"},
  239. "gpt-oss-120b:dolev-inf": {allocationTotalCost: 453.0, namespace: "dolev-inf"},
  240. // This alloc key already has a slash and no short-name alternative.
  241. "meta-llama/Llama-3:prod": {allocationTotalCost: 10.0, namespace: "prod"},
  242. }
  243. tokens := map[string]float64{
  244. // Mismatch: vLLM uses full org/model, alloc uses short name.
  245. "MiniMaxAI/MiniMax-M2.7:llm-d-pic": 4316.0,
  246. "openai/gpt-oss-120b:dolev-inf": 4773.0,
  247. // Already matches alloc key — should pass through unchanged.
  248. "meta-llama/Llama-3:prod": 1000.0,
  249. // No alloc entry at all — should pass through unchanged.
  250. "unknown-org/new-model:some-ns": 99.0,
  251. }
  252. out, remappedKeys := reconcileTokenKeys(tokens, allocCosts)
  253. // Remapped entries should appear under the short-name alloc keys.
  254. if v, ok := out["MiniMax-M2.7:llm-d-pic"]; !ok || !floatEq(v, 4316.0) {
  255. t.Errorf("MiniMax-M2.7:llm-d-pic want 4316.0 got %v (ok=%v)", v, ok)
  256. }
  257. if v, ok := out["gpt-oss-120b:dolev-inf"]; !ok || !floatEq(v, 4773.0) {
  258. t.Errorf("gpt-oss-120b:dolev-inf want 4773.0 got %v (ok=%v)", v, ok)
  259. }
  260. // Original org-prefixed keys must be gone.
  261. if _, ok := out["MiniMaxAI/MiniMax-M2.7:llm-d-pic"]; ok {
  262. t.Error("org-prefixed key MiniMaxAI/MiniMax-M2.7:llm-d-pic should have been removed")
  263. }
  264. if _, ok := out["openai/gpt-oss-120b:dolev-inf"]; ok {
  265. t.Error("org-prefixed key openai/gpt-oss-120b:dolev-inf should have been removed")
  266. }
  267. // Verify remapped keys are tracked.
  268. if _, ok := remappedKeys["MiniMaxAI/MiniMax-M2.7:llm-d-pic"]; !ok {
  269. t.Error("MiniMaxAI/MiniMax-M2.7:llm-d-pic should be in remappedKeys")
  270. }
  271. if _, ok := remappedKeys["openai/gpt-oss-120b:dolev-inf"]; !ok {
  272. t.Error("openai/gpt-oss-120b:dolev-inf should be in remappedKeys")
  273. }
  274. // Keys that already matched or had no alloc entry pass through unchanged.
  275. if v, ok := out["meta-llama/Llama-3:prod"]; !ok || !floatEq(v, 1000.0) {
  276. t.Errorf("meta-llama/Llama-3:prod want 1000.0 got %v (ok=%v)", v, ok)
  277. }
  278. if v, ok := out["unknown-org/new-model:some-ns"]; !ok || !floatEq(v, 99.0) {
  279. t.Errorf("unknown-org/new-model:some-ns want 99.0 got %v (ok=%v)", v, ok)
  280. }
  281. }
  282. func TestReconcileTokenKeys_PrefersShortAllocationKeyWhenBothFormsExist(t *testing.T) {
  283. allocCosts := map[string]*allocationResult{
  284. "gemma-4-31B:llm-d-pic": {allocationTotalCost: 10.0, namespace: "llm-d-pic"},
  285. "google/gemma-4-31B:llm-d-pic": {allocationTotalCost: 1.0, namespace: "llm-d-pic"},
  286. }
  287. tokens := map[string]float64{
  288. "google/gemma-4-31B:llm-d-pic": 123.0,
  289. }
  290. out, remappedKeys := reconcileTokenKeys(tokens, allocCosts)
  291. if v, ok := out["gemma-4-31B:llm-d-pic"]; !ok || !floatEq(v, 123.0) {
  292. t.Errorf("gemma-4-31B:llm-d-pic want 123.0 got %v (ok=%v)", v, ok)
  293. }
  294. if _, ok := out["google/gemma-4-31B:llm-d-pic"]; ok {
  295. t.Error("google/gemma-4-31B:llm-d-pic should have been folded into gemma-4-31B:llm-d-pic")
  296. }
  297. if _, ok := remappedKeys["google/gemma-4-31B:llm-d-pic"]; !ok {
  298. t.Error("google/gemma-4-31B:llm-d-pic should be in remappedKeys")
  299. }
  300. }
  301. // TestCollector_BuildQueryWindow verifies that buildQueryWindow generates
  302. // correct Prometheus time range selectors based on CollectionInterval.
  303. // TestQueryCounterDelta_Formula verifies the delta = end - start subtraction
  304. // and that negative deltas (counter resets) use endVal to capture post-reset activity.
  305. func TestQueryCounterDelta_Formula(t *testing.T) {
  306. tests := []struct {
  307. name string
  308. endVal float64
  309. startVal float64
  310. want float64
  311. }{
  312. {name: "normal increase", endVal: 1000, startVal: 200, want: 800},
  313. {name: "no activity", endVal: 500, startVal: 500, want: 0},
  314. {name: "counter reset uses endVal", endVal: 100, startVal: 900, want: 100},
  315. {name: "new pod (no start sample)", endVal: 400, startVal: 0, want: 400},
  316. }
  317. for _, tt := range tests {
  318. t.Run(tt.name, func(t *testing.T) {
  319. delta := tt.endVal - tt.startVal
  320. if delta < 0 {
  321. delta = tt.endVal
  322. }
  323. if delta != tt.want {
  324. t.Errorf("delta = %v, want %v", delta, tt.want)
  325. }
  326. })
  327. }
  328. }
  329. // TestReconcileTokenKeys_NoMismatch verifies that when all token keys directly
  330. // match allocation keys, no re-keying occurs and no entries are dropped.
  331. func TestReconcileTokenKeys_NoMismatch(t *testing.T) {
  332. allocCosts := map[string]*allocationResult{
  333. "llama-3:prod": {allocationTotalCost: 1.0},
  334. }
  335. tokens := map[string]float64{
  336. "llama-3:prod": 500.0,
  337. }
  338. out, remappedKeys := reconcileTokenKeys(tokens, allocCosts)
  339. if v, ok := out["llama-3:prod"]; !ok || !floatEq(v, 500.0) {
  340. t.Errorf("want llama-3:prod=500.0 got %v (ok=%v)", v, ok)
  341. }
  342. if len(out) != 1 {
  343. t.Errorf("expected 1 entry, got %d", len(out))
  344. }
  345. if len(remappedKeys) != 0 {
  346. t.Errorf("expected no remapped keys, got %d", len(remappedKeys))
  347. }
  348. }
  349. // TestCollector_CollectMetrics_EmptyMetrics ensures that CollectMetrics
  350. // handles empty metrics gracefully (returns empty results, not an error).
  351. func TestCollector_CollectMetrics_EmptyMetrics(t *testing.T) {
  352. cfg := baseConfig()
  353. now := time.Now()
  354. querier := &mockQuerier{set: opencost.NewAllocationSet(now.Add(-5*time.Minute), now)}
  355. // Use the standard mock - it will return empty results by default
  356. metricsQuerier := source.NewMockMetricsQuerier()
  357. collector, err := NewCollector(cfg, querier, metricsQuerier)
  358. if err != nil {
  359. t.Fatalf("NewCollector returned unexpected error: %v", err)
  360. }
  361. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  362. defer cancel()
  363. end := time.Now()
  364. start := end.Add(-5 * time.Minute)
  365. results, err := collector.CollectMetrics(ctx, start, end)
  366. // With empty metrics, CollectMetrics should succeed with empty results
  367. if err != nil {
  368. t.Errorf("unexpected error with empty metrics: %v", err)
  369. }
  370. if len(results) != 0 {
  371. t.Errorf("expected 0 results with empty metrics, got %d", len(results))
  372. }
  373. }
  374. func TestCollector_CombineMetrics_IncludesTimingOnlyKeysInUnion(t *testing.T) {
  375. cfg := baseConfig()
  376. c := &Collector{config: cfg}
  377. allocCosts := map[string]*allocationResult{}
  378. promptTokens := map[string]float64{}
  379. genTokens := map[string]float64{}
  380. inputTime := map[string]float64{"timing-only:ns1": 60}
  381. outputTime := map[string]float64{"timing-only:ns1": 40}
  382. cacheHits := map[string]float64{"timing-only:ns1": 2}
  383. cacheConfigs := map[string]*cacheConfig{}
  384. now := time.Now()
  385. results := c.combineMetrics(allocCosts, promptTokens, genTokens, inputTime, outputTime, cacheHits, cacheConfigs, now.Add(-1*time.Hour), now)
  386. if len(results) != 1 {
  387. t.Fatalf("expected 1 result, got %d", len(results))
  388. }
  389. m := results[0]
  390. if m.Properties.ModelName != "timing-only" || m.Properties.Namespace != "ns1" {
  391. t.Fatalf("unexpected properties: model=%s namespace=%s", m.Properties.ModelName, m.Properties.Namespace)
  392. }
  393. if !floatEq(m.InputProcessingTime, 60) {
  394. t.Errorf("InputProcessingTime want 60 got %f", m.InputProcessingTime)
  395. }
  396. if !floatEq(m.OutputProcessingTime, 40) {
  397. t.Errorf("OutputProcessingTime want 40 got %f", m.OutputProcessingTime)
  398. }
  399. if !floatEq(m.CachedTokens, 2) {
  400. t.Errorf("CachedTokens want 2 got %f", m.CachedTokens)
  401. }
  402. }