83 lines
3.7 KiB
Go
83 lines
3.7 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
|
||
}
|
||
|
||
// 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 {
|
||
uncached := u.PromptTokens - u.CachedTokens - u.CacheCreationTokens
|
||
if uncached < 0 {
|
||
// Defensive: a provider reporting cached > prompt would otherwise
|
||
// produce a negative term and understate cost.
|
||
uncached = 0
|
||
}
|
||
perTok := func(perM float64) float64 { return perM / 1_000_000 }
|
||
return float64(uncached)*perTok(price.InputPerM) +
|
||
float64(u.CachedTokens)*perTok(price.CachedPerM) +
|
||
float64(u.CacheCreationTokens)*perTok(price.CacheWritePerM) +
|
||
float64(u.CompletionTokens+u.ReasoningTokens)*perTok(price.OutputPerM)
|
||
}
|
||
|
||
// EstimateUSD is the pre-call reservation estimate: assume the whole prompt
|
||
// misses the cache and the completion runs to maxTokens. Deliberately
|
||
// pessimistic — the reservation is released down to the real cost at settle.
|
||
func EstimateUSD(price ModelPrice, promptTokens, maxTokens int) float64 {
|
||
return float64(promptTokens)*price.InputPerM/1_000_000 +
|
||
float64(maxTokens)*price.OutputPerM/1_000_000
|
||
}
|