2
0

exporter.go 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
  1. package inferencecost
  2. import (
  3. "github.com/opencost/opencost/core/pkg/log"
  4. "github.com/prometheus/client_golang/prometheus"
  5. )
  6. // Exporter registers and emits the llm_* Prometheus metrics.
  7. type Exporter struct {
  8. totalCost *prometheus.GaugeVec
  9. costPerMillionTokens *prometheus.GaugeVec
  10. cacheSavingsFraction *prometheus.GaugeVec
  11. }
  12. // NewExporter creates an Exporter with all gauge vectors initialised.
  13. func NewExporter() *Exporter {
  14. return &Exporter{
  15. totalCost: prometheus.NewGaugeVec(
  16. prometheus.GaugeOpts{
  17. Name: "llm_total_hourly_cost",
  18. Help: "Hourly infrastructure cost attributed to an LLM model. " +
  19. "cost_basis=allocation reconciles to the infrastructure bill (includes idle and shared infra costs). " +
  20. "cost_basis=usage reflects active compute only; idle and shared infra costs are excluded and it does NOT reconcile to the bill.",
  21. },
  22. []string{"model_name", "model_version", "namespace", "cost_basis", "workload_type"},
  23. ),
  24. costPerMillionTokens: prometheus.NewGaugeVec(
  25. prometheus.GaugeOpts{
  26. Name: "llm_cost_per_million_tokens",
  27. Help: "Infrastructure cost per 1M tokens. " +
  28. "Without phase label: blended cost (input + output combined). " +
  29. "phase=prompt: cost per 1M delivered input tokens (promptTokens denominator; see llm_cache_savings_fraction for KV cache utilization). " +
  30. "phase=generation: cost per 1M output tokens. " +
  31. "cost_basis=allocation includes idle and shared infra; reconciles to bill. " +
  32. "cost_basis=usage reflects active compute only; idle and shared infra costs excluded; does NOT reconcile to bill. " +
  33. "allocation_method=compute_time: split proportionally by vLLM prefill/decode time; KV cache savings in llm_cache_savings_fraction. " +
  34. "allocation_method=prefix_caching_off: same time-based split; prefix caching explicitly disabled on vLLM instance. " +
  35. "allocation_method=multiplier: fixed output/input ratio used (timing metrics unavailable).",
  36. },
  37. []string{"model_name", "model_version", "namespace", "cost_basis", "phase", "allocation_method", "workload_type"},
  38. ),
  39. cacheSavingsFraction: prometheus.NewGaugeVec(
  40. prometheus.GaugeOpts{
  41. Name: "llm_cache_savings_fraction",
  42. Help: "Fraction of prompt tokens served from the KV cache (range 0–1). " +
  43. "A value of 0.9 means 90% of prompt tokens were cache hits. " +
  44. "Zero when prefix caching is disabled (allocation_method=prefix_caching_off) or when no cache hits occurred in the window.",
  45. },
  46. []string{"model_name", "model_version", "namespace", "workload_type"},
  47. ),
  48. }
  49. }
  50. // Register registers all gauge vectors with the default Prometheus registry.
  51. // Returns an error if any registration fails (e.g. called twice).
  52. func (e *Exporter) Register() error {
  53. for _, c := range []prometheus.Collector{
  54. e.totalCost,
  55. e.costPerMillionTokens,
  56. e.cacheSavingsFraction,
  57. } {
  58. if err := prometheus.Register(c); err != nil {
  59. return err
  60. }
  61. }
  62. return nil
  63. }
  64. // Export sets gauge values for all metrics derived from the given InferenceCost slice.
  65. // Gauges are reset before each export so decommissioned models do not persist.
  66. func (e *Exporter) Export(metrics []*InferenceCost) {
  67. e.totalCost.Reset()
  68. e.costPerMillionTokens.Reset()
  69. e.cacheSavingsFraction.Reset()
  70. for _, m := range metrics {
  71. version := m.Properties.ModelVersion
  72. if version == "" {
  73. version = "unknown"
  74. }
  75. method := string(m.AllocationMethod)
  76. workloadType := m.Properties.WorkloadType
  77. if workloadType == "" {
  78. workloadType = "unknown"
  79. }
  80. // Calculate window duration in hours for normalization to hourly rate
  81. windowDuration := m.Window.End.Sub(m.Window.Start)
  82. windowHours := windowDuration.Hours()
  83. if windowHours <= 0 {
  84. // Avoid division by zero; skip this metric if window is invalid
  85. log.Warnf("InferenceCost: skipping export for model=%s ns=%s (invalid window duration: %v)",
  86. m.Properties.ModelName, m.Properties.Namespace, windowDuration)
  87. continue
  88. }
  89. for _, basis := range []CostBasis{CostBasisUsage, CostBasisAllocation} {
  90. basisStr := string(basis)
  91. // Normalize total cost to hourly rate: totalCost / windowHours
  92. hourlyCost := totalCostForBasis(m, basis) / windowHours
  93. e.totalCost.WithLabelValues(
  94. m.Properties.ModelName, version, m.Properties.Namespace, basisStr, workloadType,
  95. ).Set(hourlyCost)
  96. // Blended cost (no phase label)
  97. e.costPerMillionTokens.WithLabelValues(
  98. m.Properties.ModelName, version, m.Properties.Namespace, basisStr, "", "", workloadType,
  99. ).Set(m.CostPerMillionTokens[basis])
  100. // Input cost (phase=prompt)
  101. e.costPerMillionTokens.WithLabelValues(
  102. m.Properties.ModelName, version, m.Properties.Namespace, basisStr, "prompt", method, workloadType,
  103. ).Set(m.InputCostPerMillionTokens[basis])
  104. // Output cost (phase=generation)
  105. e.costPerMillionTokens.WithLabelValues(
  106. m.Properties.ModelName, version, m.Properties.Namespace, basisStr, "generation", method, workloadType,
  107. ).Set(m.OutputCostPerMillionTokens[basis])
  108. }
  109. e.cacheSavingsFraction.WithLabelValues(
  110. m.Properties.ModelName, version, m.Properties.Namespace, workloadType,
  111. ).Set(m.CacheSavingsFraction)
  112. log.Debugf("InferenceCost: exported model=%s ns=%s alloc_total=$%.4f usage_total=$%.4f method=%s workload_type=%s",
  113. m.Properties.ModelName, m.Properties.Namespace,
  114. m.AllocationTotalCost, m.UsageTotalCost, method, workloadType)
  115. }
  116. }
  117. func totalCostForBasis(m *InferenceCost, basis CostBasis) float64 {
  118. if basis == CostBasisUsage {
  119. return m.UsageTotalCost
  120. }
  121. return m.AllocationTotalCost
  122. }