129 lines
6.1 KiB
Go
129 lines
6.1 KiB
Go
// 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 пробивает потолок, пропущенный на допуске.
|
||
//
|
||
// 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
|
||
}
|