textmachine/backend/internal/ledger/pricing.go

156 lines
7.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
}