|
| 1 | +/* |
| 2 | +Copyright 2026 The llm-d Authors. |
| 3 | +
|
| 4 | +Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | +you may not use this file except in compliance with the License. |
| 6 | +You may obtain a copy of the License at |
| 7 | +
|
| 8 | + http://www.apache.org/licenses/LICENSE-2.0 |
| 9 | +
|
| 10 | +Unless required by applicable law or agreed to in writing, software |
| 11 | +distributed under the License is distributed on an "AS IS" BASIS, |
| 12 | +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 13 | +See the License for the specific language governing permissions and |
| 14 | +limitations under the License. |
| 15 | +*/ |
| 16 | + |
| 17 | +// Package requestcostmetadata implements a datasource extractor that |
| 18 | +// turns each completed inference response into a per-request cost sample |
| 19 | +// and folds it into a per-model t-digest stored on the Model's |
| 20 | +// AttributeMap under pricing.CostDigestAttributeKey. It is a building |
| 21 | +// block for the CostGuard scorer; see the package README for behavioral |
| 22 | +// intent and configuration. |
| 23 | +package requestcostmetadata |
| 24 | + |
| 25 | +import ( |
| 26 | + "context" |
| 27 | + "encoding/json" |
| 28 | + "fmt" |
| 29 | + "time" |
| 30 | + |
| 31 | + "github.com/caio/go-tdigest/v5" |
| 32 | + "sigs.k8s.io/controller-runtime/pkg/log" |
| 33 | + |
| 34 | + logutil "github.com/llm-d/llm-d-inference-payload-processor/pkg/common/observability/logging" |
| 35 | + "github.com/llm-d/llm-d-inference-payload-processor/pkg/framework/interface/datalayer" |
| 36 | + dlsrc "github.com/llm-d/llm-d-inference-payload-processor/pkg/framework/interface/datalayer/datasource" |
| 37 | + "github.com/llm-d/llm-d-inference-payload-processor/pkg/framework/interface/datalayer/pricing" |
| 38 | + "github.com/llm-d/llm-d-inference-payload-processor/pkg/framework/interface/plugin" |
| 39 | +) |
| 40 | + |
| 41 | +const ( |
| 42 | + // PluginType is the identifier used when registering this extractor. |
| 43 | + PluginType = "request-cost-metadata-extractor" |
| 44 | + |
| 45 | + // defaultCompression matches the t-digest compression value used in the |
| 46 | + // CostGuard proposal (docs/proposals/050-costguard-scorer/README.md). |
| 47 | + defaultCompression = 200.0 |
| 48 | + |
| 49 | + // defaultFlushIntervalDuration is the aggregation window before a per-model |
| 50 | + // digest snapshot is published to the AttributeMap. Mirrors the pattern in |
| 51 | + // the requestmetadata extractor. |
| 52 | + defaultFlushIntervalDuration = 5 * time.Second |
| 53 | +) |
| 54 | + |
| 55 | +// compile-time interface assertion |
| 56 | +var _ dlsrc.Extractor = &RequestCostMetadataExtractor{} |
| 57 | + |
| 58 | +// RequestCostMetadataExtractorConfig holds the JSON-configurable parameters |
| 59 | +// for the extractor. |
| 60 | +type RequestCostMetadataExtractorConfig struct { |
| 61 | + // Compression is the t-digest compression. Higher values trade memory |
| 62 | + // for quantile accuracy. Must be > 0. Defaults to 200 if not specified. |
| 63 | + Compression float64 `json:"compression,omitempty"` |
| 64 | + |
| 65 | + // FlushIntervalDuration is the aggregation window before a per-model digest |
| 66 | + // snapshot is published to the AttributeMap (e.g. "5s", "1m"). Set to "0s" |
| 67 | + // to publish on every event (used in unit tests). Defaults to "5s". |
| 68 | + FlushIntervalDuration string `json:"flushIntervalDuration,omitempty"` |
| 69 | +} |
| 70 | + |
| 71 | +// ExtractorFactory creates a RequestCostMetadataExtractor wired to the shared |
| 72 | +// Datastore. |
| 73 | +func ExtractorFactory(name string, parameters json.RawMessage, h plugin.Handle) (plugin.Plugin, error) { |
| 74 | + config := RequestCostMetadataExtractorConfig{ |
| 75 | + Compression: defaultCompression, |
| 76 | + FlushIntervalDuration: defaultFlushIntervalDuration.String(), |
| 77 | + } |
| 78 | + if len(parameters) > 0 { |
| 79 | + if err := json.Unmarshal(parameters, &config); err != nil { |
| 80 | + return nil, fmt.Errorf("failed to parse parameters for plugin %q: %w", name, err) |
| 81 | + } |
| 82 | + } |
| 83 | + |
| 84 | + if config.Compression <= 0 { |
| 85 | + return nil, fmt.Errorf("invalid compression %v for plugin %q: must be > 0", config.Compression, name) |
| 86 | + } |
| 87 | + |
| 88 | + flushInterval, err := time.ParseDuration(config.FlushIntervalDuration) |
| 89 | + if err != nil { |
| 90 | + return nil, fmt.Errorf("invalid flushIntervalDuration %q for plugin %q: %w", config.FlushIntervalDuration, name, err) |
| 91 | + } |
| 92 | + if flushInterval < 0 { |
| 93 | + return nil, fmt.Errorf("invalid flushIntervalDuration %q for plugin %q: must be >= 0", config.FlushIntervalDuration, name) |
| 94 | + } |
| 95 | + |
| 96 | + return NewRequestCostMetadataExtractor(h.Datastore()). |
| 97 | + WithName(name). |
| 98 | + WithCompression(config.Compression). |
| 99 | + WithFlushInterval(flushInterval), nil |
| 100 | +} |
| 101 | + |
| 102 | +// modelCostAccumulator holds the running t-digest for a single model and the |
| 103 | +// timestamp of its last flush, so the extractor can decide when to publish a |
| 104 | +// snapshot to the AttributeMap. |
| 105 | +type modelCostAccumulator struct { |
| 106 | + digest *tdigest.TDigest |
| 107 | + lastFlush time.Time |
| 108 | +} |
| 109 | + |
| 110 | +// TODO: in a separate PR, add a request-handling plugin that sets |
| 111 | +// stream_options.include_usage=true on the request body so that streamed |
| 112 | +// responses always carry the usage block this extractor consumes. |
| 113 | + |
| 114 | +// RequestCostMetadataExtractor accumulates per-model cost samples derived from |
| 115 | +// response usage metadata and pricing attributes, and publishes a t-digest |
| 116 | +// snapshot to the Model's AttributeMap on flush. |
| 117 | +// |
| 118 | +// Extract is assumed to be called from a single goroutine (the |
| 119 | +// NotificationSource event loop). |
| 120 | +// Note: If parallel dispatch is introduced, add a |
| 121 | +// sync.Mutex around state and the Datastore writes. |
| 122 | +type RequestCostMetadataExtractor struct { |
| 123 | + typedName plugin.TypedName |
| 124 | + ds datalayer.Datastore |
| 125 | + state map[string]*modelCostAccumulator |
| 126 | + compression float64 |
| 127 | + flushInterval time.Duration |
| 128 | +} |
| 129 | + |
| 130 | +// NewRequestCostMetadataExtractor constructs an extractor wired to ds with |
| 131 | +// proposal-default compression and flush interval. |
| 132 | +func NewRequestCostMetadataExtractor(ds datalayer.Datastore) *RequestCostMetadataExtractor { |
| 133 | + return &RequestCostMetadataExtractor{ |
| 134 | + typedName: plugin.TypedName{Type: PluginType, Name: PluginType}, |
| 135 | + ds: ds, |
| 136 | + state: make(map[string]*modelCostAccumulator), |
| 137 | + compression: defaultCompression, |
| 138 | + flushInterval: defaultFlushIntervalDuration, |
| 139 | + } |
| 140 | +} |
| 141 | + |
| 142 | +func (e *RequestCostMetadataExtractor) TypedName() plugin.TypedName { return e.typedName } |
| 143 | + |
| 144 | +// WithName sets the instance name, used by the factory when the plugin is |
| 145 | +// configured by name. |
| 146 | +func (e *RequestCostMetadataExtractor) WithName(name string) *RequestCostMetadataExtractor { |
| 147 | + e.typedName.Name = name |
| 148 | + return e |
| 149 | +} |
| 150 | + |
| 151 | +// WithCompression overrides the t-digest compression used for newly created |
| 152 | +// per-model digests. |
| 153 | +func (e *RequestCostMetadataExtractor) WithCompression(c float64) *RequestCostMetadataExtractor { |
| 154 | + e.compression = c |
| 155 | + return e |
| 156 | +} |
| 157 | + |
| 158 | +// WithFlushInterval overrides the publish interval. Pass 0 to flush after |
| 159 | +// every event (used in unit tests). |
| 160 | +func (e *RequestCostMetadataExtractor) WithFlushInterval(d time.Duration) *RequestCostMetadataExtractor { |
| 161 | + e.flushInterval = d |
| 162 | + return e |
| 163 | +} |
| 164 | + |
| 165 | +// Extract processes a batch of events. RequestEventType events are ignored; |
| 166 | +// each ResponseEventType produces (at most) one cost sample, which is added |
| 167 | +// to that model's running t-digest. Per-model snapshots are published to the |
| 168 | +// AttributeMap when the flush interval has elapsed since the last publish. |
| 169 | +func (e *RequestCostMetadataExtractor) Extract(ctx context.Context, events []dlsrc.Event) error { |
| 170 | + debugLogger := log.FromContext(ctx).V(logutil.DEBUG) |
| 171 | + debugLogger.Info("request-cost-metadata extractor invoked", "num_events", len(events)) |
| 172 | + |
| 173 | + now := time.Now() |
| 174 | + updated := map[string]bool{} |
| 175 | + |
| 176 | + for _, ev := range events { |
| 177 | + if ev.Type != dlsrc.ResponseEventType { |
| 178 | + continue |
| 179 | + } |
| 180 | + p, ok := ev.Payload.(dlsrc.ResponsePayload) |
| 181 | + if !ok { |
| 182 | + continue |
| 183 | + } |
| 184 | + // Distinguish "model field absent" from "model field present but |
| 185 | + // not a string" so a malformed upstream is visible in debug logs |
| 186 | + // rather than indistinguishable from a request with no model. |
| 187 | + rawModel, hasModel := p.Request.Body["model"] |
| 188 | + if !hasModel { |
| 189 | + continue |
| 190 | + } |
| 191 | + model, isString := rawModel.(string) |
| 192 | + if !isString { |
| 193 | + debugLogger.Info("response request body has non-string model field, skipping", "model_type", fmt.Sprintf("%T", rawModel)) |
| 194 | + continue |
| 195 | + } |
| 196 | + if model == "" { |
| 197 | + continue |
| 198 | + } |
| 199 | + |
| 200 | + promptTokens, completionTokens, ok := extractTokenCounts(p) |
| 201 | + if !ok { |
| 202 | + debugLogger.Info("response missing usable usage fields, skipping", "model", model) |
| 203 | + continue |
| 204 | + } |
| 205 | + |
| 206 | + tp, ok := lookupTokenPrices(e.ds, model) |
| 207 | + if !ok { |
| 208 | + debugLogger.Info("model has no TokenPrices attribute, skipping cost sample", "model", model) |
| 209 | + continue |
| 210 | + } |
| 211 | + |
| 212 | + cost := promptTokens*tp.InputTokenPrice + completionTokens*tp.OutputTokenPrice |
| 213 | + |
| 214 | + acc, err := e.getOrCreateAccumulator(model, now) |
| 215 | + if err != nil { |
| 216 | + debugLogger.Info("failed to create tdigest accumulator, skipping sample", "model", model, "err", err) |
| 217 | + continue |
| 218 | + } |
| 219 | + if err := acc.digest.Add(cost); err != nil { |
| 220 | + debugLogger.Info("tdigest.Add returned an unexpected error, skipping sample", "model", model, "err", err) |
| 221 | + continue |
| 222 | + } |
| 223 | + updated[model] = true |
| 224 | + } |
| 225 | + |
| 226 | + // updated contains exactly the models that received a fresh sample in |
| 227 | + // this batch, so the flushInterval gate below only consults |
| 228 | + // tdigest accumulators whose digest actually changed since the last publish. |
| 229 | + for model := range updated { |
| 230 | + acc := e.state[model] |
| 231 | + // flushInterval == 0 means publish on every event |
| 232 | + if e.flushInterval > 0 && now.Sub(acc.lastFlush) < e.flushInterval { |
| 233 | + continue |
| 234 | + } |
| 235 | + acc.lastFlush = now |
| 236 | + snapshot := acc.digest.Clone() |
| 237 | + // TODO: using GetOrCreateModel() is potentially a problem, because instead of |
| 238 | + // skipping the unconfigured models (in terms of pricing), we create |
| 239 | + // empty models. To fix: extend the datastore interface to have Get() |
| 240 | + // this is beyond the scope of this PR. Should handle in a separate PR |
| 241 | + // and remove this TODO afterwards. |
| 242 | + // Note that this is not a problem in requestmetadata. Only in this plugin. |
| 243 | + e.ds.GetOrCreateModel(model).GetAttributes().Put( |
| 244 | + pricing.CostDigestAttributeKey, |
| 245 | + &pricing.CostDigest{Digest: snapshot}, |
| 246 | + ) |
| 247 | + debugLogger.Info("request-cost-metadata published cost digest snapshot", |
| 248 | + "model", model, |
| 249 | + "count", snapshot.Count(), |
| 250 | + ) |
| 251 | + } |
| 252 | + return nil |
| 253 | +} |
| 254 | + |
| 255 | +// extractTokenCounts pulls prompt_tokens and completion_tokens from the |
| 256 | +// response's usage block. Both must be present and positive; any failure |
| 257 | +// returns ok=false so the sample is skipped. |
| 258 | +func extractTokenCounts(p dlsrc.ResponsePayload) (prompt, completion float64, ok bool) { |
| 259 | + usage, ok := p.Response.Body["usage"].(map[string]any) |
| 260 | + if !ok { |
| 261 | + return 0, 0, false |
| 262 | + } |
| 263 | + prompt, ok = usage["prompt_tokens"].(float64) |
| 264 | + if !ok || prompt <= 0 { |
| 265 | + return 0, 0, false |
| 266 | + } |
| 267 | + completion, ok = usage["completion_tokens"].(float64) |
| 268 | + if !ok || completion <= 0 { |
| 269 | + return 0, 0, false |
| 270 | + } |
| 271 | + return prompt, completion, true |
| 272 | +} |
| 273 | + |
| 274 | +// lookupTokenPrices fetches the *pricing.TokenPrices stored on the model's |
| 275 | +// AttributeMap. Returns ok=false if the attribute is absent or of the wrong |
| 276 | +// type, in which case the caller skips the cost sample (a model with no |
| 277 | +// pricing has no defined cost to record). |
| 278 | +func lookupTokenPrices(ds datalayer.Datastore, model string) (*pricing.TokenPrices, bool) { |
| 279 | + v, ok := ds.GetOrCreateModel(model).GetAttributes().Get(pricing.TokenPricesAttributeKey) |
| 280 | + if !ok { |
| 281 | + return nil, false |
| 282 | + } |
| 283 | + tp, ok := v.(*pricing.TokenPrices) |
| 284 | + if !ok { |
| 285 | + return nil, false |
| 286 | + } |
| 287 | + return tp, true |
| 288 | +} |
| 289 | + |
| 290 | +// getOrCreateAccumulator returns the per-model accumulator, creating a fresh |
| 291 | +// t-digest on first use. lastFlush is initialized to now so the first publish |
| 292 | +// happens after one full flushInterval, matching the requestmetadata pattern. |
| 293 | +func (e *RequestCostMetadataExtractor) getOrCreateAccumulator(model string, now time.Time) (*modelCostAccumulator, error) { |
| 294 | + if acc, ok := e.state[model]; ok { |
| 295 | + return acc, nil |
| 296 | + } |
| 297 | + d, err := tdigest.New(tdigest.Compression(e.compression)) |
| 298 | + if err != nil { |
| 299 | + return nil, err |
| 300 | + } |
| 301 | + acc := &modelCostAccumulator{digest: d, lastFlush: now} |
| 302 | + e.state[model] = acc |
| 303 | + return acc, nil |
| 304 | +} |
0 commit comments