collector.go 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606
  1. package inferencecost
  2. import (
  3. "context"
  4. "fmt"
  5. "strings"
  6. "time"
  7. "github.com/opencost/opencost/core/pkg/filter/allocation"
  8. "github.com/opencost/opencost/core/pkg/filter/ops"
  9. "github.com/opencost/opencost/core/pkg/log"
  10. "github.com/opencost/opencost/core/pkg/opencost"
  11. "github.com/opencost/opencost/core/pkg/source"
  12. )
  13. // AllocationQuerier is the subset of the cost model needed to fetch per-model
  14. // infrastructure costs. Abstracted as an interface for testability.
  15. type AllocationQuerier interface {
  16. // ComputeAllocation returns an AllocationSet for the given time window.
  17. ComputeAllocation(start, end time.Time) (*opencost.AllocationSet, error)
  18. }
  19. // Collector gathers per-model infrastructure costs from the OpenCost allocation
  20. // layer and token/timing/cache metrics from the data source.
  21. type Collector struct {
  22. allocationQuerier AllocationQuerier
  23. metricsQuerier source.MetricsQuerier
  24. config *Config
  25. }
  26. // NewCollector creates a Collector that uses the provided MetricsQuerier for
  27. // inference metrics.
  28. func NewCollector(config *Config, querier AllocationQuerier, metricsQuerier source.MetricsQuerier) (*Collector, error) {
  29. return &Collector{
  30. allocationQuerier: querier,
  31. metricsQuerier: metricsQuerier,
  32. config: config,
  33. }, nil
  34. }
  35. // CollectMetrics queries all data sources and returns one InferenceCost per
  36. // model/namespace combination. start and end define the time window to query;
  37. // the caller is responsible for choosing appropriate boundaries (e.g. the
  38. // runner uses now-interval..now; the API uses the request window).
  39. func (c *Collector) CollectMetrics(ctx context.Context, start, end time.Time) ([]*InferenceCost, error) {
  40. // --- Infrastructure costs from OpenCost allocation layer ---
  41. allocationCosts, err := c.queryAllocationCosts(ctx, start, end)
  42. if err != nil {
  43. return nil, fmt.Errorf("failed to query allocation costs: %w", err)
  44. }
  45. log.Infof("InferenceCost: collected allocation costs for %d model/namespace combinations", len(allocationCosts))
  46. // --- Token metrics from data source ---
  47. // Query all metrics concurrently using Futures
  48. promptTokensFuture := c.metricsQuerier.QueryInferencePromptTokens(start, end)
  49. generationTokensFuture := c.metricsQuerier.QueryInferenceGenerationTokens(start, end)
  50. inputTimeFuture := c.metricsQuerier.QueryInferenceInputProcessingTime(start, end)
  51. outputTimeFuture := c.metricsQuerier.QueryInferenceOutputProcessingTime(start, end)
  52. cachedTokensFuture := c.metricsQuerier.QueryInferenceCachedTokens(start, end)
  53. cacheConfigFuture := c.metricsQuerier.QueryInferenceCacheConfig(end)
  54. // Await required metrics (prompt and generation tokens)
  55. promptTokensResults, err := promptTokensFuture.Await()
  56. if err != nil {
  57. return nil, fmt.Errorf("failed to query prompt tokens: %w", err)
  58. }
  59. promptTokens := mergeTokenResults(promptTokensResults)
  60. generationTokensResults, err := generationTokensFuture.Await()
  61. if err != nil {
  62. return nil, fmt.Errorf("failed to query generation tokens: %w", err)
  63. }
  64. generationTokens := mergeTokenResults(generationTokensResults)
  65. // --- Timing metrics (optional — degrade gracefully) ---
  66. inputProcessingTime := make(map[string]float64)
  67. if inputTimeResults, err := inputTimeFuture.Await(); err != nil {
  68. log.Warnf("InferenceCost: failed to query input processing time (will use multiplier fallback): %v", err)
  69. } else {
  70. inputProcessingTime = mergeProcessingTimeResults(inputTimeResults)
  71. }
  72. outputProcessingTime := make(map[string]float64)
  73. if outputTimeResults, err := outputTimeFuture.Await(); err != nil {
  74. log.Warnf("InferenceCost: failed to query output processing time (will use multiplier fallback): %v", err)
  75. } else {
  76. outputProcessingTime = mergeProcessingTimeResults(outputTimeResults)
  77. }
  78. // --- KV cache hits (optional — degrade gracefully) ---
  79. cachedTokens := make(map[string]float64)
  80. if cachedTokensResults, err := cachedTokensFuture.Await(); err != nil {
  81. log.Warnf("InferenceCost: failed to query KV cache hits (cacheSavingsFraction will be zero): %v", err)
  82. } else {
  83. cachedTokens = mergeTokenResults(cachedTokensResults)
  84. }
  85. // --- KV cache config (prefix caching enabled flag only) ---
  86. cacheConfigs := make(map[string]*cacheConfig)
  87. if cacheConfigResults, err := cacheConfigFuture.Await(); err != nil {
  88. log.Warnf("InferenceCost: failed to query cache config (prefix_caching_off detection disabled): %v", err)
  89. } else {
  90. cacheConfigs = mergeCacheConfigResults(cacheConfigResults)
  91. }
  92. return c.combineMetrics(allocationCosts, promptTokens, generationTokens,
  93. inputProcessingTime, outputProcessingTime, cachedTokens, cacheConfigs, start, end), nil
  94. }
  95. // cacheConfig holds per-model KV cache configuration from vllm:cache_config_info.
  96. type cacheConfig struct {
  97. prefixCachingEnabled bool
  98. }
  99. // allocationResult holds the two cost figures derived from one Allocation.
  100. type allocationResult struct {
  101. allocationTotalCost float64
  102. usageTotalCost float64
  103. namespace string
  104. cluster string
  105. pod string
  106. controller string
  107. controllerKind string
  108. container string
  109. }
  110. // queryAllocationCosts calls the OpenCost allocation layer twice:
  111. // once with idle sharing (for allocation costs) and once without (for usage costs).
  112. // This ensures allocation costs reconcile to the bill while usage costs reflect
  113. // only active compute without idle or waste.
  114. // This approach was chosen rather than doing a single call and deducting idle and optionally shared, so that
  115. // core logic is not duplicated. A performance penalty is paid though.
  116. func (c *Collector) queryAllocationCosts(ctx context.Context, start, end time.Time) (map[string]*allocationResult, error) {
  117. // Query 1: Allocation costs with idle sharing (reconciles to bill)
  118. allocationCosts, err := c.queryAllocationCostsWithIdle(ctx, start, end)
  119. if err != nil {
  120. return nil, fmt.Errorf("failed to query allocation costs with idle: %w", err)
  121. }
  122. // Query 2: Usage costs without idle sharing (active compute only)
  123. usageCosts, err := c.queryAllocationCostsWithoutIdle(ctx, start, end)
  124. if err != nil {
  125. return nil, fmt.Errorf("failed to query usage costs without idle: %w", err)
  126. }
  127. // Merge results: allocation costs from first query, usage costs from second
  128. results := make(map[string]*allocationResult)
  129. for key, allocResult := range allocationCosts {
  130. if allocResult == nil {
  131. continue
  132. }
  133. // Copy the full allocationResult so we retain pod/controller/container metadata
  134. copied := *allocResult
  135. copied.usageTotalCost = 0 // Will be filled from usageCosts
  136. results[key] = &copied
  137. }
  138. // Fill in usage costs from the second query
  139. for key, usageResult := range usageCosts {
  140. if result, exists := results[key]; exists {
  141. result.usageTotalCost = usageResult.usageTotalCost
  142. } else {
  143. // Model exists in usage query but not allocation query (shouldn't happen)
  144. log.Warnf("InferenceCost: model %s has usage cost but no allocation cost", key)
  145. results[key] = usageResult
  146. }
  147. }
  148. // Log the differences
  149. for key, result := range results {
  150. modelName, namespace := parseKey(key)
  151. if result.allocationTotalCost > 0 {
  152. log.Debugf("InferenceCost: model=%s ns=%s alloc=$%.4f usage=$%.4f (%.1f%% of alloc)",
  153. modelName, namespace, result.allocationTotalCost, result.usageTotalCost,
  154. (result.usageTotalCost/result.allocationTotalCost)*100)
  155. }
  156. }
  157. return results, nil
  158. }
  159. // queryAllocationCostsWithIdle queries allocations with idle sharing enabled.
  160. func (c *Collector) queryAllocationCostsWithIdle(ctx context.Context, start, end time.Time) (map[string]*allocationResult, error) {
  161. as, err := c.allocationQuerier.ComputeAllocation(start, end)
  162. if err != nil {
  163. return nil, err
  164. }
  165. // Create a filter to match shared infrastructure allocations by label
  166. // This ensures allocations with the shared infra label are moved to shareSet
  167. // and distributed among other allocations, rather than aggregating into __unallocated__
  168. shareFilter := ops.Eq(
  169. ops.WithKey(allocation.FieldLabel, c.config.SharedInfraLabel),
  170. c.config.SharedInfraLabelValue,
  171. )
  172. opts := &opencost.AllocationAggregationOptions{
  173. ShareIdle: opencost.ShareWeighted,
  174. ShareSplit: opencost.ShareWeighted,
  175. Share: shareFilter,
  176. SharedLabels: map[string][]string{c.config.SharedInfraLabel: {c.config.SharedInfraLabelValue}},
  177. }
  178. aggregateBy := []string{"label:" + c.config.ModelLabel}
  179. if err := as.AggregateBy(aggregateBy, opts); err != nil {
  180. return nil, fmt.Errorf("AggregateBy label:%s: %w", c.config.ModelLabel, err)
  181. }
  182. return c.extractAllocationResults(as, true)
  183. }
  184. // queryAllocationCostsWithoutIdle queries allocations without idle or shared
  185. // infrastructure cost sharing. Usage costs reflect active compute only.
  186. func (c *Collector) queryAllocationCostsWithoutIdle(ctx context.Context, start, end time.Time) (map[string]*allocationResult, error) {
  187. as, err := c.allocationQuerier.ComputeAllocation(start, end)
  188. if err != nil {
  189. return nil, err
  190. }
  191. // Create a filter to match shared infrastructure allocations by label
  192. // Even though we're not sharing costs (ShareSplit: ShareNone), we still need
  193. // the Share filter to identify and separate shared infra allocations from
  194. // regular allocations, preventing them from aggregating into __unallocated__
  195. shareFilter := ops.Eq(
  196. ops.WithKey(allocation.FieldLabel, c.config.SharedInfraLabel),
  197. c.config.SharedInfraLabelValue,
  198. )
  199. opts := &opencost.AllocationAggregationOptions{
  200. ShareIdle: opencost.ShareNone,
  201. ShareSplit: opencost.ShareNone,
  202. Share: shareFilter,
  203. SharedLabels: map[string][]string{c.config.SharedInfraLabel: {c.config.SharedInfraLabelValue}},
  204. }
  205. aggregateBy := []string{"label:" + c.config.ModelLabel}
  206. if err := as.AggregateBy(aggregateBy, opts); err != nil {
  207. return nil, fmt.Errorf("AggregateBy label:%s: %w", c.config.ModelLabel, err)
  208. }
  209. return c.extractAllocationResults(as, false)
  210. }
  211. // extractAllocationResults extracts cost data from an AllocationSet.
  212. func (c *Collector) extractAllocationResults(as *opencost.AllocationSet, isAllocationCost bool) (map[string]*allocationResult, error) {
  213. results := make(map[string]*allocationResult)
  214. for name, alloc := range as.Allocations {
  215. if alloc == nil {
  216. continue
  217. }
  218. // Skip the synthetic __idle__ and __unallocated__ entries.
  219. if strings.HasPrefix(name, "__") {
  220. continue
  221. }
  222. modelName := extractModelName(alloc, c.config.ModelLabel)
  223. if modelName == "" {
  224. continue
  225. }
  226. namespace := ""
  227. cluster := ""
  228. pod := ""
  229. controller := ""
  230. controllerKind := ""
  231. container := ""
  232. if alloc.Properties != nil {
  233. namespace = alloc.Properties.Namespace
  234. cluster = alloc.Properties.Cluster
  235. pod = alloc.Properties.Pod
  236. controller = alloc.Properties.Controller
  237. controllerKind = alloc.Properties.ControllerKind
  238. container = alloc.Properties.Container
  239. }
  240. key := modelNamespaceKey(modelName, namespace)
  241. // Accumulate costs for the same model/namespace key
  242. existing, exists := results[key]
  243. if !exists {
  244. existing = &allocationResult{
  245. namespace: namespace,
  246. cluster: cluster,
  247. pod: pod,
  248. controller: controller,
  249. controllerKind: controllerKind,
  250. container: container,
  251. }
  252. results[key] = existing
  253. }
  254. if isAllocationCost {
  255. // For allocation cost: use TotalCost() which includes idle and shared
  256. existing.allocationTotalCost += alloc.TotalCost()
  257. } else {
  258. // For usage cost: use TotalCost() from the ShareNone query (no idle)
  259. existing.usageTotalCost += alloc.TotalCost()
  260. }
  261. // When aggregating multiple allocations, preserve the first non-empty values
  262. // for pod, controller, and container. This provides representative values
  263. // when costs are aggregated across multiple pods/containers.
  264. if existing.pod == "" && pod != "" {
  265. existing.pod = pod
  266. }
  267. if existing.controller == "" && controller != "" {
  268. existing.controller = controller
  269. }
  270. if existing.controllerKind == "" && controllerKind != "" {
  271. existing.controllerKind = controllerKind
  272. }
  273. if existing.container == "" && container != "" {
  274. existing.container = container
  275. }
  276. }
  277. return results, nil
  278. }
  279. // extractModelName extracts the model name from the allocation name or label.
  280. // After AggregateBy("label:<key>"), the allocation Name is the label value.
  281. func extractModelName(alloc *opencost.Allocation, _ string) string {
  282. if alloc == nil {
  283. return ""
  284. }
  285. // AggregateBy sets the Name to the label value.
  286. return alloc.Name
  287. }
  288. // canonicalModelName normalizes a model name by stripping any org/vendor prefix
  289. // before the last "/".
  290. // Examples:
  291. // - "MiniMaxAI/MiniMax-M2.7" -> "MiniMax-M2.7"
  292. // - "google/gemma-4-31B" -> "gemma-4-31B"
  293. func canonicalModelName(modelName string) string {
  294. if idx := strings.LastIndex(modelName, "/"); idx >= 0 {
  295. return modelName[idx+1:]
  296. }
  297. return modelName
  298. }
  299. // reconcileTokenKeys re-keys entries only when there is a confirmed mismatch
  300. // between the metric key and the allocation-backed model key for the same
  301. // namespace.
  302. //
  303. // Two common mismatch examples:
  304. // 1. Fully-qualified vLLM model name vs short allocation label:
  305. // "google/gemma-4-31B:llm-d-pic" -> "gemma-4-31B:llm-d-pic"
  306. // 2. Fully-qualified vLLM model name vs short allocation label with a
  307. // different vendor/org prefix:
  308. // "MiniMaxAI/MiniMax-M2.7:llm-d-pic" -> "MiniMax-M2.7:llm-d-pic"
  309. //
  310. // Exact matches are preserved. Keys with no matching allocation-backed target
  311. // are also preserved unchanged. A warning is logged for every remapped key so
  312. // the mismatch is auditable.
  313. //
  314. // Returns both the reconciled map and a set of keys that were remapped (to be
  315. // excluded later).
  316. func reconcileTokenKeys(tokens map[string]float64, allocCosts map[string]*allocationResult) (map[string]float64, map[string]struct{}) {
  317. // Build index: normalizedShortName:namespace -> allocKey, preferring
  318. // allocation keys that are already in short-name form.
  319. shortIndex := make(map[string]string, len(allocCosts))
  320. for allocKey := range allocCosts {
  321. modelName, namespace := parseKey(allocKey)
  322. shortName := canonicalModelName(modelName)
  323. shortKey := modelNamespaceKey(shortName, namespace)
  324. if existing, found := shortIndex[shortKey]; found {
  325. existingModelName, _ := parseKey(existing)
  326. if existingModelName == shortName {
  327. continue
  328. }
  329. }
  330. shortIndex[shortKey] = allocKey
  331. }
  332. out := make(map[string]float64, len(tokens))
  333. remappedKeys := make(map[string]struct{})
  334. for k, v := range tokens {
  335. modelName, namespace := parseKey(k)
  336. shortName := canonicalModelName(modelName)
  337. shortKey := modelNamespaceKey(shortName, namespace)
  338. if allocKey, found := shortIndex[shortKey]; found {
  339. if k != allocKey {
  340. log.Warnf("InferenceCost: remapping metric key %q → %q (model-name mismatch with allocation label)", k, allocKey)
  341. out[allocKey] += v
  342. remappedKeys[k] = struct{}{}
  343. continue
  344. }
  345. }
  346. out[k] = v
  347. }
  348. return out, remappedKeys
  349. }
  350. // reconcileCacheConfigKeys re-keys a cacheConfig map the same way reconcileTokenKeys
  351. // does for float64 maps — handling fully-qualified vs short model name mismatches.
  352. func reconcileCacheConfigKeys(configs map[string]*cacheConfig, allocCosts map[string]*allocationResult) (map[string]*cacheConfig, map[string]struct{}) {
  353. shortIndex := make(map[string]string, len(allocCosts))
  354. for allocKey := range allocCosts {
  355. modelName, namespace := parseKey(allocKey)
  356. shortName := canonicalModelName(modelName)
  357. shortKey := modelNamespaceKey(shortName, namespace)
  358. if _, exists := shortIndex[shortKey]; !exists {
  359. shortIndex[shortKey] = allocKey
  360. }
  361. }
  362. out := make(map[string]*cacheConfig, len(configs))
  363. remappedKeys := make(map[string]struct{})
  364. for k, v := range configs {
  365. modelName, namespace := parseKey(k)
  366. shortName := canonicalModelName(modelName)
  367. shortKey := modelNamespaceKey(shortName, namespace)
  368. if allocKey, found := shortIndex[shortKey]; found {
  369. if k != allocKey {
  370. log.Warnf("InferenceCost: remapping cache config key %q → %q (model-name mismatch with allocation label)", k, allocKey)
  371. out[allocKey] = v
  372. remappedKeys[k] = struct{}{}
  373. continue
  374. }
  375. }
  376. out[k] = v
  377. }
  378. return out, remappedKeys
  379. }
  380. // combineMetrics joins all data sources into InferenceCost structs.
  381. func (c *Collector) combineMetrics(
  382. allocCosts map[string]*allocationResult,
  383. promptTokens, generationTokens,
  384. inputProcessingTime, outputProcessingTime,
  385. cachedTokens map[string]float64,
  386. cacheConfigs map[string]*cacheConfig,
  387. start, end time.Time,
  388. ) []*InferenceCost {
  389. // Reconcile token map keys against allocation keys to handle the case where
  390. // vLLM reports a fully-qualified model name (e.g. "org/model") but the K8s
  391. // pod label uses only the short name ("model"). Re-keying fires only when a
  392. // mismatch is detected; keys that already match are left unchanged.
  393. // Track which keys were remapped so we can exclude them from final results.
  394. var remappedKeys map[string]struct{}
  395. promptTokens, remappedKeys = reconcileTokenKeys(promptTokens, allocCosts)
  396. var remapped map[string]struct{}
  397. generationTokens, remapped = reconcileTokenKeys(generationTokens, allocCosts)
  398. for k := range remapped {
  399. remappedKeys[k] = struct{}{}
  400. }
  401. inputProcessingTime, remapped = reconcileTokenKeys(inputProcessingTime, allocCosts)
  402. for k := range remapped {
  403. remappedKeys[k] = struct{}{}
  404. }
  405. outputProcessingTime, remapped = reconcileTokenKeys(outputProcessingTime, allocCosts)
  406. for k := range remapped {
  407. remappedKeys[k] = struct{}{}
  408. }
  409. cachedTokens, remapped = reconcileTokenKeys(cachedTokens, allocCosts)
  410. for k := range remapped {
  411. remappedKeys[k] = struct{}{}
  412. }
  413. cacheConfigs, remapped = reconcileCacheConfigKeys(cacheConfigs, allocCosts)
  414. for k := range remapped {
  415. remappedKeys[k] = struct{}{}
  416. }
  417. // Union of all keys across sources.
  418. // Include timing/cache maps as well so models that only appear in those
  419. // sources are not dropped before cost calculation.
  420. keys := make(map[string]struct{})
  421. for k := range allocCosts {
  422. keys[k] = struct{}{}
  423. }
  424. for k := range promptTokens {
  425. keys[k] = struct{}{}
  426. }
  427. for k := range generationTokens {
  428. keys[k] = struct{}{}
  429. }
  430. for k := range inputProcessingTime {
  431. keys[k] = struct{}{}
  432. }
  433. for k := range outputProcessingTime {
  434. keys[k] = struct{}{}
  435. }
  436. for k := range cachedTokens {
  437. keys[k] = struct{}{}
  438. }
  439. for k := range cacheConfigs {
  440. keys[k] = struct{}{}
  441. }
  442. results := make([]*InferenceCost, 0, len(keys))
  443. for key := range keys {
  444. // Skip keys that were remapped to avoid duplicate series
  445. if _, wasRemapped := remappedKeys[key]; wasRemapped {
  446. continue
  447. }
  448. modelName, namespace := parseKey(key)
  449. cfg := cacheConfigs[key]
  450. var prefixCachingEnabled, cacheConfigKnown bool
  451. if cfg != nil {
  452. prefixCachingEnabled = cfg.prefixCachingEnabled
  453. cacheConfigKnown = true
  454. }
  455. ic := &InferenceCost{
  456. Properties: InferenceCostProperties{
  457. ModelName: modelName,
  458. Namespace: namespace,
  459. WorkloadType: "inference",
  460. },
  461. PromptTokens: promptTokens[key],
  462. GenerationTokens: generationTokens[key],
  463. InputProcessingTime: inputProcessingTime[key],
  464. OutputProcessingTime: outputProcessingTime[key],
  465. CachedTokens: cachedTokens[key],
  466. PrefixCachingEnabled: prefixCachingEnabled,
  467. CacheConfigKnown: cacheConfigKnown,
  468. Timestamp: end,
  469. }
  470. ic.Window.Start = start
  471. ic.Window.End = end
  472. if ar, ok := allocCosts[key]; ok {
  473. ic.AllocationTotalCost = ar.allocationTotalCost
  474. ic.UsageTotalCost = ar.usageTotalCost
  475. ic.Properties.Cluster = ar.cluster
  476. ic.Properties.Pod = ar.pod
  477. ic.Properties.Controller = ar.controller
  478. ic.Properties.ControllerKind = ar.controllerKind
  479. ic.Properties.Container = ar.container
  480. if namespace == "" {
  481. ic.Properties.Namespace = ar.namespace
  482. }
  483. }
  484. ic.TotalTokens = ic.PromptTokens + ic.GenerationTokens
  485. ic.EffectiveInputTokens = ic.PromptTokens - ic.CachedTokens
  486. if ic.EffectiveInputTokens < 0 {
  487. ic.EffectiveInputTokens = 0
  488. }
  489. results = append(results, ic)
  490. }
  491. return results
  492. }
  493. func modelNamespaceKey(modelName, namespace string) string {
  494. return modelName + ":" + namespace
  495. }
  496. func parseKey(key string) (modelName, namespace string) {
  497. idx := strings.IndexByte(key, ':')
  498. if idx < 0 {
  499. return key, "unknown"
  500. }
  501. return key[:idx], key[idx+1:]
  502. }
  503. // mergeTokenResults merges multiple InferenceTokensResult into a single map
  504. func mergeTokenResults(results []*source.InferenceTokensResult) map[string]float64 {
  505. merged := make(map[string]float64)
  506. for _, result := range results {
  507. for k, v := range result.Values {
  508. merged[k] = v
  509. }
  510. }
  511. return merged
  512. }
  513. // mergeProcessingTimeResults merges multiple InferenceProcessingTimeResult into a single map
  514. func mergeProcessingTimeResults(results []*source.InferenceProcessingTimeResult) map[string]float64 {
  515. merged := make(map[string]float64)
  516. for _, result := range results {
  517. for k, v := range result.Values {
  518. merged[k] = v
  519. }
  520. }
  521. return merged
  522. }
  523. // mergeCacheConfigResults merges multiple InferenceCacheConfigResult into a single map
  524. func mergeCacheConfigResults(results []*source.InferenceCacheConfigResult) map[string]*cacheConfig {
  525. merged := make(map[string]*cacheConfig)
  526. for _, result := range results {
  527. for k, v := range result.Configs {
  528. merged[k] = &cacheConfig{prefixCachingEnabled: v.PrefixCachingEnabled}
  529. }
  530. }
  531. return merged
  532. }