aggregate.go 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. package inferencecost
  2. import (
  3. "fmt"
  4. "strings"
  5. "github.com/opencost/opencost/core/pkg/opencost"
  6. )
  7. // supportedAggregateProperties lists the InferenceCostProperties dimensions
  8. // that the collector actually populates in Phase 1.
  9. var supportedAggregateProperties = map[string]bool{
  10. "model_name": true,
  11. "model_version": true,
  12. "namespace": true,
  13. "cluster": true,
  14. "pod": true,
  15. "controller": true,
  16. "controller_kind": true,
  17. "container": true,
  18. "workload_type": true,
  19. }
  20. // aggKey derives the aggregation map key for an InferenceCostResponse given
  21. // the requested aggregation dimensions. Returns an error if any dimension is
  22. // not in supportedAggregateProperties.
  23. func aggKey(props InferenceCostAPIProperties, aggregateBy []string) (string, error) {
  24. if len(aggregateBy) == 0 {
  25. // Use "model:namespace" as the natural key. ":" cannot appear in
  26. // Kubernetes label values or namespace names, so this is unambiguous
  27. // even when the model name contains "/" (e.g. "org/model").
  28. ns := props.Namespace
  29. if ns == "" {
  30. ns = opencost.UnallocatedSuffix
  31. }
  32. return props.ModelName + ":" + ns, nil
  33. }
  34. parts := make([]string, 0, len(aggregateBy))
  35. for _, dim := range aggregateBy {
  36. if !supportedAggregateProperties[dim] {
  37. return "", fmt.Errorf("unsupported aggregation dimension %q: supported dimensions are model_name, model_version, namespace, cluster, pod, controller, controller_kind, container, workload_type", dim)
  38. }
  39. var val string
  40. switch dim {
  41. case "model_name":
  42. val = props.ModelName
  43. case "model_version":
  44. val = props.ModelVersion
  45. case "namespace":
  46. val = props.Namespace
  47. case "cluster":
  48. val = props.Cluster
  49. case "pod":
  50. val = props.Pod
  51. case "controller":
  52. val = props.Controller
  53. case "controller_kind":
  54. val = props.ControllerKind
  55. case "container":
  56. val = props.Container
  57. case "workload_type":
  58. val = props.WorkloadType
  59. }
  60. if val == "" {
  61. val = opencost.UnallocatedSuffix
  62. }
  63. parts = append(parts, val)
  64. }
  65. return strings.Join(parts, "/"), nil
  66. }
  67. // addResponse merges src into dst, summing additive fields and recomputing
  68. // derived per-million-token rates from the summed numerators/denominators.
  69. // Rates are NOT averaged — they are recomputed after summing to preserve
  70. // accuracy (e.g. two models with different throughput cannot be averaged).
  71. func addResponse(dst, src *InferenceCostResponse) {
  72. dst.TotalCost += src.TotalCost
  73. dst.PromptTokens += src.PromptTokens
  74. dst.GenerationTokens += src.GenerationTokens
  75. dst.TotalTokens += src.TotalTokens
  76. dst.InputCost += src.InputCost
  77. dst.OutputCost += src.OutputCost
  78. dst.cachedTokens += src.cachedTokens
  79. // Recompute blended rate from accumulated totals.
  80. if dst.TotalTokens > 0 {
  81. dst.CostPerMillionTokens = dst.TotalCost / dst.TotalTokens * 1_000_000
  82. } else {
  83. dst.CostPerMillionTokens = 0
  84. }
  85. if dst.PromptTokens > 0 {
  86. dst.InputCostPerMillionTokens = dst.InputCost / dst.PromptTokens * 1_000_000
  87. // Clamped to [0, 1]: see calculator.go for the full explanation of why
  88. // cachedTokens can exceed promptTokens in high-reuse workloads.
  89. dst.CacheSavingsFraction = min(dst.cachedTokens/dst.PromptTokens, 1.0)
  90. } else {
  91. dst.InputCostPerMillionTokens = 0
  92. dst.CacheSavingsFraction = 0
  93. }
  94. if dst.GenerationTokens > 0 {
  95. dst.OutputCostPerMillionTokens = dst.OutputCost / dst.GenerationTokens * 1_000_000
  96. } else {
  97. dst.OutputCostPerMillionTokens = 0
  98. }
  99. // Preserve the allocation method from the first entry; clear it when
  100. // methods differ (the merged entry reflects a mixed derivation).
  101. if dst.AllocationMethod != src.AllocationMethod {
  102. dst.AllocationMethod = ""
  103. }
  104. }
  105. // aggregate groups the InferenceCosts in s by the given dimensions, summing
  106. // all additive fields and recomputing derived rates. If aggregateBy is empty
  107. // the set is returned unchanged.
  108. //
  109. // Properties that are not part of the aggregation key are cleared on the
  110. // merged entry so the response accurately reflects what was grouped on.
  111. // For example, aggregating by "namespace" clears modelName so the result
  112. // doesn't show an arbitrary model name from whichever entry merged last.
  113. func (s *InferenceCostSet) aggregate(aggregateBy []string) error {
  114. if len(aggregateBy) == 0 {
  115. return nil
  116. }
  117. aggDims := make(map[string]bool, len(aggregateBy))
  118. for _, d := range aggregateBy {
  119. aggDims[d] = true
  120. }
  121. aggMap := make(map[string]*InferenceCostResponse, len(s.InferenceCosts))
  122. for _, ic := range s.InferenceCosts {
  123. key, err := aggKey(ic.Properties, aggregateBy)
  124. if err != nil {
  125. return err
  126. }
  127. if existing, ok := aggMap[key]; ok {
  128. addResponse(existing, ic)
  129. } else {
  130. // Clone so we don't mutate the original, then clear non-grouped
  131. // properties so the response reflects only the aggregation key.
  132. clone := *ic
  133. if !aggDims["model_name"] {
  134. clone.Properties.ModelName = ""
  135. clone.Properties.ModelVersion = ""
  136. }
  137. if !aggDims["namespace"] {
  138. clone.Properties.Namespace = ""
  139. }
  140. if !aggDims["cluster"] {
  141. clone.Properties.Cluster = ""
  142. }
  143. if !aggDims["pod"] {
  144. clone.Properties.Pod = ""
  145. }
  146. if !aggDims["controller"] {
  147. clone.Properties.Controller = ""
  148. clone.Properties.ControllerKind = ""
  149. }
  150. if !aggDims["controller_kind"] {
  151. clone.Properties.ControllerKind = ""
  152. }
  153. if !aggDims["container"] {
  154. clone.Properties.Container = ""
  155. }
  156. if !aggDims["workload_type"] {
  157. clone.Properties.WorkloadType = ""
  158. }
  159. aggMap[key] = &clone
  160. }
  161. }
  162. s.InferenceCosts = aggMap
  163. return nil
  164. }
  165. // accumulate collapses a slice of InferenceCostSets into a single set whose
  166. // Window spans all of them. It sums additive fields across windows and
  167. // recomputes derived rates. It is used to produce the flat total for the
  168. // /total endpoint.
  169. func accumulate(sets []*InferenceCostSet) *InferenceCostSet {
  170. if len(sets) == 0 {
  171. return newInferenceCostSet(opencost.Window{})
  172. }
  173. // Determine the combined window.
  174. first := sets[0]
  175. combined := opencost.NewClosedWindow(*first.Window.Start(), *first.Window.End())
  176. for _, s := range sets[1:] {
  177. if s.Window.Start() != nil && s.Window.Start().Before(*combined.Start()) {
  178. combined = opencost.NewClosedWindow(*s.Window.Start(), *combined.End())
  179. }
  180. if s.Window.End() != nil && s.Window.End().After(*combined.End()) {
  181. combined = opencost.NewClosedWindow(*combined.Start(), *s.Window.End())
  182. }
  183. }
  184. out := newInferenceCostSet(combined)
  185. for _, s := range sets {
  186. for key, ic := range s.InferenceCosts {
  187. if existing, ok := out.InferenceCosts[key]; ok {
  188. addResponse(existing, ic)
  189. } else {
  190. clone := *ic
  191. // Update the window to the combined range.
  192. clone.Window = combined
  193. out.InferenceCosts[key] = &clone
  194. }
  195. }
  196. }
  197. return out
  198. }
  199. // --- Phase-1 minimal filter ---
  200. // filterSpec holds a single parsed property:value constraint.
  201. type filterSpec struct {
  202. property string
  203. value string
  204. }
  205. // parseFilter parses a Phase-1 filter string of the form
  206. // prop:"value"[+prop:"value"]*
  207. // All terms are ANDed. Only dimensions in supportedAggregateProperties are
  208. // accepted. Values are unquoted if surrounded by double-quotes.
  209. //
  210. // This is intentionally minimal for Phase 1. Phase 2 can adopt the full
  211. // core/pkg/filter AST (with wildcard/OR support).
  212. func parseFilter(s string) ([]filterSpec, error) {
  213. if s == "" {
  214. return nil, nil
  215. }
  216. // "+" is the documented AND separator. HTTP clients that URL-encode query
  217. // strings may transmit "+" as "%2B" (preserved) or as a literal "+" which
  218. // some frameworks decode to a space before the handler sees it. Split on
  219. // both to handle either case.
  220. s = strings.ReplaceAll(s, " ", "+")
  221. terms := strings.Split(s, "+")
  222. specs := make([]filterSpec, 0, len(terms))
  223. for _, term := range terms {
  224. term = strings.TrimSpace(term)
  225. if term == "" {
  226. continue
  227. }
  228. idx := strings.IndexByte(term, ':')
  229. if idx < 0 {
  230. return nil, fmt.Errorf("invalid filter term %q: expected property:value", term)
  231. }
  232. prop := strings.TrimSpace(term[:idx])
  233. if !supportedAggregateProperties[prop] {
  234. return nil, fmt.Errorf("unsupported filter property %q: supported properties are model_name, model_version, namespace, cluster, pod, controller, controller_kind, container, workload_type", prop)
  235. }
  236. val := strings.TrimSpace(term[idx+1:])
  237. // Strip surrounding double-quotes.
  238. if len(val) >= 2 && val[0] == '"' && val[len(val)-1] == '"' {
  239. val = val[1 : len(val)-1]
  240. }
  241. specs = append(specs, filterSpec{property: prop, value: val})
  242. }
  243. return specs, nil
  244. }
  245. // matchesFilter reports whether an InferenceCostResponse satisfies all filter
  246. // specs (AND semantics).
  247. func matchesFilter(ic *InferenceCostResponse, specs []filterSpec) bool {
  248. for _, spec := range specs {
  249. var actual string
  250. switch spec.property {
  251. case "model_name":
  252. actual = ic.Properties.ModelName
  253. case "model_version":
  254. actual = ic.Properties.ModelVersion
  255. case "namespace":
  256. actual = ic.Properties.Namespace
  257. case "cluster":
  258. actual = ic.Properties.Cluster
  259. case "pod":
  260. actual = ic.Properties.Pod
  261. case "controller":
  262. actual = ic.Properties.Controller
  263. case "controller_kind":
  264. actual = ic.Properties.ControllerKind
  265. case "container":
  266. actual = ic.Properties.Container
  267. case "workload_type":
  268. actual = ic.Properties.WorkloadType
  269. }
  270. if actual != spec.value {
  271. return false
  272. }
  273. }
  274. return true
  275. }