aggregate_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519
  1. package inferencecost
  2. import (
  3. "testing"
  4. "time"
  5. "github.com/opencost/opencost/core/pkg/opencost"
  6. )
  7. // makeWindow is a test helper.
  8. func makeWindow(start, end time.Time) opencost.Window {
  9. return opencost.NewClosedWindow(start, end)
  10. }
  11. // makeICR builds a minimal InferenceCostResponse for aggregation tests.
  12. func makeICR(modelName, ns string, totalCost, promptTokens, genTokens, inputCost, outputCost float64) *InferenceCostResponse {
  13. totalTokens := promptTokens + genTokens
  14. var cpmt, icpmt, ocpmt float64
  15. if totalTokens > 0 {
  16. cpmt = totalCost / totalTokens * 1_000_000
  17. }
  18. if promptTokens > 0 {
  19. icpmt = inputCost / promptTokens * 1_000_000
  20. }
  21. if genTokens > 0 {
  22. ocpmt = outputCost / genTokens * 1_000_000
  23. }
  24. return &InferenceCostResponse{
  25. Properties: InferenceCostAPIProperties{
  26. ModelName: modelName,
  27. Namespace: ns,
  28. },
  29. CostBasis: CostBasisAllocation,
  30. TotalCost: totalCost,
  31. PromptTokens: promptTokens,
  32. GenerationTokens: genTokens,
  33. TotalTokens: totalTokens,
  34. CostPerMillionTokens: cpmt,
  35. InputCost: inputCost,
  36. OutputCost: outputCost,
  37. InputCostPerMillionTokens: icpmt,
  38. OutputCostPerMillionTokens: ocpmt,
  39. }
  40. }
  41. // --- aggKey ---
  42. func TestAggKey_NoAggregation(t *testing.T) {
  43. props := InferenceCostAPIProperties{ModelName: "llama", Namespace: "prod"}
  44. key, err := aggKey(props, nil)
  45. if err != nil {
  46. t.Fatalf("unexpected error: %v", err)
  47. }
  48. if key != "llama:prod" {
  49. t.Errorf("key = %q, want %q", key, "llama:prod")
  50. }
  51. }
  52. func TestAggKey_NoAggregation_SlashInModelName(t *testing.T) {
  53. // Model names like "org/model" must not produce an ambiguous key.
  54. // ":" is the separator because it cannot appear in K8s label values or namespaces.
  55. props := InferenceCostAPIProperties{ModelName: "Qwen/Qwen3-VL-2B-Instruct", Namespace: "test-epd-ec"}
  56. key, err := aggKey(props, nil)
  57. if err != nil {
  58. t.Fatalf("unexpected error: %v", err)
  59. }
  60. if key != "Qwen/Qwen3-VL-2B-Instruct:test-epd-ec" {
  61. t.Errorf("key = %q, want %q", key, "Qwen/Qwen3-VL-2B-Instruct:test-epd-ec")
  62. }
  63. }
  64. func TestAggKey_ByModelName(t *testing.T) {
  65. props := InferenceCostAPIProperties{ModelName: "llama", Namespace: "prod"}
  66. key, err := aggKey(props, []string{"model_name"})
  67. if err != nil {
  68. t.Fatalf("unexpected error: %v", err)
  69. }
  70. if key != "llama" {
  71. t.Errorf("key = %q, want %q", key, "llama")
  72. }
  73. }
  74. func TestAggKey_MultiDim(t *testing.T) {
  75. props := InferenceCostAPIProperties{ModelName: "llama", Namespace: "prod"}
  76. key, err := aggKey(props, []string{"model_name", "namespace"})
  77. if err != nil {
  78. t.Fatalf("unexpected error: %v", err)
  79. }
  80. if key != "llama/prod" {
  81. t.Errorf("key = %q, want %q", key, "llama/prod")
  82. }
  83. }
  84. func TestAggKey_UnsupportedDimension(t *testing.T) {
  85. props := InferenceCostAPIProperties{ModelName: "llama", Namespace: "prod"}
  86. _, err := aggKey(props, []string{"product"})
  87. if err == nil {
  88. t.Fatal("expected error for unsupported dimension, got nil")
  89. }
  90. }
  91. func TestAggKey_EmptyValueFallsBackToUnallocated(t *testing.T) {
  92. props := InferenceCostAPIProperties{ModelName: "llama"} // Namespace empty
  93. key, err := aggKey(props, []string{"namespace"})
  94. if err != nil {
  95. t.Fatalf("unexpected error: %v", err)
  96. }
  97. if key != opencost.UnallocatedSuffix {
  98. t.Errorf("key = %q, want %q (unallocated suffix)", key, opencost.UnallocatedSuffix)
  99. }
  100. }
  101. func TestAggKey_ByPod(t *testing.T) {
  102. props := InferenceCostAPIProperties{
  103. ModelName: "llama",
  104. Namespace: "prod",
  105. Pod: "llama-pod-123",
  106. }
  107. key, err := aggKey(props, []string{"pod"})
  108. if err != nil {
  109. t.Fatalf("unexpected error: %v", err)
  110. }
  111. if key != "llama-pod-123" {
  112. t.Errorf("key = %q, want %q", key, "llama-pod-123")
  113. }
  114. }
  115. func TestAggKey_ByController(t *testing.T) {
  116. props := InferenceCostAPIProperties{
  117. ModelName: "llama",
  118. Namespace: "prod",
  119. Controller: "llama-deployment",
  120. }
  121. key, err := aggKey(props, []string{"controller"})
  122. if err != nil {
  123. t.Fatalf("unexpected error: %v", err)
  124. }
  125. if key != "llama-deployment" {
  126. t.Errorf("key = %q, want %q", key, "llama-deployment")
  127. }
  128. }
  129. func TestAggKey_ByControllerKind(t *testing.T) {
  130. props := InferenceCostAPIProperties{
  131. ModelName: "llama",
  132. Namespace: "prod",
  133. ControllerKind: "Deployment",
  134. }
  135. key, err := aggKey(props, []string{"controller_kind"})
  136. if err != nil {
  137. t.Fatalf("unexpected error: %v", err)
  138. }
  139. if key != "Deployment" {
  140. t.Errorf("key = %q, want %q", key, "Deployment")
  141. }
  142. }
  143. func TestAggKey_ByContainer(t *testing.T) {
  144. props := InferenceCostAPIProperties{
  145. ModelName: "llama",
  146. Namespace: "prod",
  147. Container: "vllm-container",
  148. }
  149. key, err := aggKey(props, []string{"container"})
  150. if err != nil {
  151. t.Fatalf("unexpected error: %v", err)
  152. }
  153. if key != "vllm-container" {
  154. t.Errorf("key = %q, want %q", key, "vllm-container")
  155. }
  156. }
  157. func TestAggKey_MultiDimWithNewFields(t *testing.T) {
  158. props := InferenceCostAPIProperties{
  159. ModelName: "llama",
  160. Namespace: "prod",
  161. Pod: "llama-pod-123",
  162. Controller: "llama-deployment",
  163. ControllerKind: "Deployment",
  164. Container: "vllm-container",
  165. }
  166. key, err := aggKey(props, []string{"namespace", "controller_kind", "container"})
  167. if err != nil {
  168. t.Fatalf("unexpected error: %v", err)
  169. }
  170. if key != "prod/Deployment/vllm-container" {
  171. t.Errorf("key = %q, want %q", key, "prod/Deployment/vllm-container")
  172. }
  173. }
  174. func TestAggKey_AllDimensions(t *testing.T) {
  175. props := InferenceCostAPIProperties{
  176. ModelName: "llama",
  177. ModelVersion: "v1.0",
  178. Namespace: "prod",
  179. Cluster: "cluster-1",
  180. Pod: "llama-pod-123",
  181. Controller: "llama-deployment",
  182. ControllerKind: "Deployment",
  183. Container: "vllm-container",
  184. }
  185. key, err := aggKey(props, []string{
  186. "model_name", "model_version", "namespace", "cluster",
  187. "pod", "controller", "controller_kind", "container",
  188. })
  189. if err != nil {
  190. t.Fatalf("unexpected error: %v", err)
  191. }
  192. expected := "llama/v1.0/prod/cluster-1/llama-pod-123/llama-deployment/Deployment/vllm-container"
  193. if key != expected {
  194. t.Errorf("key = %q, want %q", key, expected)
  195. }
  196. }
  197. // --- aggregate ---
  198. func TestAggregate_ByModelName_SumsTokensAndRecomputesRates(t *testing.T) {
  199. now := time.Now().UTC()
  200. win := makeWindow(now.Add(-time.Hour), now)
  201. set := newInferenceCostSet(win)
  202. // Two entries for the same model, different namespaces.
  203. set.InferenceCosts["llama/ns1"] = makeICR("llama", "ns1", 100, 1_000_000, 500_000, 70, 30)
  204. set.InferenceCosts["llama/ns2"] = makeICR("llama", "ns2", 200, 2_000_000, 1_000_000, 140, 60)
  205. if err := set.aggregate([]string{"model_name"}); err != nil {
  206. t.Fatalf("aggregate error: %v", err)
  207. }
  208. if len(set.InferenceCosts) != 1 {
  209. t.Fatalf("expected 1 entry after aggregation, got %d", len(set.InferenceCosts))
  210. }
  211. agg := set.InferenceCosts["llama"]
  212. if agg == nil {
  213. t.Fatal("expected key 'llama', not found")
  214. }
  215. if agg.TotalCost != 300 {
  216. t.Errorf("TotalCost = %.2f, want 300", agg.TotalCost)
  217. }
  218. if agg.PromptTokens != 3_000_000 {
  219. t.Errorf("PromptTokens = %.0f, want 3_000_000", agg.PromptTokens)
  220. }
  221. if agg.TotalTokens != 4_500_000 {
  222. t.Errorf("TotalTokens = %.0f, want 4_500_000", agg.TotalTokens)
  223. }
  224. // Rate must be recomputed from sums, not averaged.
  225. wantCPMT := 300.0 / 4_500_000 * 1_000_000
  226. if !floatEq(agg.CostPerMillionTokens, wantCPMT) {
  227. t.Errorf("CostPerMillionTokens = %.4f, want %.4f", agg.CostPerMillionTokens, wantCPMT)
  228. }
  229. }
  230. func TestAggregate_NoAggregation_DoesNothing(t *testing.T) {
  231. now := time.Now().UTC()
  232. win := makeWindow(now.Add(-time.Hour), now)
  233. set := newInferenceCostSet(win)
  234. set.InferenceCosts["llama/prod"] = makeICR("llama", "prod", 100, 1_000_000, 500_000, 70, 30)
  235. set.InferenceCosts["mistral/prod"] = makeICR("mistral", "prod", 50, 500_000, 250_000, 35, 15)
  236. if err := set.aggregate(nil); err != nil {
  237. t.Fatalf("aggregate error: %v", err)
  238. }
  239. if len(set.InferenceCosts) != 2 {
  240. t.Errorf("expected 2 entries after no-op aggregation, got %d", len(set.InferenceCosts))
  241. }
  242. }
  243. // --- accumulate ---
  244. func TestAccumulate_EmptySlice(t *testing.T) {
  245. out := accumulate(nil)
  246. if out == nil {
  247. t.Fatal("expected non-nil result for empty slice")
  248. }
  249. if len(out.InferenceCosts) != 0 {
  250. t.Errorf("expected 0 entries, got %d", len(out.InferenceCosts))
  251. }
  252. }
  253. func TestAccumulate_SumsAcrossWindows(t *testing.T) {
  254. now := time.Now().UTC()
  255. win1 := makeWindow(now.Add(-2*time.Hour), now.Add(-1*time.Hour))
  256. win2 := makeWindow(now.Add(-1*time.Hour), now)
  257. set1 := newInferenceCostSet(win1)
  258. set1.InferenceCosts["llama/prod"] = makeICR("llama", "prod", 100, 1_000_000, 500_000, 70, 30)
  259. set2 := newInferenceCostSet(win2)
  260. set2.InferenceCosts["llama/prod"] = makeICR("llama", "prod", 120, 1_200_000, 600_000, 84, 36)
  261. out := accumulate([]*InferenceCostSet{set1, set2})
  262. if len(out.InferenceCosts) != 1 {
  263. t.Fatalf("expected 1 entry, got %d", len(out.InferenceCosts))
  264. }
  265. acc := out.InferenceCosts["llama/prod"]
  266. if acc.TotalCost != 220 {
  267. t.Errorf("TotalCost = %.2f, want 220", acc.TotalCost)
  268. }
  269. if acc.PromptTokens != 2_200_000 {
  270. t.Errorf("PromptTokens = %.0f, want 2_200_000", acc.PromptTokens)
  271. }
  272. // Combined window should span from win1.Start to win2.End.
  273. if out.Window.Start() == nil || out.Window.End() == nil {
  274. t.Fatal("expected combined window to be non-nil")
  275. }
  276. }
  277. func TestAccumulate_MultipleModels(t *testing.T) {
  278. now := time.Now().UTC()
  279. win1 := makeWindow(now.Add(-2*time.Hour), now.Add(-1*time.Hour))
  280. win2 := makeWindow(now.Add(-1*time.Hour), now)
  281. set1 := newInferenceCostSet(win1)
  282. set1.InferenceCosts["llama/prod"] = makeICR("llama", "prod", 100, 1_000_000, 500_000, 70, 30)
  283. set1.InferenceCosts["mistral/prod"] = makeICR("mistral", "prod", 50, 500_000, 250_000, 35, 15)
  284. set2 := newInferenceCostSet(win2)
  285. set2.InferenceCosts["llama/prod"] = makeICR("llama", "prod", 120, 1_200_000, 600_000, 84, 36)
  286. // mistral absent in set2
  287. out := accumulate([]*InferenceCostSet{set1, set2})
  288. if len(out.InferenceCosts) != 2 {
  289. t.Errorf("expected 2 entries, got %d", len(out.InferenceCosts))
  290. }
  291. llama := out.InferenceCosts["llama/prod"]
  292. if llama == nil || llama.TotalCost != 220 {
  293. t.Errorf("llama TotalCost = %.2f, want 220", llama.TotalCost)
  294. }
  295. mistral := out.InferenceCosts["mistral/prod"]
  296. if mistral == nil || mistral.TotalCost != 50 {
  297. t.Errorf("mistral TotalCost = %.2f, want 50", mistral.TotalCost)
  298. }
  299. }
  300. // --- parseFilter ---
  301. func TestParseFilter_Empty(t *testing.T) {
  302. specs, err := parseFilter("")
  303. if err != nil {
  304. t.Fatalf("unexpected error: %v", err)
  305. }
  306. if len(specs) != 0 {
  307. t.Errorf("expected 0 specs, got %d", len(specs))
  308. }
  309. }
  310. func TestParseFilter_SingleTerm(t *testing.T) {
  311. specs, err := parseFilter(`namespace:"llm-prod"`)
  312. if err != nil {
  313. t.Fatalf("unexpected error: %v", err)
  314. }
  315. if len(specs) != 1 {
  316. t.Fatalf("expected 1 spec, got %d", len(specs))
  317. }
  318. if specs[0].property != "namespace" || specs[0].value != "llm-prod" {
  319. t.Errorf("spec = %+v, want {namespace, llm-prod}", specs[0])
  320. }
  321. }
  322. func TestParseFilter_MultiTermAnd(t *testing.T) {
  323. specs, err := parseFilter(`namespace:"llm-prod"+model_name:"llama"`)
  324. if err != nil {
  325. t.Fatalf("unexpected error: %v", err)
  326. }
  327. if len(specs) != 2 {
  328. t.Fatalf("expected 2 specs, got %d", len(specs))
  329. }
  330. }
  331. func TestParseFilter_UnsupportedProperty(t *testing.T) {
  332. _, err := parseFilter(`product:"team-a"`)
  333. if err == nil {
  334. t.Fatal("expected error for unsupported property, got nil")
  335. }
  336. }
  337. func TestParseFilter_MissingColon(t *testing.T) {
  338. _, err := parseFilter(`namespacellm-prod`)
  339. if err == nil {
  340. t.Fatal("expected error for missing colon, got nil")
  341. }
  342. }
  343. // --- matchesFilter ---
  344. func TestMatchesFilter_NoSpecs(t *testing.T) {
  345. ic := &InferenceCostResponse{Properties: InferenceCostAPIProperties{Namespace: "prod"}}
  346. if !matchesFilter(ic, nil) {
  347. t.Error("expected matchesFilter to return true for empty specs")
  348. }
  349. }
  350. func TestMatchesFilter_Match(t *testing.T) {
  351. ic := &InferenceCostResponse{Properties: InferenceCostAPIProperties{Namespace: "prod"}}
  352. specs := []filterSpec{{property: "namespace", value: "prod"}}
  353. if !matchesFilter(ic, specs) {
  354. t.Error("expected matchesFilter to return true for matching spec")
  355. }
  356. }
  357. func TestMatchesFilter_NoMatch(t *testing.T) {
  358. ic := &InferenceCostResponse{Properties: InferenceCostAPIProperties{Namespace: "prod"}}
  359. specs := []filterSpec{{property: "namespace", value: "staging"}}
  360. if matchesFilter(ic, specs) {
  361. t.Error("expected matchesFilter to return false for non-matching spec")
  362. }
  363. }
  364. func TestMatchesFilter_Pod(t *testing.T) {
  365. ic := &InferenceCostResponse{
  366. Properties: InferenceCostAPIProperties{
  367. Namespace: "prod",
  368. Pod: "llama-pod-123",
  369. },
  370. }
  371. specs := []filterSpec{{property: "pod", value: "llama-pod-123"}}
  372. if !matchesFilter(ic, specs) {
  373. t.Error("expected matchesFilter to return true for matching pod")
  374. }
  375. }
  376. func TestMatchesFilter_Controller(t *testing.T) {
  377. ic := &InferenceCostResponse{
  378. Properties: InferenceCostAPIProperties{
  379. Namespace: "prod",
  380. Controller: "llama-deployment",
  381. },
  382. }
  383. specs := []filterSpec{{property: "controller", value: "llama-deployment"}}
  384. if !matchesFilter(ic, specs) {
  385. t.Error("expected matchesFilter to return true for matching controller")
  386. }
  387. }
  388. func TestMatchesFilter_ControllerKind(t *testing.T) {
  389. ic := &InferenceCostResponse{
  390. Properties: InferenceCostAPIProperties{
  391. Namespace: "prod",
  392. ControllerKind: "Deployment",
  393. },
  394. }
  395. specs := []filterSpec{{property: "controller_kind", value: "Deployment"}}
  396. if !matchesFilter(ic, specs) {
  397. t.Error("expected matchesFilter to return true for matching controller_kind")
  398. }
  399. }
  400. func TestMatchesFilter_Container(t *testing.T) {
  401. ic := &InferenceCostResponse{
  402. Properties: InferenceCostAPIProperties{
  403. Namespace: "prod",
  404. Container: "vllm-container",
  405. },
  406. }
  407. specs := []filterSpec{{property: "container", value: "vllm-container"}}
  408. if !matchesFilter(ic, specs) {
  409. t.Error("expected matchesFilter to return true for matching container")
  410. }
  411. }
  412. func TestMatchesFilter_MultipleNewDimensions(t *testing.T) {
  413. ic := &InferenceCostResponse{
  414. Properties: InferenceCostAPIProperties{
  415. Namespace: "prod",
  416. Pod: "llama-pod-123",
  417. ControllerKind: "Deployment",
  418. Container: "vllm-container",
  419. },
  420. }
  421. specs := []filterSpec{
  422. {property: "namespace", value: "prod"},
  423. {property: "controller_kind", value: "Deployment"},
  424. {property: "container", value: "vllm-container"},
  425. }
  426. if !matchesFilter(ic, specs) {
  427. t.Error("expected matchesFilter to return true for all matching specs")
  428. }
  429. }
  430. func TestMatchesFilter_NewDimensionNoMatch(t *testing.T) {
  431. ic := &InferenceCostResponse{
  432. Properties: InferenceCostAPIProperties{
  433. Namespace: "prod",
  434. Pod: "llama-pod-123",
  435. },
  436. }
  437. specs := []filterSpec{{property: "pod", value: "mistral-pod-456"}}
  438. if matchesFilter(ic, specs) {
  439. t.Error("expected matchesFilter to return false for non-matching pod")
  440. }
  441. }
  442. func TestParseFilter_NewDimensions(t *testing.T) {
  443. specs, err := parseFilter(`pod:"llama-pod-123"+controller:"llama-deployment"+container:"vllm"`)
  444. if err != nil {
  445. t.Fatalf("unexpected error: %v", err)
  446. }
  447. if len(specs) != 3 {
  448. t.Fatalf("expected 3 specs, got %d", len(specs))
  449. }
  450. expectedSpecs := []filterSpec{
  451. {property: "pod", value: "llama-pod-123"},
  452. {property: "controller", value: "llama-deployment"},
  453. {property: "container", value: "vllm"},
  454. }
  455. for i, expected := range expectedSpecs {
  456. if specs[i].property != expected.property || specs[i].value != expected.value {
  457. t.Errorf("spec[%d] = %+v, want %+v", i, specs[i], expected)
  458. }
  459. }
  460. }