textmachine/backend/internal/ledger/pricing.go

129 lines
6.1 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. Дисциплина 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
}