textmachine/backend/internal/ledger/pricing_test.go

103 lines
4.9 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
import (
"math"
"testing"
"textmachine/backend/internal/llm"
)
func approx(t *testing.T, got, want float64, msg string) {
t.Helper()
if math.Abs(got-want) > 1e-12 {
t.Fatalf("%s: got %v, want %v", msg, got, want)
}
}
// Формула денег — ядро юнит-экономики; проверяем каждое слагаемое, включая
// новое (cache write), и деградацию к формуле vojo без кэш-полей.
func TestCostUSD(t *testing.T) {
price := ModelPrice{InputPerM: 2.0, CachedPerM: 0.2, CacheWritePerM: 2.5, OutputPerM: 10.0}
// Anthropic-подобный вызов: 10k всего входа = 6k некэш + 3k cache-read +
// 1k cache-write; 2k выхода.
u := llm.Usage{PromptTokens: 10_000, CachedTokens: 3_000, CacheCreationTokens: 1_000, CompletionTokens: 2_000}
want := 6_000*2.0/1e6 + 3_000*0.2/1e6 + 1_000*2.5/1e6 + 2_000*10.0/1e6
approx(t, CostUSD(price, u), want, "anthropic-style usage")
// OpenAI-совместимый вызов без кэш-полей — формула vojo.
u2 := llm.Usage{PromptTokens: 10_000, CompletionTokens: 2_000}
approx(t, CostUSD(price, u2), 10_000*2.0/1e6+2_000*10.0/1e6, "plain usage")
// Reasoning-токены биллятся по output-ставке ПОВЕРХ completion (xAI).
u3 := llm.Usage{PromptTokens: 1_000, CompletionTokens: 500, ReasoningTokens: 1_500}
approx(t, CostUSD(price, u3), 1_000*2.0/1e6+2_000*10.0/1e6, "additive reasoning")
// Защита от провайдера, отрапортовавшего cached > prompt: некэш-слагаемое
// зажимается в 0, а не уходит в минус.
u4 := llm.Usage{PromptTokens: 100, CachedTokens: 500, CompletionTokens: 10}
want4 := 0*2.0/1e6 + 500*0.2/1e6 + 10*10.0/1e6
approx(t, CostUSD(price, u4), want4, "cached > prompt guard")
// Отрицательные usage-числа (баг учёта провайдера) не должны УМЕНЬШАТЬ
// стоимость — каждое слагаемое зажимается в ≥0.
u5 := llm.Usage{PromptTokens: -1000, CachedTokens: -50, CompletionTokens: -200, ReasoningTokens: -5}
if got := CostUSD(price, u5); got != 0 {
t.Fatalf("negative usage must never reduce cost below 0, got %v", got)
}
}
func TestPriceForResponse(t *testing.T) {
def := ModelPrice{InputPerM: 0.14, OutputPerM: 0.28} // дешёвый якорь
p, err := NewPricer(map[string]ModelPrice{
"deepseek-v4-flash": def,
"claude-sonnet-5": {InputPerM: 2.0, OutputPerM: 10.0},
}, def)
if err != nil {
t.Fatal(err)
}
// Фактически ответившая известна — по ней.
if p.PriceForResponse("claude-sonnet-5", "claude-sonnet-5").InputPerM != 2.0 {
t.Fatal("known actual model must price by actual")
}
// Провайдер вернул датированный слаг премиум-модели — НЕ дешёвый якорь, а
// цена ЗАПРОШЕННОЙ модели (иначе систематический недоучёт премиума).
if got := p.PriceForResponse("claude-sonnet-5", "claude-sonnet-5-20260101").InputPerM; got != 2.0 {
t.Fatalf("unknown actual must fall back to requested model price (2.0), got %v", got)
}
// Обе неизвестны — дефолтный якорь (never $0).
if got := p.PriceForResponse("mystery", "also-mystery").InputPerM; got != 0.14 {
t.Fatalf("both unknown must fall back to default anchor, got %v", got)
}
}
func TestPricerNeverZero(t *testing.T) {
def := ModelPrice{InputPerM: 1, OutputPerM: 2}
p, err := NewPricer(map[string]ModelPrice{"known": {InputPerM: 5, CachedPerM: 1, OutputPerM: 10}}, def)
if err != nil {
t.Fatal(err)
}
if got := p.PriceFor("known").InputPerM; got != 5 {
t.Fatalf("known model price: got %v", got)
}
// Неизвестная модель (фактическая после фейловера) — дефолтный якорь, не $0.
if got := p.PriceFor("mystery-model").InputPerM; got != 1 {
t.Fatalf("unknown model must fall back to default price, got %v", got)
}
// $0-дефолт ослепил бы потолки — конструктор обязан отказать.
if _, err := NewPricer(nil, ModelPrice{}); err == nil {
t.Fatal("zero default price must be rejected")
}
}
func TestEstimateUSDIsPessimistic(t *testing.T) {
price := ModelPrice{InputPerM: 2.0, CachedPerM: 0.2, OutputPerM: 10.0}
est := EstimateUSD(price, 1_000, 2_000)
approx(t, est, 1_000*2.0/1e6+2_000*10.0/1e6, "estimate")
// Реальный вызов с кэш-хитом обязан стоить не больше оценки — иначе
// settle раздует committed выше потолка, пропущенного на допуске.
real := CostUSD(price, llm.Usage{PromptTokens: 1_000, CachedTokens: 900, CompletionTokens: 2_000})
if real > est {
t.Fatalf("real cost %v exceeds reservation estimate %v", real, est)
}
}