exporter_test.go 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. package inferencecost
  2. import (
  3. "strings"
  4. "testing"
  5. "time"
  6. "github.com/prometheus/client_golang/prometheus"
  7. "github.com/prometheus/client_golang/prometheus/testutil"
  8. )
  9. // newTestExporter creates a fresh Exporter registered on an isolated Prometheus
  10. // registry so tests don't conflict with each other or the default registry.
  11. func newTestExporter(t *testing.T) (*Exporter, *prometheus.Registry) {
  12. t.Helper()
  13. reg := prometheus.NewRegistry()
  14. e := NewExporter()
  15. for _, c := range []prometheus.Collector{
  16. e.totalCost,
  17. e.costPerMillionTokens,
  18. e.cacheSavingsFraction,
  19. } {
  20. if err := reg.Register(c); err != nil {
  21. t.Fatalf("failed to register collector: %v", err)
  22. }
  23. }
  24. return e, reg
  25. }
  26. func sampleMetric(method AllocationMethod) *InferenceCost {
  27. now := time.Now()
  28. ic := &InferenceCost{
  29. Properties: InferenceCostProperties{
  30. ModelName: "meta-llama/Llama-3.1-8B",
  31. ModelVersion: "v1",
  32. Namespace: "llm-prod",
  33. },
  34. AllocationTotalCost: 4.0,
  35. UsageTotalCost: 1.0,
  36. TotalTokens: 1_000_000,
  37. EffectiveInputTokens: 800_000,
  38. GenerationTokens: 200_000,
  39. AllocationMethod: method,
  40. CostPerMillionTokens: map[CostBasis]float64{
  41. CostBasisAllocation: 4.0,
  42. CostBasisUsage: 1.0,
  43. },
  44. InputCostPerMillionTokens: map[CostBasis]float64{
  45. CostBasisAllocation: 3.5,
  46. CostBasisUsage: 0.875,
  47. },
  48. OutputCostPerMillionTokens: map[CostBasis]float64{
  49. CostBasisAllocation: 7.0,
  50. CostBasisUsage: 1.75,
  51. },
  52. Timestamp: now,
  53. }
  54. // Set a 1-hour window so costs are already normalized (4.0 for 1 hour = 4.0/hour)
  55. ic.Window.Start = now.Add(-1 * time.Hour)
  56. ic.Window.End = now
  57. return ic
  58. }
  59. // TestExporter_MetricNames verifies that exported metric names are llm_* not opencost_inference_*.
  60. func TestExporter_MetricNames(t *testing.T) {
  61. e, reg := newTestExporter(t)
  62. e.Export([]*InferenceCost{sampleMetric(AllocationMethodComputeTime)})
  63. mfs, err := reg.Gather()
  64. if err != nil {
  65. t.Fatalf("gather: %v", err)
  66. }
  67. names := make([]string, 0, len(mfs))
  68. for _, mf := range mfs {
  69. names = append(names, mf.GetName())
  70. }
  71. required := []string{
  72. "llm_total_hourly_cost",
  73. "llm_cost_per_million_tokens",
  74. "llm_cache_savings_fraction",
  75. }
  76. for _, want := range required {
  77. found := false
  78. for _, got := range names {
  79. if got == want {
  80. found = true
  81. break
  82. }
  83. }
  84. if !found {
  85. t.Errorf("metric %q not found; registered names: %v", want, names)
  86. }
  87. }
  88. for _, name := range names {
  89. if strings.HasPrefix(name, "opencost_inference") {
  90. t.Errorf("found deprecated metric name %q — should be llm_*", name)
  91. }
  92. }
  93. }
  94. // TestExporter_TwoCostBasisSeriesPerModel verifies that llm_total_hourly_cost produces
  95. // two series (usage + allocation) and llm_cost_per_million_tokens produces
  96. // six series (2 cost bases × 3 phase values: blended/"", prompt, generation).
  97. func TestExporter_TwoCostBasisSeriesPerModel(t *testing.T) {
  98. e, reg := newTestExporter(t)
  99. e.Export([]*InferenceCost{sampleMetric(AllocationMethodComputeTime)})
  100. // llm_total_hourly_cost should have 2 series (usage + allocation)
  101. count := testutil.CollectAndCount(e.totalCost)
  102. if count != 2 {
  103. t.Errorf("llm_total_hourly_cost: expected 2 series (usage+allocation), got %d", count)
  104. }
  105. // llm_cost_per_million_tokens should have 6 series:
  106. // 2 cost bases × 3 phases (blended/"", prompt, generation)
  107. count = testutil.CollectAndCount(e.costPerMillionTokens)
  108. if count != 6 {
  109. t.Errorf("llm_cost_per_million_tokens: expected 6 series (2 bases × 3 phases), got %d", count)
  110. }
  111. // Verify both cost_basis values are present for llm_total_hourly_cost.
  112. mfs, _ := reg.Gather()
  113. for _, mf := range mfs {
  114. if mf.GetName() != "llm_total_hourly_cost" {
  115. continue
  116. }
  117. bases := make(map[string]bool)
  118. for _, m := range mf.GetMetric() {
  119. for _, lp := range m.GetLabel() {
  120. if lp.GetName() == "cost_basis" {
  121. bases[lp.GetValue()] = true
  122. }
  123. }
  124. }
  125. if !bases["usage"] {
  126. t.Error("llm_total_hourly_cost missing cost_basis=usage series")
  127. }
  128. if !bases["allocation"] {
  129. t.Error("llm_total_hourly_cost missing cost_basis=allocation series")
  130. }
  131. }
  132. }
  133. // TestExporter_PhaseLabelsAndAllocationMethod verifies that
  134. // llm_cost_per_million_tokens has the correct phase labels and allocation_method.
  135. func TestExporter_PhaseLabelsAndAllocationMethod(t *testing.T) {
  136. for _, method := range []AllocationMethod{
  137. AllocationMethodComputeTime,
  138. AllocationMethodPrefixCachingOff,
  139. AllocationMethodMultiplier,
  140. } {
  141. e, reg := newTestExporter(t)
  142. e.Export([]*InferenceCost{sampleMetric(method)})
  143. mfs, _ := reg.Gather()
  144. for _, mf := range mfs {
  145. name := mf.GetName()
  146. if name != "llm_cost_per_million_tokens" {
  147. continue
  148. }
  149. bases := make(map[string]bool)
  150. phases := make(map[string]bool)
  151. methods := make(map[string]bool)
  152. for _, m := range mf.GetMetric() {
  153. for _, lp := range m.GetLabel() {
  154. switch lp.GetName() {
  155. case "cost_basis":
  156. bases[lp.GetValue()] = true
  157. case "phase":
  158. phases[lp.GetValue()] = true
  159. case "allocation_method":
  160. methods[lp.GetValue()] = true
  161. }
  162. }
  163. }
  164. if !bases["usage"] || !bases["allocation"] {
  165. t.Errorf("%s method=%s: missing cost_basis label values, got %v", name, method, bases)
  166. }
  167. // Should have 3 phase values: "" (blended), "prompt", "generation"
  168. if !phases[""] || !phases["prompt"] || !phases["generation"] {
  169. t.Errorf("%s method=%s: expected phases [\"\", \"prompt\", \"generation\"], got %v", name, method, phases)
  170. }
  171. // allocation_method should be present (for phase=prompt and phase=generation)
  172. // and empty (for blended phase="")
  173. if !methods[string(method)] || !methods[""] {
  174. t.Errorf("%s: expected allocation_method values [%s, \"\"], got %v", name, method, methods)
  175. }
  176. }
  177. }
  178. }
  179. // TestExporter_HelpStringsContainReconciliationNote verifies that usage-basis
  180. // metrics document that they do not reconcile to the bill.
  181. func TestExporter_HelpStringsContainReconciliationNote(t *testing.T) {
  182. e, reg := newTestExporter(t)
  183. e.Export([]*InferenceCost{sampleMetric(AllocationMethodComputeTime)})
  184. mfs, _ := reg.Gather()
  185. reconciliationKeyword := "does NOT reconcile"
  186. for _, mf := range mfs {
  187. name := mf.GetName()
  188. if name != "llm_total_hourly_cost" && name != "llm_cost_per_million_tokens" {
  189. continue
  190. }
  191. help := mf.GetHelp()
  192. if !strings.Contains(help, reconciliationKeyword) {
  193. t.Errorf("%s Help string should mention reconciliation, got: %q", name, help)
  194. }
  195. }
  196. }
  197. // TestExporter_CacheSavingsFraction verifies that llm_cache_savings_fraction is exported correctly.
  198. func TestExporter_CacheSavingsFraction(t *testing.T) {
  199. e, reg := newTestExporter(t)
  200. ic := sampleMetric(AllocationMethodComputeTime)
  201. ic.CacheSavingsFraction = 0.4
  202. e.Export([]*InferenceCost{ic})
  203. mfs, _ := reg.Gather()
  204. for _, mf := range mfs {
  205. if mf.GetName() != "llm_cache_savings_fraction" {
  206. continue
  207. }
  208. if len(mf.GetMetric()) != 1 {
  209. t.Fatalf("expected 1 series for llm_cache_savings_fraction, got %d", len(mf.GetMetric()))
  210. }
  211. val := mf.GetMetric()[0].GetGauge().GetValue()
  212. if !floatEq(val, 0.4) {
  213. t.Errorf("llm_cache_savings_fraction want 0.4 got %f", val)
  214. }
  215. return
  216. }
  217. t.Error("llm_cache_savings_fraction metric not found")
  218. }
  219. // TestExporter_Values verifies that exported gauge values match InferenceCost fields.
  220. func TestExporter_Values(t *testing.T) {
  221. e, reg := newTestExporter(t)
  222. ic := sampleMetric(AllocationMethodComputeTime)
  223. e.Export([]*InferenceCost{ic})
  224. mfs, _ := reg.Gather()
  225. for _, mf := range mfs {
  226. if mf.GetName() != "llm_total_hourly_cost" {
  227. continue
  228. }
  229. for _, m := range mf.GetMetric() {
  230. var basis string
  231. for _, lp := range m.GetLabel() {
  232. if lp.GetName() == "cost_basis" {
  233. basis = lp.GetValue()
  234. }
  235. }
  236. val := m.GetGauge().GetValue()
  237. switch basis {
  238. case "allocation":
  239. if !floatEq(val, ic.AllocationTotalCost) {
  240. t.Errorf("llm_total_hourly_cost allocation want %f got %f", ic.AllocationTotalCost, val)
  241. }
  242. case "usage":
  243. if !floatEq(val, ic.UsageTotalCost) {
  244. t.Errorf("llm_total_hourly_cost usage want %f got %f", ic.UsageTotalCost, val)
  245. }
  246. }
  247. }
  248. }
  249. }