mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-12 04:54:45 +00:00
Billing: - Passthrough SSE streams now bill the usage the upstream reports. The gateway falls back to a request-size estimate only on a 2xx stream or a truncated JSON body. - A unified stream that fails after it starts is estimated, not billed at zero. - Billing uses the built-in LiteLLM price table and honours modelMeta free: true. - Pricing entries are validated. Cost arithmetic rounds the total and saturates on overflow. - Gemini thinking tokens count as completion tokens. Limits and storage: - A rejected request rolls back its counts on the other scopes, so one user cannot lock out a group. - Redis EXPIRE replies are checked. Keys recreated without a TTL are repaired at once. - A mutating pipeline is not retried after reply bytes arrive. - History, model totals and target usage report a store outage. Routing and translation: - Discovered ids with a provider-like prefix are listed only in owner/id form, so listing and routing agree. - Anthropic and Gemini discovery follows pagination. - The developer role maps to system. - stream_options.include_usage is honoured on translated streams. - tool_choice none keeps tools when history holds tool blocks. - refusal maps to content_filter. Data URI parameters are stripped. Streaming, retry and failover: - SSE accepts CR, LF and CRLF line ends. This closes a CR injection path. - Retry-After is never jittered. A cancelled wait returns the context error. - The watchdog timer stops at end of body. - Oversized bodies return 413. - A failed stream ends with an error event. - Failover restores pre-existing headers. Client cancels do not count as provider successes. Also: "admin" is a reserved provider name. metrics.path must start with "/". Pricing warnings go to the right gateway instance. The admin targets view sends access as null or [] so the two states differ. Upstream organisation headers are stripped. The users-file reload error is logged once per error. Dead code is removed, and test-only helpers move to test files. Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
262 lines
12 KiB
Go
262 lines
12 KiB
Go
package traefikllmgateway
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// microUSDPerUSD scales a micro-USD integer (ModelMetaConfig/
|
|
// builtinModelMeta's storage unit — integer math avoids the float-drift
|
|
// pricing.go's costMicros already documents for the cost-ACCOUNTING path)
|
|
// to plain USD for exposure in a JSON API. Kept as its own named constant
|
|
// rather than reusing pricing.go's tokensPerMillion: the two are
|
|
// numerically identical (1_000_000) but represent different physical
|
|
// quantities — one a token count, this one a currency scale — and must
|
|
// not be conflated just because they share a value.
|
|
const microUSDPerUSD = 1_000_000
|
|
|
|
// freeTierSuffix marks a discovered model id as free by naming
|
|
// convention alone (review addendum, feature v0.23): a real provider
|
|
// (OpenRouter's own live API is the known case) reports free-tier model
|
|
// ids with this exact suffix, independent of whatever LiteLLM's static
|
|
// pricing file does or does not track for that provider. resolveModelMeta
|
|
// checks this AFTER an operator's own config override and BEFORE the
|
|
// built-in table, so a ":free"-suffixed id never falls through to a
|
|
// builtin entry's paid price for that same key, and an operator can
|
|
// still override it (e.g. to attach a context window) if they choose.
|
|
const freeTierSuffix = ":free"
|
|
|
|
// resolvedModelMeta is resolveModelMeta's result: a model's context
|
|
// window and per-token cost, each resolved independently through its own
|
|
// layering (feature v0.23). ContextKnown/CostKnown are authoritative — a
|
|
// caller must check them, never infer "unknown" from a zero numeric
|
|
// field, since a free model's cost fields are legitimately zero AND
|
|
// known (ModelMetaConfig.Free).
|
|
type resolvedModelMeta struct {
|
|
ContextTokens int
|
|
InputCostPerMTokMicroUSD int64
|
|
OutputCostPerMTokMicroUSD int64
|
|
ContextKnown bool
|
|
CostKnown bool
|
|
}
|
|
|
|
// resolveModelMeta layers per-model metadata resolution (feature v0.23):
|
|
//
|
|
// - Context: an operator's modelMeta config override
|
|
// (lookupModelMetaConfig: an exact "provider/model" key, then a bare
|
|
// "model" key) wins when it sets ContextTokens > 0; otherwise
|
|
// discoveredContext (only when discoveredKnown — a provider's own
|
|
// discovery-captured context, registry.go's providerState.
|
|
// discoveredContext, populated only for a provider whose adapter
|
|
// implements modelMetadataFetcher and returned a value for this
|
|
// model); otherwise the built-in table (builtinModelMetaTable,
|
|
// pricing_data_gen.go), keyed by bare model id only — an operator's
|
|
// own provider name never matches LiteLLM's canonical provider
|
|
// names, so an exact "provider/model" form makes no sense for that
|
|
// table the way it does for cfg; otherwise unknown.
|
|
// - Cost: the config override wins when it sets Free (an explicit,
|
|
// known zero) or either cost field > 0; otherwise a ":free"-suffixed
|
|
// model id (freeTierSuffix — review addendum) wins next, ahead of
|
|
// the built-in table, so a provider's own free-tier naming
|
|
// convention is never shadowed by a paid built-in price under the
|
|
// identical key; otherwise the built-in table. Discovery never
|
|
// reports cost, so there is no discovery layer for it.
|
|
//
|
|
// Absent at every applicable layer is a genuinely unknown value and is
|
|
// never invented — ContextKnown/CostKnown both stay false in that case.
|
|
// The freeTierSuffix rule affects ONLY cost: a ":free" id's context
|
|
// still runs through the normal context layering above (config/
|
|
// discovery/builtin/absent), never inherited from some other, unrelated
|
|
// entry.
|
|
func resolveModelMeta(provider, model string, cfg map[string]*ModelMetaConfig, discoveredContext int, discoveredKnown bool) resolvedModelMeta {
|
|
var out resolvedModelMeta
|
|
override := lookupModelMetaConfig(provider, model, cfg)
|
|
builtin, builtinOK := builtinModelMetaTable[model]
|
|
|
|
switch {
|
|
case override != nil && override.ContextTokens > 0:
|
|
out.ContextTokens, out.ContextKnown = override.ContextTokens, true
|
|
case discoveredKnown && discoveredContext > 0:
|
|
out.ContextTokens, out.ContextKnown = discoveredContext, true
|
|
case builtinOK && builtin.ContextTokens > 0:
|
|
out.ContextTokens, out.ContextKnown = builtin.ContextTokens, true
|
|
}
|
|
|
|
switch {
|
|
case override != nil && override.Free:
|
|
out.InputCostPerMTokMicroUSD, out.OutputCostPerMTokMicroUSD, out.CostKnown = 0, 0, true
|
|
case override != nil && (override.InputCostPerMTokMicroUSD > 0 || override.OutputCostPerMTokMicroUSD > 0):
|
|
out.InputCostPerMTokMicroUSD = override.InputCostPerMTokMicroUSD
|
|
out.OutputCostPerMTokMicroUSD = override.OutputCostPerMTokMicroUSD
|
|
out.CostKnown = true
|
|
case strings.HasSuffix(model, freeTierSuffix):
|
|
out.InputCostPerMTokMicroUSD, out.OutputCostPerMTokMicroUSD, out.CostKnown = 0, 0, true
|
|
case builtinOK && (builtin.InputCostPerMTokMicroUSD > 0 || builtin.OutputCostPerMTokMicroUSD > 0):
|
|
out.InputCostPerMTokMicroUSD = builtin.InputCostPerMTokMicroUSD
|
|
out.OutputCostPerMTokMicroUSD = builtin.OutputCostPerMTokMicroUSD
|
|
out.CostKnown = true
|
|
}
|
|
return out
|
|
}
|
|
|
|
// priceKnown reports whether model — given as both its canonical
|
|
// "provider/model" id and its bare upstream id, canonical checked first,
|
|
// the same two-id order routes_unified.go's own modelPriceKnown/
|
|
// unifiedCostMicrosKnown already check — has a KNOWN price for billing
|
|
// purposes: a configured pricing override or the built-in tables
|
|
// (lookupPricing, pricing.go — builtinPricing then the generated
|
|
// builtinModelMetaTable), OR an operator's own modelMeta entry that marks
|
|
// the model explicitly Free (review-auth finding F1, 2026-09 audit:
|
|
// modelPriceKnown used to consult only pricing overrides and the built-in
|
|
// table, so a model an operator declared modelMeta:{free:true} — the
|
|
// common case for a self-hosted/local model with no real cost — was still
|
|
// reported "unpriced" and refused by the AllowUnpricedWithCostBudget cost
|
|
// guard even though its price, zero, is perfectly well known).
|
|
//
|
|
// meta is checked by the SAME two key shapes lookupModelMetaConfig itself
|
|
// checks (an exact "provider/model" key, then a bare "model" key) — done
|
|
// here as plain map lookups rather than a resolveModelMeta call, since
|
|
// resolveModelMeta also resolves context-window/discovery data this
|
|
// cost-only check has no use for, and canonical here already IS the
|
|
// joined "provider/model" string, not a separate (provider, model) pair
|
|
// resolveModelMeta could split it back into.
|
|
//
|
|
// This does not change AllowUnpricedWithCostBudget's own default — it
|
|
// only widens what counts as "known", so a model already treated as
|
|
// priced stays priced, and a model an operator has explicitly marked free
|
|
// now also counts as priced, rather than being refused as though it had
|
|
// no price on record at all.
|
|
func priceKnown(canonical, bare string, overrides map[string]*ModelPricing, meta map[string]*ModelMetaConfig) bool {
|
|
if _, ok := lookupPricing(canonical, overrides); ok {
|
|
return true
|
|
}
|
|
if _, ok := lookupPricing(bare, overrides); ok {
|
|
return true
|
|
}
|
|
return modelMetaFree(canonical, bare, meta)
|
|
}
|
|
|
|
// modelMetaFree reports whether the operator modelMeta entry for
|
|
// canonical (or, absent that, bare) marks it Free — the SAME two-key
|
|
// lookup priceKnown itself performs (an exact "provider/model" key, then
|
|
// a bare "model" key), factored out so billing (unifiedCostMicros, routes_unified.go)
|
|
// and the 402 cost-budget guard (priceKnown, above) can never disagree
|
|
// about which models are free (verify-core fix, round 4). Before this,
|
|
// priceKnown alone treated a modelMeta free:true model as priced, but
|
|
// unifiedCostMicros never consulted modelMeta at all, so a self-hosted
|
|
// id an operator declared free that also happened to match a bare id in
|
|
// the generated LiteLLM table (gpt-oss-120b, llama-3.3-70b, and other
|
|
// common local-model names) was billed that table's real price instead
|
|
// of 0 — passing the 402 guard while silently charging for it anyway.
|
|
//
|
|
// Precedence matches the display path (lookupModelMetaConfig): an exact
|
|
// "provider/model" entry decides on its own; the bare entry is consulted
|
|
// only when no provider/model entry exists, so /v1/models and billing
|
|
// never disagree about whether a model is free.
|
|
func modelMetaFree(canonical, bare string, meta map[string]*ModelMetaConfig) bool {
|
|
if m, ok := meta[canonical]; ok && m != nil {
|
|
return m.Free
|
|
}
|
|
if m, ok := meta[bare]; ok && m != nil {
|
|
return m.Free
|
|
}
|
|
return false
|
|
}
|
|
|
|
// lookupModelMetaConfig resolves cfg's config-override layer for
|
|
// (provider, model): an exact "provider/model" key wins over a bare
|
|
// "model" key, matching this feature's own spec precedence (a key names
|
|
// either an exact provider/model pair or a bare id that applies wherever
|
|
// that id resolves). Returns nil (no override at this layer) when cfg is
|
|
// nil or neither key is present, or when the matched entry is itself a
|
|
// nil pointer (a malformed-but-decoded config value, treated as absent
|
|
// rather than dereferenced).
|
|
func lookupModelMetaConfig(provider, model string, cfg map[string]*ModelMetaConfig) *ModelMetaConfig {
|
|
if cfg == nil {
|
|
return nil
|
|
}
|
|
if v, ok := cfg[provider+"/"+model]; ok && v != nil {
|
|
return v
|
|
}
|
|
if v, ok := cfg[model]; ok && v != nil {
|
|
return v
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// resolveAliasModelMeta applies modelMeta's alias-inheritance rule
|
|
// (feature v0.23): an alias inherits its target's fully resolved
|
|
// metadata (resolveModelMeta against the alias's resolved provider/
|
|
// bare-model target) UNLESS cfg carries a modelMeta entry keyed by the
|
|
// alias id itself — that entry's own fields win over the inherited ones,
|
|
// independently per field (an alias-level entry that sets only context
|
|
// still inherits the target's cost, and vice versa), the same
|
|
// override-wins-per-field shape resolveModelMeta itself applies between
|
|
// its own layers.
|
|
func resolveAliasModelMeta(alias, targetProvider, targetModel string, cfg map[string]*ModelMetaConfig, discoveredContext int, discoveredKnown bool) resolvedModelMeta {
|
|
out := resolveModelMeta(targetProvider, targetModel, cfg, discoveredContext, discoveredKnown)
|
|
if cfg == nil {
|
|
return out
|
|
}
|
|
override, ok := cfg[alias]
|
|
if !ok || override == nil {
|
|
return out
|
|
}
|
|
if override.ContextTokens > 0 {
|
|
out.ContextTokens, out.ContextKnown = override.ContextTokens, true
|
|
}
|
|
switch {
|
|
case override.Free:
|
|
out.InputCostPerMTokMicroUSD, out.OutputCostPerMTokMicroUSD, out.CostKnown = 0, 0, true
|
|
case override.InputCostPerMTokMicroUSD > 0 || override.OutputCostPerMTokMicroUSD > 0:
|
|
out.InputCostPerMTokMicroUSD = override.InputCostPerMTokMicroUSD
|
|
out.OutputCostPerMTokMicroUSD = override.OutputCostPerMTokMicroUSD
|
|
out.CostKnown = true
|
|
}
|
|
return out
|
|
}
|
|
|
|
// microUSDPerMTokToUSD converts a micro-USD-per-million-tokens integer
|
|
// (ModelMetaConfig/builtinModelMeta's storage unit) to plain
|
|
// USD-per-million-tokens for exposure in a JSON API (GET /v1/models'
|
|
// "pricing" extension field: "floats in USD per MTok").
|
|
func microUSDPerMTokToUSD(micro int64) float64 {
|
|
return float64(micro) / microUSDPerUSD
|
|
}
|
|
|
|
// validateModelMeta validates cfg.ModelMeta (feature v0.23) and returns
|
|
// it unchanged (nil when raw is empty — the zero-behavior-change
|
|
// default, matching validateModelAliases' own convention, registry.go).
|
|
// raw is iterated in sorted key order, not Go's randomized map order, so
|
|
// a config with more than one invalid entry always reports the same one
|
|
// first, deterministically, across repeated runs.
|
|
func validateModelMeta(raw map[string]*ModelMetaConfig) (map[string]*ModelMetaConfig, error) {
|
|
if len(raw) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
keys := make([]string, 0, len(raw))
|
|
for k := range raw {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
for _, k := range keys {
|
|
v := raw[k]
|
|
if v == nil {
|
|
return nil, fmt.Errorf("llmgateway: modelMeta: entry %q must not be nil", k)
|
|
}
|
|
if v.ContextTokens < 0 {
|
|
return nil, fmt.Errorf("llmgateway: modelMeta: entry %q: contextTokens must not be negative", k)
|
|
}
|
|
if v.InputCostPerMTokMicroUSD < 0 || v.OutputCostPerMTokMicroUSD < 0 {
|
|
return nil, fmt.Errorf("llmgateway: modelMeta: entry %q: cost fields must not be negative", k)
|
|
}
|
|
if v.Free && (v.InputCostPerMTokMicroUSD != 0 || v.OutputCostPerMTokMicroUSD != 0) {
|
|
return nil, fmt.Errorf("llmgateway: modelMeta: entry %q: free=true is contradictory with a non-zero cost field", k)
|
|
}
|
|
}
|
|
return raw, nil
|
|
}
|