2
0

queryservice.go 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. package inferencecost
  2. import (
  3. "context"
  4. "fmt"
  5. "net/http"
  6. "time"
  7. "github.com/julienschmidt/httprouter"
  8. "github.com/opencost/opencost/core/pkg/opencost"
  9. proto "github.com/opencost/opencost/core/pkg/protocol"
  10. "github.com/opencost/opencost/core/pkg/util/httputil"
  11. )
  12. // protocol is the package-level HTTP response helper, mirroring the pattern
  13. // used in pkg/costmodel/router.go.
  14. var protocol = proto.HTTP()
  15. // QueryService handles HTTP requests for the /inferenceCost endpoints. It
  16. // computes inference costs on demand at request time by driving the Collector
  17. // and Calculator, matching the pattern of OpenCost's /allocation and /assets
  18. // APIs (no stored repository).
  19. //
  20. // Note: each request issues two ComputeAllocation calls per sub-window (one
  21. // with idle sharing for allocation costs, one without for usage costs). This
  22. // is consistent with /allocation which also recomputes from Prometheus on
  23. // every request and has no result cache.
  24. type QueryService struct {
  25. collector *Collector
  26. calculator *Calculator
  27. }
  28. // NewQueryService creates a QueryService. Both collector and calculator must
  29. // be non-nil; if either is nil the handlers return 501.
  30. func NewQueryService(c *Collector, calc *Calculator) *QueryService {
  31. return &QueryService{
  32. collector: c,
  33. calculator: calc,
  34. }
  35. }
  36. // GetInferenceCostTotalHandler returns an httprouter-compatible handler for
  37. // GET /inferenceCost/total.
  38. //
  39. // Query parameters:
  40. // - window (required): RFC3339 "start,end" or named range
  41. // - costBasis: "usage" or "allocation" (default: allocation)
  42. // - aggregate: comma-separated list of dimensions (model_name, model_version, namespace, cluster)
  43. // - accumulate: hour, day, week, month (optional; controls sub-window step for large windows)
  44. // - filter: prop:value[+prop:value] (optional; Phase-1 minimal, AND semantics)
  45. func (qs *QueryService) GetInferenceCostTotalHandler() func(http.ResponseWriter, *http.Request, httprouter.Params) {
  46. return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
  47. if qs == nil || qs.collector == nil || qs.calculator == nil {
  48. http.Error(w, "InferenceCost query service not available", http.StatusNotImplemented)
  49. return
  50. }
  51. qp := httputil.NewQueryParams(r.URL.Query())
  52. req, err := ParseInferenceCostRequest(qp)
  53. if err != nil {
  54. protocol.WriteError(w, protocol.BadRequest(err.Error()))
  55. return
  56. }
  57. setRange, err := qs.query(r.Context(), req)
  58. if err != nil {
  59. protocol.WriteError(w, protocol.InternalServerError(fmt.Sprintf("InferenceCost query failed: %s", err)))
  60. return
  61. }
  62. // Accumulate all step-windows into a single total set.
  63. total := accumulate(setRange.InferenceCostSets)
  64. protocol.WriteData(w, total)
  65. }
  66. }
  67. // GetInferenceCostTimeseriesHandler returns an httprouter-compatible handler
  68. // for GET /inferenceCost/timeseries.
  69. //
  70. // The 'accumulate' parameter is required for this endpoint (it defines the
  71. // time-series step size). All other parameters are the same as /total.
  72. func (qs *QueryService) GetInferenceCostTimeseriesHandler() func(http.ResponseWriter, *http.Request, httprouter.Params) {
  73. return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
  74. if qs == nil || qs.collector == nil || qs.calculator == nil {
  75. http.Error(w, "InferenceCost query service not available", http.StatusNotImplemented)
  76. return
  77. }
  78. qp := httputil.NewQueryParams(r.URL.Query())
  79. req, err := ParseInferenceCostTimeseriesRequest(qp)
  80. if err != nil {
  81. protocol.WriteError(w, protocol.BadRequest(err.Error()))
  82. return
  83. }
  84. setRange, err := qs.query(r.Context(), req)
  85. if err != nil {
  86. protocol.WriteError(w, protocol.InternalServerError(fmt.Sprintf("InferenceCost query failed: %s", err)))
  87. return
  88. }
  89. protocol.WriteData(w, setRange)
  90. }
  91. }
  92. // query drives the on-demand compute loop for a QueryRequest:
  93. // 1. Split [Start, End] into Step-sized sub-windows.
  94. // 2. For each sub-window: collect metrics from Prometheus + allocation layer,
  95. // calculate costs, project to the requested cost basis, apply filter, aggregate.
  96. // 3. Return all sub-window sets in an InferenceCostSetRange.
  97. //
  98. // The caller decides whether to accumulate the range into a single total (/total)
  99. // or return the per-step sets (/timeseries).
  100. func (qs *QueryService) query(ctx context.Context, req *QueryRequest) (*InferenceCostSetRange, error) {
  101. overallWindow := opencost.NewClosedWindow(req.Start, req.End)
  102. sets := make([]*InferenceCostSet, 0)
  103. stepStart := req.Start
  104. for stepStart.Before(req.End) {
  105. stepEnd := stepStart.Add(req.Step)
  106. if stepEnd.After(req.End) {
  107. stepEnd = req.End
  108. }
  109. stepWindow := opencost.NewClosedWindow(stepStart, stepEnd)
  110. set, err := qs.computeStep(ctx, req, stepWindow)
  111. if err != nil {
  112. return nil, fmt.Errorf("computing window [%s, %s]: %w", stepStart.Format(time.RFC3339), stepEnd.Format(time.RFC3339), err)
  113. }
  114. sets = append(sets, set)
  115. stepStart = stepEnd
  116. }
  117. return &InferenceCostSetRange{
  118. InferenceCostSets: sets,
  119. Window: overallWindow,
  120. }, nil
  121. }
  122. // computeStep computes inference costs for a single sub-window, projects to
  123. // the requested cost basis, applies filters, and aggregates.
  124. func (qs *QueryService) computeStep(ctx context.Context, req *QueryRequest, win opencost.Window) (*InferenceCostSet, error) {
  125. // Collect raw metrics for this sub-window.
  126. metrics, err := qs.collector.CollectMetrics(ctx, *win.Start(), *win.End())
  127. if err != nil {
  128. return nil, fmt.Errorf("collecting metrics: %w", err)
  129. }
  130. // Compute derived costs (per-million rates, input/output split).
  131. qs.calculator.CalculateCosts(metrics)
  132. set := newInferenceCostSet(win)
  133. for _, ic := range metrics {
  134. resp := newInferenceCostResponse(ic, req.CostBasis, win)
  135. // Apply Phase-1 filter (AND semantics over supported properties).
  136. if !matchesFilter(resp, req.Filter) {
  137. continue
  138. }
  139. // Derive the natural key for this entry; aggregate() will re-key if
  140. // aggregateBy is set, but we still need a unique key here.
  141. key, err := aggKey(resp.Properties, nil) // nil = natural key
  142. if err != nil {
  143. return nil, err
  144. }
  145. set.InferenceCosts[key] = resp
  146. }
  147. // Aggregate into the requested dimensions.
  148. if err := set.aggregate(req.AggregateBy); err != nil {
  149. return nil, fmt.Errorf("aggregating results: %w", err)
  150. }
  151. return set, nil
  152. }