Files
traefik-llmgateway/pricing.go
T
lukaszraczylo b3b8ae6ad5 fix(security): harden retry, SSE, Redis and cost-budget paths
The gateway refuses a request for an unpriced model when the caller has a
cost budget. An unpriced model cost 0, so the budget counter was never
written and the budget could never fire. The new
allowUnpricedWithCostBudget setting restores the old behaviour.

The retry loop honours an upstream Retry-After of up to 30s and jitters
its own computed backoff. SSE parsing bounds the total size of one event.
The Redis client can use TLS. Provider base URLs and errors are scrubbed
of credentials before they reach logs or the admin view. The gateway
warns when failover and retry settings multiply into a large number of
outbound calls.

Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
2026-09-23 09:35:38 +01:00

163 lines
7.1 KiB
Go

package traefikllmgateway
import (
"math"
"sync"
)
// tokensPerMillion is the denominator for the providers' per-1M-token
// pricing unit.
const tokensPerMillion = 1_000_000
// builtinPricing is the built-in per-model price table, in USD per 1M
// tokens (InputPerM, OutputPerM). Source: each provider's own published
// list prices, as recorded 2026-08. Approximate: providers change prices
// without notice, and this table is not refreshed automatically — set the
// middleware config's "pricing" overrides for billing-grade accuracy.
var builtinPricing = map[string]ModelPricing{
"gpt-5": {InputPerM: 1.25, OutputPerM: 10},
"gpt-5-mini": {InputPerM: 0.25, OutputPerM: 2},
"gpt-4o": {InputPerM: 2.5, OutputPerM: 10},
"o3": {InputPerM: 2, OutputPerM: 8},
"claude-opus-4-5": {InputPerM: 5, OutputPerM: 25},
"claude-sonnet-4-5": {InputPerM: 3, OutputPerM: 15},
"claude-haiku-4-5": {InputPerM: 1, OutputPerM: 5},
"gemini-2.5-pro": {InputPerM: 1.25, OutputPerM: 10},
"gemini-2.5-flash": {InputPerM: 0.3, OutputPerM: 2.5},
"grok-4": {InputPerM: 3, OutputPerM: 15},
}
// unknownModelWarnCap bounds warnedModels. The model id in costMicros'
// calls comes from client-supplied request bodies (a group with an empty
// Models allowlist accepts any model string), so without a cap an attacker
// or a buggy client cycling through model names could grow warnedModels
// without bound. Once the cap is reached, costMicros stops recording new
// names and pricingWarnFn fires once more with a summary message instead
// of a per-model one.
const unknownModelWarnCap = 128
// warnCapMessage is the single summary warning fired once the dedup set
// in warnedModels reaches unknownModelWarnCap.
const warnCapMessage = "pricing: unknown-model warning cap reached; further unknown models unwarned"
// pricingWarnFn is called once per unknown model id encountered by
// costMicros, with the model id — or, once unknownModelWarnCap is
// reached, once more with warnCapMessage. The default is a no-op so
// pricing.go has no logging dependency of its own; a later task wires
// this to the gateway's logf via setPricingWarnFn, and tests can
// substitute their own func to capture warnings the same way. Read inside
// warnUnknownModel under warnedModelsMu — always assign through
// setPricingWarnFn, never by a bare `pricingWarnFn = …`, or a concurrent
// costMicros call can race the assignment.
var pricingWarnFn = func(string) {}
// warnedModels tracks which unknown model ids have already triggered
// pricingWarnFn, so a gateway processing many requests for the same
// unpriced model logs the warning once per process lifetime rather than
// once per request. warnCapNotified tracks whether the one-time summary
// warning has already fired once the cap is reached. Package-level and
// mutex-guarded by design: costMicros is called from the request path
// with no natural place to thread a longer-lived cache through, and the
// dedup set must survive across requests and across limiter instances,
// not reset per call.
var (
warnedModelsMu sync.Mutex
warnedModels = map[string]bool{}
warnCapNotified bool
)
// costMicros computes the micro-USD cost of u's token usage for model,
// looking up its price in overrides first (exact model id match), then
// builtinPricing. An unknown model costs 0 and triggers one pricingWarnFn
// call per model id, up to unknownModelWarnCap distinct names.
//
// The math stays in integer micro-USD throughout to avoid float drift:
// priceMicrosPerM is the per-1M-token price scaled to micro-USD and
// rounded to the nearest micro-USD (a bare truncating cast can undershoot
// a price like 8.2 by one micro-USD due to float64 representation), and
// each of input/output cost is tokens*priceMicrosPerM/1e6, summed.
func costMicros(model string, u usage, overrides map[string]*ModelPricing) int64 {
cost, _ := costMicrosKnown(model, u, overrides)
return cost
}
// costMicrosKnown is costMicros plus the one bit costMicros throws away:
// whether a price was actually FOUND. An unknown model returns (0, false)
// — a zero that means "cannot be priced", which is NOT the same value a
// genuinely free model produces, even though both are 0.
//
// Security audit run-1, finding F-1 (high): costMicros' single-int64
// return conflated those two cases, and limiter.account's own
// `if costMicros != 0` guard then skipped every cost-counter write for an
// unpriced model. checkAndCount reads exactly those never-written
// counters, so `used < limit` stayed permanently true and a configured
// costPerDayUSD/costPerMonthUSD could never fire. Any caller gating a
// SPEND control must use this function and handle ok == false; costMicros
// above remains for callers that only need the number (the accounting
// write itself, where a zero is harmless).
//
// This is the distinction resolvedModelMeta.CostKnown (modelmeta.go)
// already draws for the metadata path, and the one candidateCostAllowed
// (failover.go) already relies on to protect real money — its own comment
// calls recording zero for an unpriced model "an accounting shortcut, not
// proof the provider invoices zero".
func costMicrosKnown(model string, u usage, overrides map[string]*ModelPricing) (int64, bool) {
price, ok := lookupPricing(model, overrides)
if !ok {
warnUnknownModel(model)
return 0, false
}
inputMicrosPerM := int64(math.Round(price.InputPerM * tokensPerMillion))
outputMicrosPerM := int64(math.Round(price.OutputPerM * tokensPerMillion))
inputCost := u.prompt * inputMicrosPerM / tokensPerMillion
outputCost := u.completion * outputMicrosPerM / tokensPerMillion
return inputCost + outputCost, true
}
// lookupPricing resolves model's price: overrides first (a nil entry does
// not count as a match, so a caller can't accidentally price a model at
// zero by leaving a map entry nil), then builtinPricing.
func lookupPricing(model string, overrides map[string]*ModelPricing) (ModelPricing, bool) {
if p, ok := overrides[model]; ok && p != nil {
return *p, true
}
if p, ok := builtinPricing[model]; ok {
return p, true
}
return ModelPricing{}, false
}
// setPricingWarnFn replaces pricingWarnFn, guarded by the same mutex
// warnUnknownModel reads it under. A later task calls this at New() to
// wire warnings to the gateway's logf; tests call it to capture warnings.
// Always use this instead of assigning pricingWarnFn directly.
func setPricingWarnFn(fn func(string)) {
warnedModelsMu.Lock()
defer warnedModelsMu.Unlock()
pricingWarnFn = fn
}
// warnUnknownModel calls pricingWarnFn for model, once per process
// lifetime. Once warnedModels reaches unknownModelWarnCap, it stops
// recording new model ids and instead fires a single one-time summary
// warning via warnCapMessage — after that, further unknown models are
// silently unwarned rather than growing the set forever.
func warnUnknownModel(model string) {
warnedModelsMu.Lock()
defer warnedModelsMu.Unlock()
if warnedModels[model] {
return
}
if len(warnedModels) >= unknownModelWarnCap {
if !warnCapNotified {
warnCapNotified = true
pricingWarnFn(warnCapMessage)
}
return
}
warnedModels[model] = true
pricingWarnFn(model)
}