156 lines
7.3 KiB
Go
156 lines
7.3 KiB
Go
// Package ledger is the money seam: per-model prices, usage→USD computation,
|
||
// and reserve/settle bookkeeping against the store. The full vojo discipline
|
||
// (Р7): billing by usage from the API response, reserve-before-call / settle-after,
|
||
// $ ceilings; the Phase 0 extension — the cost of writing to the cache (Anthropic ×1.25/×2).
|
||
package ledger
|
||
|
||
import (
|
||
"fmt"
|
||
|
||
"textmachine/backend/internal/llm"
|
||
)
|
||
|
||
// ModelPrice is the per-1M-token USD price for one model, applied to the
|
||
// API's returned usage so the ceilings track real cost even as prices drift.
|
||
// Prices live ONLY in configs/models.yaml with a checked-date (Р4/Р5).
|
||
type ModelPrice struct {
|
||
InputPerM float64 // non-cached prompt tokens
|
||
// CachedPerM prices prompt tokens served FROM the cache (cache read).
|
||
CachedPerM float64
|
||
// CacheWritePerM prices prompt tokens WRITTEN to the cache. Anthropic bills
|
||
// writes at ×1.25 (5m TTL) / ×2 (1h) of input; providers without explicit
|
||
// writes keep 0 and the term vanishes.
|
||
CacheWritePerM float64
|
||
OutputPerM float64 // completion + reasoning tokens
|
||
}
|
||
|
||
// Pricer resolves model→price with an explicit default anchor. An unknown
|
||
// model falls back to the DEFAULT price rather than $0 — a $0 price would
|
||
// silently blind the ceilings to that call, the one failure mode we never
|
||
// want (in vojo the anchor was XAIModel; here the multi-provider config requires
|
||
// an explicit default_price).
|
||
type Pricer struct {
|
||
prices map[string]ModelPrice
|
||
defaultPrice ModelPrice
|
||
}
|
||
|
||
// NewPricer builds a pricer. defaultPrice must be a real (non-zero) price —
|
||
// enforced at config load (fail-fast), re-checked here defensively.
|
||
func NewPricer(prices map[string]ModelPrice, defaultPrice ModelPrice) (*Pricer, error) {
|
||
if defaultPrice.InputPerM <= 0 || defaultPrice.OutputPerM <= 0 {
|
||
return nil, fmt.Errorf("ledger: default price must be non-zero (a $0 fallback would blind the spend ceilings)")
|
||
}
|
||
return &Pricer{prices: prices, defaultPrice: defaultPrice}, nil
|
||
}
|
||
|
||
// PriceFor returns the configured price for a model, falling back to the
|
||
// default anchor for unknown models (never $0).
|
||
func (p *Pricer) PriceFor(model string) ModelPrice {
|
||
if mp, ok := p.prices[model]; ok {
|
||
return mp
|
||
}
|
||
return p.defaultPrice
|
||
}
|
||
|
||
// PriceBasis names WHICH model's row a completion was priced from. It is returned rather than inferred
|
||
// because the fallback is invisible from the outside: the same call site, the same slugs, and a bill
|
||
// that is right or wrong by a factor nobody is told about.
|
||
type PriceBasis string
|
||
|
||
const (
|
||
// PriceByAnswerer — the model that actually answered is in the catalogue. The ordinary case.
|
||
PriceByAnswerer PriceBasis = "answerer"
|
||
// PriceByRequested — the answering slug is unknown and the bill was taken from the model we ASKED
|
||
// for. Safe in one direction and not in the other: it stops a premium answer being billed at a cheap
|
||
// default, and it lets a provider that routed DOWN be billed at the expensive pin. Measured on the
|
||
// run of 11.09: 28 of 33 rows answered `deepseek-flash`, a slug the catalogue does not carry.
|
||
PriceByRequested PriceBasis = "requested"
|
||
// PriceByAnchor — neither slug is known and the global default anchor was used. Never $0, and never
|
||
// a number anybody chose for this call.
|
||
PriceByAnchor PriceBasis = "anchor"
|
||
)
|
||
|
||
// Substituted reports whether the bill came from a model that did not answer this call.
|
||
func (b PriceBasis) Substituted() bool { return b != PriceByAnswerer }
|
||
|
||
// PriceForResponse prices a completion by the model that ACTUALLY answered,
|
||
// but if that id is unknown (the provider returned a canonicalized/dated
|
||
// slug like claude-sonnet-5-2026xxxx) falls back to the REQUESTED model's
|
||
// price BEFORE the global anchor — otherwise a premium answer would be billed at
|
||
// the cheap default (systematic under-accounting, review finding). Only if both
|
||
// are unknown — the default anchor (never $0).
|
||
//
|
||
// The basis travels WITH the price, out of the same lookup, so a caller cannot describe one branch
|
||
// while billing through another. The guard the comment above names covers exactly one direction — a
|
||
// premium answer must not be billed at a cheap default — and the opposite direction has no guard at
|
||
// all: ask for a premium model, get a cheaper one, pay the premium pin. That is the bill the reader
|
||
// sees, so the least a caller owes is to know it happened.
|
||
func (p *Pricer) PriceForResponse(requested, actual string) (ModelPrice, PriceBasis) {
|
||
if mp, ok := p.prices[actual]; ok {
|
||
return mp, PriceByAnswerer
|
||
}
|
||
if mp, ok := p.prices[requested]; ok {
|
||
return mp, PriceByRequested
|
||
}
|
||
return p.defaultPrice, PriceByAnchor
|
||
}
|
||
|
||
// CostUSD prices one completion by its usage. Formula (invariant from
|
||
// llm.Usage: PromptTokens = total input = uncached + cached + cache-written):
|
||
//
|
||
// (prompt − cached − cacheCreation)·in + cached·cacheRead
|
||
// + cacheCreation·cacheWrite + (completion + reasoning)·out
|
||
//
|
||
// For providers without cache-write accounting the third term is 0 and the
|
||
// formula degrades to vojo's computeUSD.
|
||
func CostUSD(price ModelPrice, u llm.Usage) float64 {
|
||
// Each term is clamped to ≥0: a provider with an accounting bug that sends
|
||
// negative tokens would otherwise REDUCE committed and weaken the ceiling
|
||
// (review finding). The cost can only grow or be zero.
|
||
nn := func(x int) int {
|
||
if x < 0 {
|
||
return 0
|
||
}
|
||
return x
|
||
}
|
||
cached := nn(u.CachedTokens)
|
||
cacheWrite := nn(u.CacheCreationTokens)
|
||
completion := nn(u.CompletionTokens) + nn(u.ReasoningTokens)
|
||
uncached := nn(u.PromptTokens) - cached - cacheWrite
|
||
if uncached < 0 {
|
||
// cached+write > prompt (the provider reported a subset larger than the
|
||
// whole) — don't drive the uncached term negative.
|
||
uncached = 0
|
||
}
|
||
perTok := func(perM float64) float64 { return perM / 1_000_000 }
|
||
return float64(uncached)*perTok(price.InputPerM) +
|
||
float64(cached)*perTok(price.CachedPerM) +
|
||
float64(cacheWrite)*perTok(price.CacheWritePerM) +
|
||
float64(completion)*perTok(price.OutputPerM)
|
||
}
|
||
|
||
// EstimateUSD is the pre-call reservation estimate: assume the whole prompt
|
||
// misses the cache — or, worse, is entirely WRITTEN to the cache (Anthropic
|
||
// write ×1.25/×2 more expensive than input) — and the completion runs to maxTokens.
|
||
// Deliberately pessimistic: the real price must not exceed the reservation, otherwise
|
||
// settle breaks through a ceiling let past on the tolerance.
|
||
//
|
||
// reasoningBudgetTokens (D13.6) reserves an EXPLICIT allowance for providers that
|
||
// bill reasoning ADDITIVELY (xAI: reasoning tokens land ON TOP of completion, not
|
||
// inside maxTokens — the overshoot pricing.go used to only acknowledge). It is
|
||
// priced at OutputPerM (reasoning bills as output) and MUST be > 0 for a think-ON
|
||
// stage on such a provider (config fail-fasts otherwise — pipeline.go); it is 0 for
|
||
// subset-billing providers (deepseek/zai/kimi: reasoning ⊆ completion ≤ maxTokens,
|
||
// already covered) and for reasoning-off stages. This closes the D6.2 block on
|
||
// think-ON grok: the ceiling is no longer blind to the additive reasoning spend.
|
||
func EstimateUSD(price ModelPrice, promptTokens, maxTokens, reasoningBudgetTokens int) float64 {
|
||
inPerM := price.InputPerM
|
||
if price.CacheWritePerM > inPerM {
|
||
inPerM = price.CacheWritePerM
|
||
}
|
||
if reasoningBudgetTokens < 0 {
|
||
reasoningBudgetTokens = 0
|
||
}
|
||
return float64(promptTokens)*inPerM/1_000_000 +
|
||
float64(maxTokens+reasoningBudgetTokens)*price.OutputPerM/1_000_000
|
||
}
|