Skip to content

Commit 8747267

Browse files
Implements requestcostmetadata extractor
Signed-off-by: David Breitgand <davidbreitgand@users.noreply.github.com>
1 parent b75cf4d commit 8747267

3 files changed

Lines changed: 842 additions & 0 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Request Cost Metadata Extractor
2+
3+
## What it is
4+
5+
A datasource extractor that turns each completed inference response into a per-request
6+
cost sample and folds it into a per-model [t-digest](https://github.com/caio/go-tdigest)
7+
stored on the Model's `AttributeMap`. It is registered as type
8+
`request-cost-metadata-extractor` and runs on the same response-event loop as
9+
`request-metadata-extractor`. It is a building block for the CostGuard scorer
10+
(see [docs/proposals/050-costguard-scorer/README.md](../../../../../../docs/proposals/050-costguard-scorer/README.md)).
11+
12+
## What it does
13+
14+
1. Ignores `RequestEventType` events. Cost is observed only after a response.
15+
2. On each `ResponseEventType` event:
16+
- Reads the model name from the request body's `model` field.
17+
- Reads `prompt_tokens` and `completion_tokens` from the response's `usage` block.
18+
Skips the sample (with a debug log) if either is absent or non-positive.
19+
- Reads the model's `*pricing.TokenPrices` from the AttributeMap under
20+
`pricing.TokenPricesAttributeKey`. Skips the sample (with a debug log) if absent —
21+
a model with no declared pricing has no defined cost. A model declared with
22+
`TokenPrices{0,0}` (a free model) is *not* skipped: it records `cost=0`.
23+
- Computes
24+
`cost = prompt_tokens * InputTokenPrice + completion_tokens * OutputTokenPrice`
25+
and adds the value to the model's running t-digest.
26+
3. At the end of each `Extract` batch, for every model whose digest was updated and
27+
whose flush interval has elapsed since the last publish, writes a *clone* of the
28+
digest to the Model's AttributeMap under `pricing.CostDigestAttributeKey`. The
29+
stored value is a `*pricing.CostDigest`.
30+
31+
This extractor does not freeze and replace the digest at epoch boundaries — the
32+
digest accumulates without bound. Epoch handling lands in a follow-up PR.
33+
34+
## Inputs consumed
35+
36+
- `dlsrc.ResponsePayload.Request.Body["model"]` — the model name (string).
37+
- `dlsrc.ResponsePayload.Response.Body["usage"]` — a `map[string]any` containing
38+
`prompt_tokens` and `completion_tokens` as `float64`.
39+
- `pricing.TokenPricesAttributeKey` on the Model's AttributeMap — populated by the
40+
`modelconfigcollector` plugin at startup and on config-file changes.
41+
42+
## Configuration
43+
44+
```json
45+
{
46+
"compression": 200,
47+
"flushIntervalDuration": "5s"
48+
}
49+
```
50+
51+
- `compression` (optional, default `200`): t-digest compression. Higher values
52+
trade memory for quantile accuracy. Must be `> 0`.
53+
- `flushIntervalDuration` (optional, default `"5s"`): aggregation window before a
54+
per-model digest snapshot is published to the AttributeMap. Set to `"0s"` to
55+
publish on every event (used in unit tests). Must be `>= 0`.
56+
57+
## Known limitations
58+
59+
- **Side-effect creation of empty Models for unconfigured names.** When a
60+
response arrives for a model name that the operator never declared (i.e. a
61+
model with no `pricing.TokenPrices` attribute), this extractor's lookup
62+
goes through `Datastore.GetOrCreateModel`, which registers an empty Model
63+
in the datastore as a side effect. The cost sample is correctly skipped,
64+
but the model name leaks into `Datastore.Models()` and becomes visible to
65+
every other plugin that enumerates the store. This is a limitation of the
66+
current `Datastore` interface, which has no read-only `GetModel(name)`
67+
method. A follow-up PR will add `GetModel` to the interface and migrate
68+
this extractor to use it; once that lands, responses for unconfigured
69+
models will be skipped without any datastore mutation.
Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
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

Comments
 (0)