Files
llmgw/internal/accounting/cost.go
T
Ilker ManapandClaude Opus 5.5 ed0d1ec0ad
CI / test (push) Successful in 8m45s
A cached prompt costs the cached rate
cached_per_1k in a price charges the prompt tokens the answer counts as
cached_tokens at their own rate; zero keeps charging them as prompt tokens.
The count reaches the cost and not a column: CostMicros holds what it
changed. Cached beyond the prompt is clamped, an engine's miscount rather
than a refund. BACKLOG L5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:15:48 +01:00

122 lines
4.0 KiB
Go

// Package accounting turns finished requests into spend, enforces what a key
// is allowed, and gets both into the store without touching the request path
// (plan §7.9.3, §7.9.4).
package accounting
import (
"strings"
"sync"
)
// Price is what one model costs, in micro-units per thousand tokens.
//
// Micro-units rather than a float because spend is accumulated by repeated
// addition, and repeated addition of floats drifts. What a unit means is the
// operator's: self-hosted models have no per-token price, so it may be
// GPU-seconds, a notional rate, or 1000 everywhere — which turns a budget into
// a token quota.
type Price struct {
PromptPer1k int64 `koanf:"prompt_per_1k"`
CompletionPer1k int64 `koanf:"completion_per_1k"`
// CachedPer1k is what a prompt token costs that the engine or provider
// read from its cache instead of computing: the part of prompt_tokens
// the answer counts as cached_tokens. Zero charges those at
// PromptPer1k, as before there was a rate for them.
CachedPer1k int64 `koanf:"cached_per_1k"`
// PerImage is what one picture costs, for a model that makes them: a
// picture's cost is the card's time, which its prompt's few tokens do
// not measure.
PerImage int64 `koanf:"per_image"`
// PerMesh is what one 3D model costs: minutes of a card, which no
// token count measures.
PerMesh int64 `koanf:"per_mesh"`
}
// Zero reports whether this model spends nothing.
//
// It matters beyond arithmetic: a zero-cost model stays available to a key
// whose budget is spent, which is what makes the plan's budget fallback work
// (§7.7).
func (p Price) Zero() bool {
return p.PromptPer1k == 0 && p.CompletionPer1k == 0 && p.CachedPer1k == 0 && p.PerImage == 0 && p.PerMesh == 0
}
// Cost computes what a request spent, rounding up.
//
// Up rather than to nearest: rounding down would make a stream of tiny requests
// free, which is exactly the shape of traffic a quota exists to bound.
//
// cachedTokens are counted within promptTokens, as OpenAI's usage counts
// them, and are charged at the cached rate instead of the prompt rate.
func (p Price) Cost(promptTokens, cachedTokens, completionTokens, images, meshes int64) int64 {
cachedTokens = min(max(cachedTokens, 0), max(promptTokens, 0))
cachedRate := p.CachedPer1k
if cachedRate == 0 {
cachedRate = p.PromptPer1k
}
return divUp((promptTokens-cachedTokens)*p.PromptPer1k+cachedTokens*cachedRate, 1000) +
divUp(completionTokens*p.CompletionPer1k, 1000) +
max(images, 0)*p.PerImage + max(meshes, 0)*p.PerMesh
}
func divUp(n, d int64) int64 {
if n <= 0 {
return 0
}
return (n + d - 1) / d
}
// Pricing resolves a model name to its price.
type Pricing struct {
mu sync.RWMutex
byModel map[string]Price
fallback Price
}
// NewPricing builds a price table. Names are matched case-insensitively,
// because a model name reaches us from whatever the client typed.
func NewPricing(byModel map[string]Price, fallback Price) *Pricing {
table := make(map[string]Price, len(byModel))
for name, p := range byModel {
table[strings.ToLower(name)] = p
}
return &Pricing{byModel: table, fallback: fallback}
}
// For returns a model's price, or the fallback.
func (p *Pricing) For(model string) Price {
p.mu.RLock()
defer p.mu.RUnlock()
if v, ok := p.byModel[strings.ToLower(model)]; ok {
return v
}
return p.fallback
}
// AnythingPriced reports whether any model costs anything.
//
// A budget is spend per period, and spend is computed from a price table. With
// no prices every request costs nothing, so a budget written on a team is a
// number that can never move — displayed as a limit, reported as zero percent
// used, and binding on nothing.
//
// Self-hosted models have no per-token price, so an empty table is the ordinary
// case rather than a misconfiguration. What is not ordinary is showing somebody
// a budget without saying so.
func (p *Pricing) AnythingPriced() bool {
if p == nil {
return false
}
p.mu.RLock()
defer p.mu.RUnlock()
if !p.fallback.Zero() {
return true
}
for _, price := range p.byModel {
if !price.Zero() {
return true
}
}
return false
}