collector_test.go 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056
  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, in the absence of
  168. // utilisation metrics.
  169. //
  170. // Note: when utilisation metrics (CPUCoreUsageAverage, RAMBytesUsageAverage,
  171. // GPUUsageAverage) are available, usage cost is further reduced below
  172. // allocationCost - idle by scaling each resource to its actual consumption.
  173. // This test covers only the idle-exclusion step; see
  174. // TestCollector_UsageCost_ScalesResourcesByUtilisation for utilisation scaling.
  175. func TestCollector_UsageCost_ExcludesIdle(t *testing.T) {
  176. // With ShareWeighted: AllocationTotalCost = 4.0 (GPU 3.0 + CPU 0.5 + RAM 0.5)
  177. // With ShareNone: UsageCost = 2.6 (excludes idle: 1.0 + 0.2 + 0.2 = 1.4)
  178. allocTotal := 4.0
  179. idleGPU, idleCPU, idleRAM := 1.0, 0.2, 0.2
  180. expectedUsageCost := allocTotal - (idleGPU + idleCPU + idleRAM)
  181. if !floatEq(expectedUsageCost, 2.6) {
  182. t.Errorf("expected usage cost 2.6 got %f", expectedUsageCost)
  183. }
  184. if expectedUsageCost >= allocTotal {
  185. t.Error("usage cost should be less than allocation cost when idle is present")
  186. }
  187. }
  188. // makeAllocationWithUtilisation creates an Allocation with both cost and
  189. // utilisation fields set, for testing the usage cost scaling path.
  190. // Pass nil for gpuUsageAverage to omit the GPUAllocation entirely (no GPU metric available).
  191. func makeAllocationWithUtilisation(
  192. name string,
  193. gpuCost, cpuCost, ramCost float64,
  194. gpuUsageAverage *float64, // SM duty cycle fraction [0,1]; nil means no GPU metric
  195. cpuCoreRequest, cpuCoreUsage float64,
  196. ramBytesRequest, ramBytesUsage float64,
  197. namespace string,
  198. ) *opencost.Allocation {
  199. a := &opencost.Allocation{
  200. Name: name,
  201. GPUCost: gpuCost,
  202. CPUCost: cpuCost,
  203. RAMCost: ramCost,
  204. CPUCoreRequestAverage: cpuCoreRequest,
  205. CPUCoreUsageAverage: cpuCoreUsage,
  206. RAMBytesRequestAverage: ramBytesRequest,
  207. RAMBytesUsageAverage: ramBytesUsage,
  208. Properties: &opencost.AllocationProperties{
  209. Namespace: namespace,
  210. Labels: opencost.AllocationLabels(map[string]string{"llm-d.ai/model": name}),
  211. },
  212. }
  213. if gpuUsageAverage != nil {
  214. a.GPUAllocation = &opencost.GPUAllocation{
  215. GPUUsageAverage: gpuUsageAverage,
  216. }
  217. }
  218. return a
  219. }
  220. // gpuUsage is a helper that returns a pointer to a float64, for use in
  221. // makeAllocationWithUtilisation calls.
  222. func gpuUsage(v float64) *float64 { return &v }
  223. // TestCollector_UsageCost_ScalesResourcesByUtilisation verifies that when
  224. // utilisation metrics are present, extractAllocationResults scales GPU, CPU,
  225. // and RAM costs proportionally to their actual consumption.
  226. //
  227. // Numbers:
  228. //
  229. // GPU $6 at 50% → $3.00
  230. // CPU $4 at 25% (1 core used / 4 requested) → $1.00
  231. // RAM $2 at 10% (10 GB used / 100 GB requested) → $0.20
  232. // Total = $4.20
  233. func TestCollector_UsageCost_ScalesResourcesByUtilisation(t *testing.T) {
  234. cfg := baseConfig()
  235. c := &Collector{config: cfg}
  236. now := time.Now()
  237. alloc := makeAllocationWithUtilisation(
  238. "llama-3",
  239. 6.0, // gpuCost
  240. 4.0, // cpuCost
  241. 2.0, // ramCost
  242. gpuUsage(0.5), // gpuUsageAverage: 50%
  243. 4.0, 1.0, // cpuCoreRequest=4, cpuCoreUsage=1 → 25%
  244. 100.0, 10.0, // ramBytesRequest=100, ramBytesUsage=10 → 10%
  245. "llm-prod",
  246. )
  247. as := opencost.NewAllocationSet(now.Add(-5*time.Minute), now)
  248. as.Set(alloc)
  249. results, err := c.extractAllocationResults(as, false)
  250. if err != nil {
  251. t.Fatalf("extractAllocationResults failed: %v", err)
  252. }
  253. key := modelNamespaceKey("llama-3", "llm-prod")
  254. r, ok := results[key]
  255. if !ok {
  256. t.Fatal("expected result for llama-3/llm-prod")
  257. }
  258. // GPU: $6 × 0.50 = $3.00
  259. // CPU: $4 × (1/4) = $1.00
  260. // RAM: $2 × (10/100) = $0.20
  261. // Total: $4.20
  262. want := 4.20
  263. if !floatEq(r.usageTotalCost, want) {
  264. t.Errorf("usageTotalCost want %.2f got %.4f", want, r.usageTotalCost)
  265. }
  266. }
  267. // TestCollector_UsageCost_NoScalingWhenUtilisationMetricsAbsent verifies that
  268. // when utilisation averages are zero (metrics not available), extractAllocationResults
  269. // leaves usageTotalCost at the full TotalCost() — the safe fallback.
  270. func TestCollector_UsageCost_NoScalingWhenUtilisationMetricsAbsent(t *testing.T) {
  271. cfg := baseConfig()
  272. c := &Collector{config: cfg}
  273. now := time.Now()
  274. // No utilisation fields set — CPUCoreUsageAverage and RAMBytesUsageAverage
  275. // default to 0, so gating conditions are not met and no scaling fires.
  276. alloc := &opencost.Allocation{
  277. Name: "llama-3",
  278. GPUCost: 6.0,
  279. CPUCost: 4.0,
  280. RAMCost: 2.0,
  281. Properties: &opencost.AllocationProperties{
  282. Namespace: "llm-prod",
  283. Labels: opencost.AllocationLabels(map[string]string{"llm-d.ai/model": "llama-3"}),
  284. },
  285. }
  286. as := opencost.NewAllocationSet(now.Add(-5*time.Minute), now)
  287. as.Set(alloc)
  288. results, err := c.extractAllocationResults(as, false)
  289. if err != nil {
  290. t.Fatalf("extractAllocationResults failed: %v", err)
  291. }
  292. key := modelNamespaceKey("llama-3", "llm-prod")
  293. r, ok := results[key]
  294. if !ok {
  295. t.Fatal("expected result for llama-3/llm-prod")
  296. }
  297. // No utilisation metrics → full TotalCost() = $12.00 unchanged.
  298. want := 12.0
  299. if !floatEq(r.usageTotalCost, want) {
  300. t.Errorf("usageTotalCost want %.2f got %.4f (expected no scaling)", want, r.usageTotalCost)
  301. }
  302. }
  303. // TestCollector_UsageCost_ZeroGPUUsage verifies that GPUUsageAverage==0 (GPU
  304. // completely idle) scales the GPU cost to $0, not left at full reservation.
  305. // This is the primary regression test for the original exclusive `> 0` guard.
  306. func TestCollector_UsageCost_ZeroGPUUsage(t *testing.T) {
  307. cfg := baseConfig()
  308. c := &Collector{config: cfg}
  309. now := time.Now()
  310. alloc := makeAllocationWithUtilisation(
  311. "llama-3",
  312. 6.0, // gpuCost
  313. 4.0, // cpuCost
  314. 2.0, // ramCost
  315. gpuUsage(0.0), // GPUUsageAverage = 0: completely idle GPU
  316. 0, 0, // no CPU scaling
  317. 0, 0, // no RAM scaling
  318. "llm-prod",
  319. )
  320. as := opencost.NewAllocationSet(now.Add(-5*time.Minute), now)
  321. as.Set(alloc)
  322. results, err := c.extractAllocationResults(as, false)
  323. if err != nil {
  324. t.Fatalf("extractAllocationResults failed: %v", err)
  325. }
  326. key := modelNamespaceKey("llama-3", "llm-prod")
  327. r, ok := results[key]
  328. if !ok {
  329. t.Fatal("expected result for llama-3/llm-prod")
  330. }
  331. // GPU $6 × 0.0 = $0; CPU $4 + RAM $2 = $6 total (no CPU/RAM scaling).
  332. want := 6.0
  333. if !floatEq(r.usageTotalCost, want) {
  334. t.Errorf("usageTotalCost want %.2f got %.4f (zero GPU usage should zero GPU cost)", want, r.usageTotalCost)
  335. }
  336. }
  337. // TestCollector_UsageCost_OutOfRangeGPUUsageClamped verifies that
  338. // GPUUsageAverage values outside [0,1] are clamped before scaling, so they
  339. // never produce nonsensical (negative or inflated) costs.
  340. func TestCollector_UsageCost_OutOfRangeGPUUsageClamped(t *testing.T) {
  341. cfg := baseConfig()
  342. c := &Collector{config: cfg}
  343. now := time.Now()
  344. tests := []struct {
  345. name string
  346. gpuUsageAvg float64
  347. wantUsageCost float64 // GPU $6 clamped + CPU $4 + RAM $2 (no CPU/RAM scaling)
  348. }{
  349. {"above_one", 1.5, 12.0}, // clamped to 1.0 → $6 GPU + $4 CPU + $2 RAM
  350. {"negative", -0.5, 6.0}, // clamped to 0.0 → $0 GPU + $4 CPU + $2 RAM
  351. }
  352. for _, tc := range tests {
  353. t.Run(tc.name, func(t *testing.T) {
  354. alloc := makeAllocationWithUtilisation(
  355. "llama-3",
  356. 6.0, 4.0, 2.0,
  357. gpuUsage(tc.gpuUsageAvg),
  358. 0, 0,
  359. 0, 0,
  360. "llm-prod",
  361. )
  362. as := opencost.NewAllocationSet(now.Add(-5*time.Minute), now)
  363. as.Set(alloc)
  364. results, err := c.extractAllocationResults(as, false)
  365. if err != nil {
  366. t.Fatalf("extractAllocationResults failed: %v", err)
  367. }
  368. key := modelNamespaceKey("llama-3", "llm-prod")
  369. r, ok := results[key]
  370. if !ok {
  371. t.Fatal("expected result for llama-3/llm-prod")
  372. }
  373. if !floatEq(r.usageTotalCost, tc.wantUsageCost) {
  374. t.Errorf("usageTotalCost want %.2f got %.4f (gpuUsageAvg=%.2f should be clamped)",
  375. tc.wantUsageCost, r.usageTotalCost, tc.gpuUsageAvg)
  376. }
  377. })
  378. }
  379. }
  380. // TestCollector_UsageCost_GPUOnlyScaling verifies that GPU cost is scaled by
  381. // GPUUsageAverage while CPU and RAM costs are left at their full reservation
  382. // when no CPU/RAM utilisation metrics are available.
  383. //
  384. // Numbers:
  385. //
  386. // GPU $6 × 0.75 = $4.50
  387. // CPU $4 (no scaling — CPUCoreUsageAverage == 0)
  388. // RAM $2 (no scaling — RAMBytesUsageAverage == 0)
  389. // Total = $10.50
  390. func TestCollector_UsageCost_GPUOnlyScaling(t *testing.T) {
  391. cfg := baseConfig()
  392. c := &Collector{config: cfg}
  393. now := time.Now()
  394. alloc := makeAllocationWithUtilisation(
  395. "llama-3",
  396. 6.0, 4.0, 2.0,
  397. gpuUsage(0.75), // GPU 75%
  398. 0, 0, // no CPU utilisation metrics
  399. 0, 0, // no RAM utilisation metrics
  400. "llm-prod",
  401. )
  402. as := opencost.NewAllocationSet(now.Add(-5*time.Minute), now)
  403. as.Set(alloc)
  404. results, err := c.extractAllocationResults(as, false)
  405. if err != nil {
  406. t.Fatalf("extractAllocationResults failed: %v", err)
  407. }
  408. key := modelNamespaceKey("llama-3", "llm-prod")
  409. r, ok := results[key]
  410. if !ok {
  411. t.Fatal("expected result for llama-3/llm-prod")
  412. }
  413. // GPU $6 × 0.75 = $4.50; CPU $4 + RAM $2 unchanged → $10.50
  414. want := 10.50
  415. if !floatEq(r.usageTotalCost, want) {
  416. t.Errorf("usageTotalCost want %.2f got %.4f", want, r.usageTotalCost)
  417. }
  418. }
  419. // TestCollector_UsageCost_CPUOnlyScaling verifies that CPU cost is scaled by
  420. // the core utilisation ratio while GPU and RAM costs are left at their full
  421. // reservation when those metrics are absent.
  422. //
  423. // Numbers:
  424. //
  425. // GPU $6 (no GPUAllocation → no scaling)
  426. // CPU $4 × (2/8) = $1.00
  427. // RAM $2 (no scaling — RAMBytesUsageAverage == 0)
  428. // Total = $9.00
  429. func TestCollector_UsageCost_CPUOnlyScaling(t *testing.T) {
  430. cfg := baseConfig()
  431. c := &Collector{config: cfg}
  432. now := time.Now()
  433. alloc := makeAllocationWithUtilisation(
  434. "llama-3",
  435. 6.0, 4.0, 2.0,
  436. nil, // no GPUAllocation
  437. 8.0, 2.0, // cpuRequest=8, cpuUsage=2 → 25%
  438. 0, 0, // no RAM utilisation metrics
  439. "llm-prod",
  440. )
  441. as := opencost.NewAllocationSet(now.Add(-5*time.Minute), now)
  442. as.Set(alloc)
  443. results, err := c.extractAllocationResults(as, false)
  444. if err != nil {
  445. t.Fatalf("extractAllocationResults failed: %v", err)
  446. }
  447. key := modelNamespaceKey("llama-3", "llm-prod")
  448. r, ok := results[key]
  449. if !ok {
  450. t.Fatal("expected result for llama-3/llm-prod")
  451. }
  452. // GPU $6 unchanged + CPU $4 × (2/8) = $1.00 + RAM $2 unchanged = $9.00
  453. want := 9.00
  454. if !floatEq(r.usageTotalCost, want) {
  455. t.Errorf("usageTotalCost want %.2f got %.4f", want, r.usageTotalCost)
  456. }
  457. }
  458. // TestCollector_UsageCost_RAMOnlyScaling verifies that RAM cost is scaled by
  459. // the byte utilisation ratio while GPU and CPU costs are left at their full
  460. // reservation when those metrics are absent.
  461. //
  462. // Numbers:
  463. //
  464. // GPU $6 (no GPUAllocation → no scaling)
  465. // CPU $4 (no scaling — CPUCoreUsageAverage == 0)
  466. // RAM $2 × (20/200) = $0.20
  467. // Total = $10.20
  468. func TestCollector_UsageCost_RAMOnlyScaling(t *testing.T) {
  469. cfg := baseConfig()
  470. c := &Collector{config: cfg}
  471. now := time.Now()
  472. alloc := makeAllocationWithUtilisation(
  473. "llama-3",
  474. 6.0, 4.0, 2.0,
  475. nil, // no GPUAllocation
  476. 0, 0, // no CPU utilisation metrics
  477. 200.0, 20.0, // ramRequest=200, ramUsage=20 → 10%
  478. "llm-prod",
  479. )
  480. as := opencost.NewAllocationSet(now.Add(-5*time.Minute), now)
  481. as.Set(alloc)
  482. results, err := c.extractAllocationResults(as, false)
  483. if err != nil {
  484. t.Fatalf("extractAllocationResults failed: %v", err)
  485. }
  486. key := modelNamespaceKey("llama-3", "llm-prod")
  487. r, ok := results[key]
  488. if !ok {
  489. t.Fatal("expected result for llama-3/llm-prod")
  490. }
  491. // GPU $6 + CPU $4 unchanged + RAM $2 × (20/200) = $0.20 → $10.20
  492. want := 10.20
  493. if !floatEq(r.usageTotalCost, want) {
  494. t.Errorf("usageTotalCost want %.2f got %.4f", want, r.usageTotalCost)
  495. }
  496. }
  497. // TestCollector_UsageCost_CPUUsageEqualsRequest verifies that when CPU usage
  498. // exactly equals the request (utilisation == 100%), the guard condition
  499. // (usage < request) prevents scaling and the full CPU cost is retained.
  500. // This also confirms no double-counting from the subtraction/addition path.
  501. func TestCollector_UsageCost_CPUUsageEqualsRequest(t *testing.T) {
  502. cfg := baseConfig()
  503. c := &Collector{config: cfg}
  504. now := time.Now()
  505. alloc := makeAllocationWithUtilisation(
  506. "llama-3",
  507. 0, 4.0, 0,
  508. nil, // no GPU
  509. 4.0, 4.0, // usage == request → guard (usage < request) is false → no scaling
  510. 0, 0,
  511. "llm-prod",
  512. )
  513. as := opencost.NewAllocationSet(now.Add(-5*time.Minute), now)
  514. as.Set(alloc)
  515. results, err := c.extractAllocationResults(as, false)
  516. if err != nil {
  517. t.Fatalf("extractAllocationResults failed: %v", err)
  518. }
  519. key := modelNamespaceKey("llama-3", "llm-prod")
  520. r, ok := results[key]
  521. if !ok {
  522. t.Fatal("expected result for llama-3/llm-prod")
  523. }
  524. // usage == request → no scaling → full $4.00 retained
  525. want := 4.0
  526. if !floatEq(r.usageTotalCost, want) {
  527. t.Errorf("usageTotalCost want %.2f got %.4f (CPU at 100%% should not be scaled)", want, r.usageTotalCost)
  528. }
  529. }
  530. // TestCollector_UsageCost_RAMUsageEqualsRequest verifies the same guard for RAM:
  531. // when RAM usage exactly equals the request, no scaling fires and full cost is kept.
  532. func TestCollector_UsageCost_RAMUsageEqualsRequest(t *testing.T) {
  533. cfg := baseConfig()
  534. c := &Collector{config: cfg}
  535. now := time.Now()
  536. alloc := makeAllocationWithUtilisation(
  537. "llama-3",
  538. 0, 0, 2.0,
  539. nil,
  540. 0, 0,
  541. 100.0, 100.0, // usage == request → no scaling
  542. "llm-prod",
  543. )
  544. as := opencost.NewAllocationSet(now.Add(-5*time.Minute), now)
  545. as.Set(alloc)
  546. results, err := c.extractAllocationResults(as, false)
  547. if err != nil {
  548. t.Fatalf("extractAllocationResults failed: %v", err)
  549. }
  550. key := modelNamespaceKey("llama-3", "llm-prod")
  551. r, ok := results[key]
  552. if !ok {
  553. t.Fatal("expected result for llama-3/llm-prod")
  554. }
  555. // usage == request → full $2.00 retained
  556. want := 2.0
  557. if !floatEq(r.usageTotalCost, want) {
  558. t.Errorf("usageTotalCost want %.2f got %.4f (RAM at 100%% should not be scaled)", want, r.usageTotalCost)
  559. }
  560. }
  561. // TestCollector_UsageCost_CPUOvercommit verifies that when CPU usage exceeds
  562. // the request (overcommit), the guard (usage < request) prevents scaling and
  563. // the full CPU cost is retained — overcommit situations should not produce
  564. // sub-reservation costs.
  565. func TestCollector_UsageCost_CPUOvercommit(t *testing.T) {
  566. cfg := baseConfig()
  567. c := &Collector{config: cfg}
  568. now := time.Now()
  569. alloc := makeAllocationWithUtilisation(
  570. "llama-3",
  571. 0, 4.0, 0,
  572. nil,
  573. 2.0, 3.0, // usage (3.0) > request (2.0) → overcommit, no scaling
  574. 0, 0,
  575. "llm-prod",
  576. )
  577. as := opencost.NewAllocationSet(now.Add(-5*time.Minute), now)
  578. as.Set(alloc)
  579. results, err := c.extractAllocationResults(as, false)
  580. if err != nil {
  581. t.Fatalf("extractAllocationResults failed: %v", err)
  582. }
  583. key := modelNamespaceKey("llama-3", "llm-prod")
  584. r, ok := results[key]
  585. if !ok {
  586. t.Fatal("expected result for llama-3/llm-prod")
  587. }
  588. // overcommit → no scaling → full $4.00 retained
  589. want := 4.0
  590. if !floatEq(r.usageTotalCost, want) {
  591. t.Errorf("usageTotalCost want %.2f got %.4f (CPU overcommit should not reduce cost)", want, r.usageTotalCost)
  592. }
  593. }
  594. // TestCollector_UsageCost_RAMOvercommit verifies the same overcommit guard for RAM.
  595. func TestCollector_UsageCost_RAMOvercommit(t *testing.T) {
  596. cfg := baseConfig()
  597. c := &Collector{config: cfg}
  598. now := time.Now()
  599. alloc := makeAllocationWithUtilisation(
  600. "llama-3",
  601. 0, 0, 2.0,
  602. nil,
  603. 0, 0,
  604. 50.0, 80.0, // usage (80) > request (50) → overcommit, no scaling
  605. "llm-prod",
  606. )
  607. as := opencost.NewAllocationSet(now.Add(-5*time.Minute), now)
  608. as.Set(alloc)
  609. results, err := c.extractAllocationResults(as, false)
  610. if err != nil {
  611. t.Fatalf("extractAllocationResults failed: %v", err)
  612. }
  613. key := modelNamespaceKey("llama-3", "llm-prod")
  614. r, ok := results[key]
  615. if !ok {
  616. t.Fatal("expected result for llama-3/llm-prod")
  617. }
  618. // overcommit → no scaling → full $2.00 retained
  619. want := 2.0
  620. if !floatEq(r.usageTotalCost, want) {
  621. t.Errorf("usageTotalCost want %.2f got %.4f (RAM overcommit should not reduce cost)", want, r.usageTotalCost)
  622. }
  623. }
  624. // TestCollector_UsageCost_NilGPUAllocationStruct verifies that an allocation
  625. // with a nil GPUAllocation pointer (as opposed to a non-nil struct with a nil
  626. // GPUUsageAverage pointer) is handled safely — no GPU scaling, no panic.
  627. func TestCollector_UsageCost_NilGPUAllocationStruct(t *testing.T) {
  628. cfg := baseConfig()
  629. c := &Collector{config: cfg}
  630. now := time.Now()
  631. // makeAllocationWithUtilisation passes nil for gpuUsageAverage → GPUAllocation stays nil
  632. alloc := makeAllocationWithUtilisation(
  633. "llama-3",
  634. 6.0, 4.0, 2.0,
  635. nil, // GPUAllocation == nil
  636. 0, 0,
  637. 0, 0,
  638. "llm-prod",
  639. )
  640. as := opencost.NewAllocationSet(now.Add(-5*time.Minute), now)
  641. as.Set(alloc)
  642. results, err := c.extractAllocationResults(as, false)
  643. if err != nil {
  644. t.Fatalf("extractAllocationResults failed: %v", err)
  645. }
  646. key := modelNamespaceKey("llama-3", "llm-prod")
  647. r, ok := results[key]
  648. if !ok {
  649. t.Fatal("expected result for llama-3/llm-prod")
  650. }
  651. // No GPUAllocation → no GPU scaling; no CPU/RAM metrics → no CPU/RAM scaling.
  652. // Full TotalCost() = $12.00 retained.
  653. want := 12.0
  654. if !floatEq(r.usageTotalCost, want) {
  655. t.Errorf("usageTotalCost want %.2f got %.4f (nil GPUAllocation should not panic or scale)", want, r.usageTotalCost)
  656. }
  657. }
  658. // TestCollector_UsageCost_TwoModelsIndependent verifies that two different
  659. // models in the same namespace produce two independent result entries, each
  660. // scaled only by its own utilisation metrics with no cross-contamination.
  661. func TestCollector_UsageCost_TwoModelsIndependent(t *testing.T) {
  662. cfg := baseConfig()
  663. c := &Collector{config: cfg}
  664. now := time.Now()
  665. // Model A: GPU $6 × 0.5 = $3.00; CPU/RAM no metrics → $3 + $4 + $2 = $9.00
  666. modelA := makeAllocationWithUtilisation(
  667. "llama-3",
  668. 6.0, 4.0, 2.0,
  669. gpuUsage(0.5),
  670. 0, 0,
  671. 0, 0,
  672. "llm-prod",
  673. )
  674. // Model B: GPU $3 × 0.2 = $0.60; CPU/RAM no metrics → $0.60 + $2 + $1 = $3.60
  675. modelB := makeAllocationWithUtilisation(
  676. "mistral-7b",
  677. 3.0, 2.0, 1.0,
  678. gpuUsage(0.2),
  679. 0, 0,
  680. 0, 0,
  681. "llm-prod",
  682. )
  683. as := opencost.NewAllocationSet(now.Add(-5*time.Minute), now)
  684. as.Set(modelA)
  685. as.Set(modelB)
  686. results, err := c.extractAllocationResults(as, false)
  687. if err != nil {
  688. t.Fatalf("extractAllocationResults failed: %v", err)
  689. }
  690. if len(results) != 2 {
  691. t.Fatalf("expected 2 independent result entries, got %d", len(results))
  692. }
  693. // llama-3: GPU $6 × 0.5 = $3 + CPU $4 + RAM $2 = $9.00
  694. keyA := modelNamespaceKey("llama-3", "llm-prod")
  695. rA, ok := results[keyA]
  696. if !ok {
  697. t.Fatal("expected result for llama-3/llm-prod")
  698. }
  699. if !floatEq(rA.usageTotalCost, 9.00) {
  700. t.Errorf("llama-3 usageTotalCost want 9.00 got %.4f", rA.usageTotalCost)
  701. }
  702. // mistral-7b: GPU $3 × 0.2 = $0.60 + CPU $2 + RAM $1 = $3.60
  703. keyB := modelNamespaceKey("mistral-7b", "llm-prod")
  704. rB, ok := results[keyB]
  705. if !ok {
  706. t.Fatal("expected result for mistral-7b/llm-prod")
  707. }
  708. if !floatEq(rB.usageTotalCost, 3.60) {
  709. t.Errorf("mistral-7b usageTotalCost want 3.60 got %.4f", rB.usageTotalCost)
  710. }
  711. }
  712. // TestCollector_CombineMetrics_DerivesCachedTokens verifies that combineMetrics
  713. // passes CachedTokens through directly and derives EffectiveInputTokens correctly.
  714. func TestCollector_CombineMetrics_DerivesCachedTokens(t *testing.T) {
  715. cfg := baseConfig()
  716. allocCosts := map[string]*allocationResult{
  717. "llama-3:llm-prod": {allocationTotalCost: 4.0, usageTotalCost: 2.6, namespace: "llm-prod"},
  718. }
  719. promptTokens := map[string]float64{"llama-3:llm-prod": 20}
  720. genTokens := map[string]float64{"llama-3:llm-prod": 10}
  721. inputTime := map[string]float64{}
  722. outputTime := map[string]float64{}
  723. // vllm:prefix_cache_hits_total reports tokens directly (not blocks).
  724. cachedTokens := map[string]float64{"llama-3:llm-prod": 8}
  725. cacheConfigs := map[string]*cacheConfig{"llama-3:llm-prod": {prefixCachingEnabled: true}}
  726. c := &Collector{config: cfg}
  727. now := time.Now()
  728. results := c.combineMetrics(allocCosts, promptTokens, genTokens, inputTime, outputTime, cachedTokens, cacheConfigs, now.Add(-1*time.Hour), now)
  729. if len(results) != 1 {
  730. t.Fatalf("expected 1 result, got %d", len(results))
  731. }
  732. m := results[0]
  733. if !floatEq(m.CachedTokens, 8) {
  734. t.Errorf("CachedTokens want 8 got %f", m.CachedTokens)
  735. }
  736. if !floatEq(m.EffectiveInputTokens, 12) {
  737. t.Errorf("EffectiveInputTokens want 12 got %f", m.EffectiveInputTokens)
  738. }
  739. }
  740. // TestCollector_CombineMetrics_NoCacheHits_FallsBackToPromptTokens verifies that
  741. // EffectiveInputTokens equals PromptTokens when no cache hits are reported.
  742. func TestCollector_CombineMetrics_NoCacheHits_FallsBackToPromptTokens(t *testing.T) {
  743. cfg := baseConfig()
  744. allocCosts := map[string]*allocationResult{
  745. "llama-3:llm-prod": {allocationTotalCost: 1.0, usageTotalCost: 1.0, namespace: "llm-prod"},
  746. }
  747. promptTokens := map[string]float64{"llama-3:llm-prod": 1000}
  748. genTokens := map[string]float64{"llama-3:llm-prod": 500}
  749. // cachedTokens map is empty — simulates metric being unavailable
  750. cacheHits := map[string]float64{}
  751. cacheConfigs := map[string]*cacheConfig{"llama-3:llm-prod": {prefixCachingEnabled: true}}
  752. c := &Collector{config: cfg}
  753. now := time.Now()
  754. results := c.combineMetrics(allocCosts, promptTokens, genTokens,
  755. map[string]float64{}, map[string]float64{}, cacheHits, cacheConfigs, now.Add(-1*time.Hour), now)
  756. if len(results) != 1 {
  757. t.Fatalf("expected 1 result, got %d", len(results))
  758. }
  759. m := results[0]
  760. if !floatEq(m.EffectiveInputTokens, 1000) {
  761. t.Errorf("EffectiveInputTokens should fall back to PromptTokens=1000, got %f", m.EffectiveInputTokens)
  762. }
  763. }
  764. // TestReconcileTokenKeys_OrgPrefixMismatch verifies that a metric key with a
  765. // fully-qualified org/model name is re-keyed to match the allocation key that
  766. // uses only the short name, and that keys which already match are left unchanged.
  767. func TestReconcileTokenKeys_OrgPrefixMismatch(t *testing.T) {
  768. allocCosts := map[string]*allocationResult{
  769. "MiniMax-M2.7:llm-d-pic": {allocationTotalCost: 489.0, namespace: "llm-d-pic"},
  770. "gpt-oss-120b:dolev-inf": {allocationTotalCost: 453.0, namespace: "dolev-inf"},
  771. // This alloc key already has a slash and no short-name alternative.
  772. "meta-llama/Llama-3:prod": {allocationTotalCost: 10.0, namespace: "prod"},
  773. }
  774. tokens := map[string]float64{
  775. // Mismatch: vLLM uses full org/model, alloc uses short name.
  776. "MiniMaxAI/MiniMax-M2.7:llm-d-pic": 4316.0,
  777. "openai/gpt-oss-120b:dolev-inf": 4773.0,
  778. // Already matches alloc key — should pass through unchanged.
  779. "meta-llama/Llama-3:prod": 1000.0,
  780. // No alloc entry at all — should pass through unchanged.
  781. "unknown-org/new-model:some-ns": 99.0,
  782. }
  783. out, remappedKeys := reconcileTokenKeys(tokens, allocCosts)
  784. // Remapped entries should appear under the short-name alloc keys.
  785. if v, ok := out["MiniMax-M2.7:llm-d-pic"]; !ok || !floatEq(v, 4316.0) {
  786. t.Errorf("MiniMax-M2.7:llm-d-pic want 4316.0 got %v (ok=%v)", v, ok)
  787. }
  788. if v, ok := out["gpt-oss-120b:dolev-inf"]; !ok || !floatEq(v, 4773.0) {
  789. t.Errorf("gpt-oss-120b:dolev-inf want 4773.0 got %v (ok=%v)", v, ok)
  790. }
  791. // Original org-prefixed keys must be gone.
  792. if _, ok := out["MiniMaxAI/MiniMax-M2.7:llm-d-pic"]; ok {
  793. t.Error("org-prefixed key MiniMaxAI/MiniMax-M2.7:llm-d-pic should have been removed")
  794. }
  795. if _, ok := out["openai/gpt-oss-120b:dolev-inf"]; ok {
  796. t.Error("org-prefixed key openai/gpt-oss-120b:dolev-inf should have been removed")
  797. }
  798. // Verify remapped keys are tracked.
  799. if _, ok := remappedKeys["MiniMaxAI/MiniMax-M2.7:llm-d-pic"]; !ok {
  800. t.Error("MiniMaxAI/MiniMax-M2.7:llm-d-pic should be in remappedKeys")
  801. }
  802. if _, ok := remappedKeys["openai/gpt-oss-120b:dolev-inf"]; !ok {
  803. t.Error("openai/gpt-oss-120b:dolev-inf should be in remappedKeys")
  804. }
  805. // Keys that already matched or had no alloc entry pass through unchanged.
  806. if v, ok := out["meta-llama/Llama-3:prod"]; !ok || !floatEq(v, 1000.0) {
  807. t.Errorf("meta-llama/Llama-3:prod want 1000.0 got %v (ok=%v)", v, ok)
  808. }
  809. if v, ok := out["unknown-org/new-model:some-ns"]; !ok || !floatEq(v, 99.0) {
  810. t.Errorf("unknown-org/new-model:some-ns want 99.0 got %v (ok=%v)", v, ok)
  811. }
  812. }
  813. func TestReconcileTokenKeys_PrefersShortAllocationKeyWhenBothFormsExist(t *testing.T) {
  814. allocCosts := map[string]*allocationResult{
  815. "gemma-4-31B:llm-d-pic": {allocationTotalCost: 10.0, namespace: "llm-d-pic"},
  816. "google/gemma-4-31B:llm-d-pic": {allocationTotalCost: 1.0, namespace: "llm-d-pic"},
  817. }
  818. tokens := map[string]float64{
  819. "google/gemma-4-31B:llm-d-pic": 123.0,
  820. }
  821. out, remappedKeys := reconcileTokenKeys(tokens, allocCosts)
  822. if v, ok := out["gemma-4-31B:llm-d-pic"]; !ok || !floatEq(v, 123.0) {
  823. t.Errorf("gemma-4-31B:llm-d-pic want 123.0 got %v (ok=%v)", v, ok)
  824. }
  825. if _, ok := out["google/gemma-4-31B:llm-d-pic"]; ok {
  826. t.Error("google/gemma-4-31B:llm-d-pic should have been folded into gemma-4-31B:llm-d-pic")
  827. }
  828. if _, ok := remappedKeys["google/gemma-4-31B:llm-d-pic"]; !ok {
  829. t.Error("google/gemma-4-31B:llm-d-pic should be in remappedKeys")
  830. }
  831. }
  832. // TestCollector_BuildQueryWindow verifies that buildQueryWindow generates
  833. // correct Prometheus time range selectors based on CollectionInterval.
  834. // TestQueryCounterDelta_Formula verifies the delta = end - start subtraction
  835. // and that negative deltas (counter resets) use endVal to capture post-reset activity.
  836. func TestQueryCounterDelta_Formula(t *testing.T) {
  837. tests := []struct {
  838. name string
  839. endVal float64
  840. startVal float64
  841. want float64
  842. }{
  843. {name: "normal increase", endVal: 1000, startVal: 200, want: 800},
  844. {name: "no activity", endVal: 500, startVal: 500, want: 0},
  845. {name: "counter reset uses endVal", endVal: 100, startVal: 900, want: 100},
  846. {name: "new pod (no start sample)", endVal: 400, startVal: 0, want: 400},
  847. }
  848. for _, tt := range tests {
  849. t.Run(tt.name, func(t *testing.T) {
  850. delta := tt.endVal - tt.startVal
  851. if delta < 0 {
  852. delta = tt.endVal
  853. }
  854. if delta != tt.want {
  855. t.Errorf("delta = %v, want %v", delta, tt.want)
  856. }
  857. })
  858. }
  859. }
  860. // TestReconcileTokenKeys_NoMismatch verifies that when all token keys directly
  861. // match allocation keys, no re-keying occurs and no entries are dropped.
  862. func TestReconcileTokenKeys_NoMismatch(t *testing.T) {
  863. allocCosts := map[string]*allocationResult{
  864. "llama-3:prod": {allocationTotalCost: 1.0},
  865. }
  866. tokens := map[string]float64{
  867. "llama-3:prod": 500.0,
  868. }
  869. out, remappedKeys := reconcileTokenKeys(tokens, allocCosts)
  870. if v, ok := out["llama-3:prod"]; !ok || !floatEq(v, 500.0) {
  871. t.Errorf("want llama-3:prod=500.0 got %v (ok=%v)", v, ok)
  872. }
  873. if len(out) != 1 {
  874. t.Errorf("expected 1 entry, got %d", len(out))
  875. }
  876. if len(remappedKeys) != 0 {
  877. t.Errorf("expected no remapped keys, got %d", len(remappedKeys))
  878. }
  879. }
  880. // TestCollector_CollectMetrics_EmptyMetrics ensures that CollectMetrics
  881. // handles empty metrics gracefully (returns empty results, not an error).
  882. func TestCollector_CollectMetrics_EmptyMetrics(t *testing.T) {
  883. cfg := baseConfig()
  884. now := time.Now()
  885. querier := &mockQuerier{set: opencost.NewAllocationSet(now.Add(-5*time.Minute), now)}
  886. // Use the standard mock - it will return empty results by default
  887. metricsQuerier := source.NewMockMetricsQuerier()
  888. collector, err := NewCollector(cfg, querier, metricsQuerier)
  889. if err != nil {
  890. t.Fatalf("NewCollector returned unexpected error: %v", err)
  891. }
  892. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
  893. defer cancel()
  894. end := time.Now()
  895. start := end.Add(-5 * time.Minute)
  896. results, err := collector.CollectMetrics(ctx, start, end)
  897. // With empty metrics, CollectMetrics should succeed with empty results
  898. if err != nil {
  899. t.Errorf("unexpected error with empty metrics: %v", err)
  900. }
  901. if len(results) != 0 {
  902. t.Errorf("expected 0 results with empty metrics, got %d", len(results))
  903. }
  904. }
  905. func TestCollector_CombineMetrics_IncludesTimingOnlyKeysInUnion(t *testing.T) {
  906. cfg := baseConfig()
  907. c := &Collector{config: cfg}
  908. allocCosts := map[string]*allocationResult{}
  909. promptTokens := map[string]float64{}
  910. genTokens := map[string]float64{}
  911. inputTime := map[string]float64{"timing-only:ns1": 60}
  912. outputTime := map[string]float64{"timing-only:ns1": 40}
  913. cacheHits := map[string]float64{"timing-only:ns1": 2}
  914. cacheConfigs := map[string]*cacheConfig{}
  915. now := time.Now()
  916. results := c.combineMetrics(allocCosts, promptTokens, genTokens, inputTime, outputTime, cacheHits, cacheConfigs, now.Add(-1*time.Hour), now)
  917. if len(results) != 1 {
  918. t.Fatalf("expected 1 result, got %d", len(results))
  919. }
  920. m := results[0]
  921. if m.Properties.ModelName != "timing-only" || m.Properties.Namespace != "ns1" {
  922. t.Fatalf("unexpected properties: model=%s namespace=%s", m.Properties.ModelName, m.Properties.Namespace)
  923. }
  924. if !floatEq(m.InputProcessingTime, 60) {
  925. t.Errorf("InputProcessingTime want 60 got %f", m.InputProcessingTime)
  926. }
  927. if !floatEq(m.OutputProcessingTime, 40) {
  928. t.Errorf("OutputProcessingTime want 40 got %f", m.OutputProcessingTime)
  929. }
  930. if !floatEq(m.CachedTokens, 2) {
  931. t.Errorf("CachedTokens want 2 got %f", m.CachedTokens)
  932. }
  933. }