// Package ledger is the money seam: per-model prices, usage→USD computation, // and reserve/settle bookkeeping against the store. Дисциплина vojo целиком // (Р7): биллинг по usage из ответа API, reserve-before-call / settle-after, // потолки $; расширение Фазы 0 — стоимость записи в кэш (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 (у vojo якорем была XAIModel; здесь мультипровайдерный конфиг требует // явного 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 } // PriceForResponse prices a completion by the model that ACTUALLY answered, // but if that id is unknown (провайдер вернул канонизированный/датированный // слаг вроде claude-sonnet-5-2026xxxx) falls back to the REQUESTED model's // price BEFORE the global anchor — иначе премиум-ответ книжился бы по дешёвому // дефолту (систематический недоучёт, находка ревью). Только если неизвестны // обе — дефолтный якорь (never $0). func (p *Pricer) PriceForResponse(requested, actual string) ModelPrice { if mp, ok := p.prices[actual]; ok { return mp } if mp, ok := p.prices[requested]; ok { return mp } return p.defaultPrice } // 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 { // Каждое слагаемое зажимается в ≥0: провайдер с багом учёта, приславший // отрицательные токены, иначе УМЕНЬШИЛ бы committed и ослабил потолок // (находка ревью). Стоимость может только расти или быть нулевой. 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 (провайдер отрапортовал подмножество больше // целого) — не уводим некэш-слагаемое в минус. 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 дороже input) — and the completion runs to maxTokens. // Deliberately pessimistic: реальная цена не должна превышать резерв, иначе // settle пробивает потолок, пропущенный на допуске. Известный остаточный // случай: additive reasoning-токены (xAI) биллятся ПОВЕРХ completion и оценкой // не покрыты — допустимый овершут ≤ одной резервации, как у донора. func EstimateUSD(price ModelPrice, promptTokens, maxTokens int) float64 { inPerM := price.InputPerM if price.CacheWritePerM > inPerM { inPerM = price.CacheWritePerM } return float64(promptTokens)*inPerM/1_000_000 + float64(maxTokens)*price.OutputPerM/1_000_000 }