mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-12 04:54:45 +00:00
I2 (controller ruling): docs/superpowers/specs/2026-08-19-traefik-llmgateway-design.md
described limits as "user overrides group" / "user limit if set, else
group limit, else unlimited" — stale relative to the actual, already-correct
implementation (buildLimitScopes/checkAndCount evaluate every configured
scope and return the first violation). Amended §2 and §7 to state both
scopes are enforced together and the tighter bound wins.
I4 (manifest hedge): .traefik.yml's groups.default was a bare "{}",
the shape empirically reproduced as a decode failure outside this
repo's own (necessarily simplified) yaegi-check harness. Changed to
"providers: []" — same zero semantics, no longer a bare empty object.
manifest_test.go's JSON mirror and drift-check token updated to match;
make yaegi-check still passes (its YAML-subset parser already handles
an inline "[]" under a nested map key).
M9 (pricing provenance): pricing.go's built-in table comment replaced
"verify before release" with a real source note (each provider's own
published list prices, recorded 2026-08, approximate). README's limits
section gained the matching caveat and points to the "pricing" config
override for billing-grade accuracy.
Doc nits: README's Kubernetes section now says the example manifest
applies cleanly once Traefik's CRDs (Middleware, IngressRoute) are
installed, rather than claiming kubectl apply -f works unconditionally.
README's Security notes section no longer claims "no auth-event logging
at all" (see the paired fix: commit) — it now describes what is logged
and that key material never appears.
This commit and its sibling fix: commit are the final wave closing out
the whole-branch review findings for docs/superpowers/sdd/2026-08-20-
traefik-llmgateway.
Gates: go test ./... -count=1 (-race) green, gofmt -l . empty, go vet
clean, make yaegi-check OK, golangci-lint 0 issues.
138 lines
5.7 KiB
Go
138 lines
5.7 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 {
|
|
price, ok := lookupPricing(model, overrides)
|
|
if !ok {
|
|
warnUnknownModel(model)
|
|
return 0
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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)
|
|
}
|