queryservice_test.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. package inferencecost
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "net/http"
  7. "net/http/httptest"
  8. "testing"
  9. "time"
  10. "github.com/opencost/opencost/core/pkg/opencost"
  11. "github.com/opencost/opencost/core/pkg/source"
  12. )
  13. // --- Fake Prometheus server ---
  14. // promSample is one model/namespace series value for a fake Prometheus response.
  15. type promSample struct {
  16. modelName string
  17. namespace string
  18. value float64
  19. }
  20. // promInstantJSON returns a minimal Prometheus API v1 instant-query JSON
  21. // payload containing the given vector samples.
  22. func promInstantJSON(samples []promSample) string {
  23. type result struct {
  24. Metric map[string]string `json:"metric"`
  25. Value [2]interface{} `json:"value"`
  26. }
  27. results := make([]result, 0, len(samples))
  28. for _, s := range samples {
  29. results = append(results, result{
  30. Metric: map[string]string{
  31. "model_name": s.modelName,
  32. "namespace": s.namespace,
  33. },
  34. Value: [2]interface{}{1.0, fmt.Sprintf("%f", s.value)},
  35. })
  36. }
  37. data := map[string]interface{}{
  38. "status": "success",
  39. "data": map[string]interface{}{
  40. "resultType": "vector",
  41. "result": results,
  42. },
  43. }
  44. b, _ := json.Marshal(data)
  45. return string(b)
  46. }
  47. // newFakePromServer returns a *httptest.Server that responds to any
  48. // /api/v1/query request with the provided samples.
  49. func newFakePromServer(t *testing.T, samples []promSample) *httptest.Server {
  50. t.Helper()
  51. body := promInstantJSON(samples)
  52. return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  53. w.Header().Set("Content-Type", "application/json")
  54. w.WriteHeader(http.StatusOK)
  55. w.Write([]byte(body)) //nolint:errcheck
  56. }))
  57. }
  58. // --- HTTP handler tests (do not require real Prometheus / allocation data) ---
  59. func TestQueryService_NilCollector_Returns501(t *testing.T) {
  60. qs := &QueryService{collector: nil, calculator: nil}
  61. handler := qs.GetInferenceCostTotalHandler()
  62. req, _ := http.NewRequest(http.MethodGet, "/inferenceCost/total?window=2024-01-01T00:00:00Z,2024-01-02T00:00:00Z", nil)
  63. rr := httptest.NewRecorder()
  64. handler(rr, req, nil)
  65. if rr.Code != http.StatusNotImplemented {
  66. t.Errorf("total handler: status = %d, want %d (501)", rr.Code, http.StatusNotImplemented)
  67. }
  68. handler2 := qs.GetInferenceCostTimeseriesHandler()
  69. rr2 := httptest.NewRecorder()
  70. handler2(rr2, req, nil)
  71. if rr2.Code != http.StatusNotImplemented {
  72. t.Errorf("timeseries handler: status = %d, want %d (501)", rr2.Code, http.StatusNotImplemented)
  73. }
  74. }
  75. func TestQueryService_MissingWindow_Returns400(t *testing.T) {
  76. cfg := baseConfig()
  77. querier := &mockQuerier{set: opencost.NewAllocationSet(time.Now().Add(-time.Hour), time.Now())}
  78. metricsQuerier := source.NewMockMetricsQuerier()
  79. collector, err := NewCollector(cfg, querier, metricsQuerier)
  80. if err != nil {
  81. t.Fatalf("NewCollector: %v", err)
  82. }
  83. qs := NewQueryService(collector, NewCalculator(cfg))
  84. handler := qs.GetInferenceCostTotalHandler()
  85. req, _ := http.NewRequest(http.MethodGet, "/inferenceCost/total", nil) // no window
  86. rr := httptest.NewRecorder()
  87. handler(rr, req, nil)
  88. if rr.Code != http.StatusBadRequest {
  89. t.Errorf("status = %d, want 400", rr.Code)
  90. }
  91. }
  92. func TestQueryService_InvalidCostBasis_Returns400(t *testing.T) {
  93. cfg := baseConfig()
  94. querier := &mockQuerier{set: opencost.NewAllocationSet(time.Now().Add(-time.Hour), time.Now())}
  95. metricsQuerier := source.NewMockMetricsQuerier()
  96. collector, _ := NewCollector(cfg, querier, metricsQuerier)
  97. qs := NewQueryService(collector, NewCalculator(cfg))
  98. handler := qs.GetInferenceCostTotalHandler()
  99. req, _ := http.NewRequest(http.MethodGet, "/inferenceCost/total?window=2024-01-01T00:00:00Z,2024-01-02T00:00:00Z&costBasis=bogus", nil)
  100. rr := httptest.NewRecorder()
  101. handler(rr, req, nil)
  102. if rr.Code != http.StatusBadRequest {
  103. t.Errorf("status = %d, want 400 for invalid costBasis", rr.Code)
  104. }
  105. }
  106. func TestQueryService_TimeseriesMissingAccumulate_Returns400(t *testing.T) {
  107. srv := newFakePromServer(t, nil)
  108. defer srv.Close()
  109. cfg := baseConfig()
  110. cfg.PrometheusURL = srv.URL
  111. querier := &mockQuerier{set: opencost.NewAllocationSet(time.Now().Add(-time.Hour), time.Now())}
  112. metricsQuerier := source.NewMockMetricsQuerier()
  113. collector, _ := NewCollector(cfg, querier, metricsQuerier)
  114. qs := NewQueryService(collector, NewCalculator(cfg))
  115. handler := qs.GetInferenceCostTimeseriesHandler()
  116. req, _ := http.NewRequest(http.MethodGet, "/inferenceCost/timeseries?window=2024-01-01T00:00:00Z,2024-01-02T00:00:00Z", nil)
  117. rr := httptest.NewRecorder()
  118. handler(rr, req, nil)
  119. if rr.Code != http.StatusBadRequest {
  120. t.Errorf("status = %d, want 400 for missing accumulate", rr.Code)
  121. }
  122. }
  123. // --- query() unit tests using pre-built InferenceCost structs ---
  124. //
  125. // computeStep calls CollectMetrics → CalculateCosts → project/filter/aggregate.
  126. // We can test the projection and aggregation layers directly by using
  127. // combineMetrics with pre-built allocationResult and token maps (bypassing the
  128. // AggregateBy path in the real collector which requires a live K8s label setup).
  129. func TestQuery_SingleStep_ReturnsOneSet(t *testing.T) {
  130. qs, srv := newQueryServiceWithDirectMetrics(t, "llama-3", "llm-prod", 10.0, 1_000_000)
  131. defer srv.Close()
  132. now := time.Now().UTC()
  133. req := &QueryRequest{
  134. Start: now.Add(-time.Hour),
  135. End: now,
  136. CostBasis: CostBasisAllocation,
  137. Step: time.Hour,
  138. }
  139. setRange, err := qs.query(context.Background(), req)
  140. if err != nil {
  141. t.Fatalf("query error: %v", err)
  142. }
  143. if len(setRange.InferenceCostSets) != 1 {
  144. t.Errorf("expected 1 set for a single step, got %d", len(setRange.InferenceCostSets))
  145. }
  146. }
  147. func TestQuery_TwoSteps_ReturnsTwoSets(t *testing.T) {
  148. qs, srv := newQueryServiceWithDirectMetrics(t, "llama-3", "llm-prod", 5.0, 500_000)
  149. defer srv.Close()
  150. now := time.Now().UTC().Truncate(time.Hour)
  151. req := &QueryRequest{
  152. Start: now.Add(-2 * time.Hour),
  153. End: now,
  154. CostBasis: CostBasisAllocation,
  155. Accumulate: opencost.AccumulateOptionHour,
  156. Step: time.Hour,
  157. }
  158. setRange, err := qs.query(context.Background(), req)
  159. if err != nil {
  160. t.Fatalf("query error: %v", err)
  161. }
  162. if len(setRange.InferenceCostSets) != 2 {
  163. t.Errorf("expected 2 sets for 2-hour range at 1-hour step, got %d", len(setRange.InferenceCostSets))
  164. }
  165. }
  166. func TestQuery_Filter_ExcludesNonMatchingNamespace(t *testing.T) {
  167. qs, srv := newQueryServiceWithDirectMetrics(t, "llama-3", "llm-prod", 10.0, 1_000_000)
  168. defer srv.Close()
  169. filterSpecs, _ := parseFilter(`namespace:"other-ns"`)
  170. now := time.Now().UTC()
  171. req := &QueryRequest{
  172. Start: now.Add(-time.Hour),
  173. End: now,
  174. CostBasis: CostBasisAllocation,
  175. Step: time.Hour,
  176. Filter: filterSpecs,
  177. }
  178. setRange, err := qs.query(context.Background(), req)
  179. if err != nil {
  180. t.Fatalf("query error: %v", err)
  181. }
  182. total := accumulate(setRange.InferenceCostSets)
  183. if len(total.InferenceCosts) != 0 {
  184. t.Errorf("expected 0 entries after filtering to non-matching namespace, got %d", len(total.InferenceCosts))
  185. }
  186. }
  187. func TestQuery_Filter_MatchingNamespace(t *testing.T) {
  188. qs, srv := newQueryServiceWithDirectMetrics(t, "llama-3", "llm-prod", 10.0, 1_000_000)
  189. defer srv.Close()
  190. filterSpecs, _ := parseFilter(`namespace:"llm-prod"`)
  191. now := time.Now().UTC()
  192. req := &QueryRequest{
  193. Start: now.Add(-time.Hour),
  194. End: now,
  195. CostBasis: CostBasisAllocation,
  196. Step: time.Hour,
  197. Filter: filterSpecs,
  198. }
  199. setRange, err := qs.query(context.Background(), req)
  200. if err != nil {
  201. t.Fatalf("query error: %v", err)
  202. }
  203. total := accumulate(setRange.InferenceCostSets)
  204. if len(total.InferenceCosts) == 0 {
  205. t.Error("expected at least one entry after filtering to matching namespace, got 0")
  206. }
  207. }
  208. func TestQuery_UsageBasis_LowerThanAllocation(t *testing.T) {
  209. // Usage costs should be <= allocation costs (no idle sharing).
  210. // The fake allocation querier returns fixed costs; the multiplier calculator
  211. // sets both basis costs from the allocationResult directly.
  212. qs, srv := newQueryServiceWithDirectMetrics(t, "llama-3", "llm-prod", 10.0, 1_000_000)
  213. defer srv.Close()
  214. now := time.Now().UTC()
  215. queryBasis := func(basis CostBasis) float64 {
  216. req := &QueryRequest{
  217. Start: now.Add(-time.Hour),
  218. End: now,
  219. CostBasis: basis,
  220. Step: time.Hour,
  221. }
  222. setRange, err := qs.query(context.Background(), req)
  223. if err != nil {
  224. t.Fatalf("query error: %v", err)
  225. }
  226. total := accumulate(setRange.InferenceCostSets)
  227. var tc float64
  228. for _, ic := range total.InferenceCosts {
  229. tc += ic.TotalCost
  230. }
  231. return tc
  232. }
  233. allocCost := queryBasis(CostBasisAllocation)
  234. usageCost := queryBasis(CostBasisUsage)
  235. // With ShareNone for usage (default config), usage cost ≤ allocation cost.
  236. if usageCost > allocCost {
  237. t.Errorf("usageCost (%.4f) > allocCost (%.4f): unexpected", usageCost, allocCost)
  238. }
  239. }
  240. // newQueryServiceWithDirectMetrics builds a QueryService backed by a fake
  241. // Prometheus server and an allocation querier that returns a pre-built
  242. // AllocationSet with the model label set in its sanitized form.
  243. //
  244. // OpenCost's AggregateBy("label:llm-d.ai/model") sanitizes the label key
  245. // via promutil.SanitizeLabelName before lookup, converting non-alphanumeric
  246. // characters to underscores. So we must store the label under the sanitized
  247. // key "llm_d_ai_model" for the allocation to be found and named correctly
  248. // after aggregation.
  249. func newQueryServiceWithDirectMetrics(t *testing.T, modelName, namespace string, allocCost, tokenCount float64) (*QueryService, *httptest.Server) {
  250. t.Helper()
  251. samples := []promSample{
  252. {modelName: modelName, namespace: namespace, value: tokenCount},
  253. }
  254. srv := newFakePromServer(t, samples)
  255. cfg := baseConfig()
  256. cfg.PrometheusURL = srv.URL
  257. cfg.AllocationMode = AllocationModeMultiplier
  258. cfg.OutputTokenCostMultiplier = 2.5
  259. // cfg.ModelLabel = "llm-d.ai/model" (default from baseConfig)
  260. // The sanitized form of "llm-d.ai/model" that AggregateBy uses for lookup.
  261. sanitizedLabelKey := "llm_d_ai_model"
  262. querier := &mockQuerier{
  263. set: func() *opencost.AllocationSet {
  264. now := time.Now()
  265. as := opencost.NewAllocationSet(now.Add(-time.Hour), now)
  266. a := &opencost.Allocation{
  267. Name: modelName,
  268. GPUCost: allocCost,
  269. Properties: &opencost.AllocationProperties{
  270. Namespace: namespace,
  271. Labels: opencost.AllocationLabels{
  272. sanitizedLabelKey: modelName,
  273. },
  274. },
  275. }
  276. as.Set(a)
  277. return as
  278. }(),
  279. }
  280. metricsQuerier := source.NewMockMetricsQuerier()
  281. collector, err := NewCollector(cfg, querier, metricsQuerier)
  282. if err != nil {
  283. t.Fatalf("NewCollector: %v", err)
  284. }
  285. calculator := NewCalculator(cfg)
  286. return NewQueryService(collector, calculator), srv
  287. }