72 lines
3.3 KiB
Go
72 lines
3.3 KiB
Go
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")
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|