Files
netbird/proxy/internal/llm/pricing/pricing_test.go
Maycon Santos 766fcae3f8 [proxy,management] Conform the Agent Network endpoint to the LLM gateway protocol (#7154)
[proxy,management] Conform the Agent Network endpoint to the LLM gateway protocol

Reviewed the proxy against Claude Code's published gateway contract. The
transport layer already held up; fourteen gaps sat one layer up, in the model
catalog and in the non-inference endpoints clients call.

Two of them cost money. The catalog carried no claude-opus-5 or
claude-sonnet-5, so an operator could not authorise the models coding agents
default to — those requests denied as not-routable, or priced at zero where a
catch-all carried them. And gateway records pin ParserID "openai" while the
same record serves /v1/messages, so Anthropic responses were read with the
OpenAI parser, which never looks at message_start where input tokens live:
input metered as roughly zero on every stream and cost was skipped entirely.

The rest fix requests refused for structural rather than policy reasons: model
discovery denied for every account with a model allowlist, token counting
denied on Bedrock and mis-parsed on Vertex, startup probes refused and written
into the access log at every session start, and denials rendered in a shape no
LLM client parses. Two changes are additive by design — the deny body keeps
every field it had and adds the vendor's error object alongside, and body-level
identity injection is now gated on the request's dialect so it stops sending
OpenAI-shape fields into Anthropic bodies that reject them.

The end-to-end work turned up one more: the discovery filter treated any slash
in a model id as a gateway prefix, which would have dropped every self-hosted
"Qwen/..." model from the picker.
2026-08-23 20:02:33 +02:00

197 lines
8.3 KiB
Go

package pricing
import (
"math"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestCost_OpenAICachedSubsetDiscount proves OpenAI's cached input
// tokens are billed at the configured cached_input_per_1k rate while
// the non-cached remainder of input_tokens is billed at the regular
// rate. Critical because OpenAI returns cached_tokens as a SUBSET of
// prompt_tokens — naïvely charging the cached count on top of
// prompt_tokens would double-bill that portion.
func TestCost_OpenAICachedSubsetDiscount(t *testing.T) {
tbl := &Table{entries: map[string]map[string]Entry{
"openai": {"gpt-4o": {
InputPer1K: 0.0025, // 0.0025 USD per 1k input tokens
OutputPer1K: 0.01,
CachedInputPer1K: 0.00125, // 0.5x discount on cached
}},
}}
// 1000 prompt tokens, 750 of which were cached. 250 non-cached
// at regular rate, 750 cached at the discount rate, 500 output.
cost, ok := tbl.Cost("openai", "gpt-4o", 1000, 500, 750, 0)
require.True(t, ok, "known model resolves")
want := (250.0/1000.0)*0.0025 + (750.0/1000.0)*0.00125 + (500.0/1000.0)*0.01
assert.InDelta(t, want, cost, 1e-12,
"cached subset must bill at the discount rate; non-cached remainder at regular rate")
}
// TestCost_OpenAICachedFallsBackToInputRate covers the fallback
// contract: when CachedInputPer1K is unset (zero), cached tokens bill
// at the regular input rate.
func TestCost_OpenAICachedFallsBackToInputRate(t *testing.T) {
tbl := &Table{entries: map[string]map[string]Entry{
"openai": {"gpt-4o": {InputPer1K: 0.0025, OutputPer1K: 0.01}},
}}
cost, ok := tbl.Cost("openai", "gpt-4o", 1000, 500, 750, 0)
require.True(t, ok)
want := 0.0025 + (500.0/1000.0)*0.01
assert.InDelta(t, want, cost, 1e-12,
"absent cached_input_per_1k rate must fall back to input_per_1k")
}
// TestCost_OpenAIClampsCachedToInputCount is the defensive guard
// against malformed upstream responses that report cached_tokens >
// prompt_tokens. We clamp so the formula never produces a negative
// "non-cached remainder" multiplied by the input rate.
func TestCost_OpenAIClampsCachedToInputCount(t *testing.T) {
tbl := &Table{entries: map[string]map[string]Entry{
"openai": {"gpt-4o": {InputPer1K: 0.0025, OutputPer1K: 0.01, CachedInputPer1K: 0.00125}},
}}
cost, ok := tbl.Cost("openai", "gpt-4o", 100, 0, 9999, 0)
require.True(t, ok)
// All 100 cached, 0 non-cached. Output is 0.
want := (100.0 / 1000.0) * 0.00125
assert.InDelta(t, want, cost, 1e-12,
"cached count > input count must clamp to input — never bill negative non-cached tokens")
}
// TestCost_AnthropicCacheReadAndCreationAreAdditive proves the
// Anthropic shape: cache_read and cache_creation tokens are
// ADDITIVE to input_tokens (not subset), each billed at its own
// configured rate.
func TestCost_AnthropicCacheReadAndCreationAreAdditive(t *testing.T) {
tbl := &Table{entries: map[string]map[string]Entry{
"anthropic": {"claude-sonnet": {
InputPer1K: 0.003,
OutputPer1K: 0.015,
CacheReadPer1K: 0.0003, // 0.1x of input
CacheCreationPer1K: 0.00375, // 1.25x of input
}},
}}
// 256 regular input + 768 cache_read + 512 cache_creation +
// 200 output. Each input bucket bills at its own rate.
cost, ok := tbl.Cost("anthropic", "claude-sonnet", 256, 200, 768, 512)
require.True(t, ok, "known model resolves")
want := (256.0/1000.0)*0.003 +
(768.0/1000.0)*0.0003 +
(512.0/1000.0)*0.00375 +
(200.0/1000.0)*0.015
assert.InDelta(t, want, cost, 1e-12,
"each Anthropic input bucket must bill at its own configured rate")
}
// TestCost_AnthropicCacheRatesFallBackToInput covers the no-rate
// path: when neither CacheReadPer1K nor CacheCreationPer1K is set,
// cache tokens bill at the regular input rate.
func TestCost_AnthropicCacheRatesFallBackToInput(t *testing.T) {
tbl := &Table{entries: map[string]map[string]Entry{
"anthropic": {"claude-sonnet": {InputPer1K: 0.003, OutputPer1K: 0.015}},
}}
cost, ok := tbl.Cost("anthropic", "claude-sonnet", 256, 200, 768, 512)
require.True(t, ok)
// Without overrides: every input bucket at input_per_1k.
want := ((256.0+768.0+512.0)/1000.0)*0.003 + (200.0/1000.0)*0.015
assert.InDelta(t, want, cost, 1e-12,
"absent cache rates must fall back to input_per_1k")
}
// TestEntryCosts_SurfaceSelectsFormula pins that the formula branches on
// the SURFACE, not on which table the entry came from: the same entry
// bills a subset carve-out on "openai", additive buckets on
// "anthropic"/"bedrock", and ignores cache counts everywhere else. This
// is what keeps per-provider-record entries (looked up by record id)
// mathematically identical to defaults-table entries.
func TestEntryCosts_SurfaceSelectsFormula(t *testing.T) {
e := Entry{InputPer1K: 0.002, OutputPer1K: 0.01, CachedInputPer1K: 0.001, CacheReadPer1K: 0.0002, CacheCreationPer1K: 0.0025}
openai := EntryCosts(e, "openai", 1000, 0, 400, 300)
assert.InDelta(t, (600.0/1000.0)*0.002+(400.0/1000.0)*0.001, openai.TotalUSD, 1e-12,
"openai: cached is a subset, cacheCreation ignored")
anthropic := EntryCosts(e, "anthropic", 1000, 0, 400, 300)
assert.InDelta(t, 0.002+(400.0/1000.0)*0.0002+(300.0/1000.0)*0.0025, anthropic.TotalUSD, 1e-12,
"anthropic: cache buckets are additive")
bedrock := EntryCosts(e, "bedrock", 1000, 0, 400, 300)
assert.InDelta(t, anthropic.TotalUSD, bedrock.TotalUSD, 1e-12, "bedrock shares the anthropic formula")
other := EntryCosts(e, "gemini", 1000, 0, 400, 300)
assert.InDelta(t, 0.002, other.TotalUSD, 1e-12, "unknown surface: cache counts ignored")
}
// TestEntryCosts_ClampsNegativeTokens: malformed upstream counts must
// never produce a negative cost.
func TestEntryCosts_ClampsNegativeTokens(t *testing.T) {
e := Entry{InputPer1K: 0.002, OutputPer1K: 0.01}
c := EntryCosts(e, "openai", -50, -10, -5, -3)
assert.Zero(t, c.TotalUSD, "all-negative counts clamp to zero cost")
}
func TestTableCost_NilSafe(t *testing.T) {
var t1 *Table
cost, ok := t1.Cost("x", "y", 1, 1, 0, 0)
assert.False(t, ok, "nil table reports unknown")
assert.Zero(t, cost, "nil table returns zero cost")
assert.False(t, t1.Has("x", "y"), "nil table has nothing")
}
func TestNewTable_ValidatesRates(t *testing.T) {
good := map[string]map[string]EntryJSON{
"openai": {"gpt-4o": {InputPer1K: 0.0025, OutputPer1K: 0.01, CachedInputPer1K: 0.00125}},
}
tbl, err := NewTable(good)
require.NoError(t, err)
cost, ok := tbl.Cost("openai", "gpt-4o", 1000, 1000, 0, 0)
require.True(t, ok, "entry survives the wire conversion")
assert.InDelta(t, 0.0125, cost, 1e-9)
_, ok = tbl.Cost("openai", "unknown-model", 1, 1, 0, 0)
assert.False(t, ok, "unknown model misses")
for name, bad := range map[string]EntryJSON{
"negative input": {InputPer1K: -1, OutputPer1K: 0.01},
"NaN output": {InputPer1K: 0.01, OutputPer1K: math.NaN()},
"Inf cache read": {InputPer1K: 0.01, OutputPer1K: 0.01, CacheReadPer1K: math.Inf(1)},
"negative cached": {InputPer1K: 0.01, OutputPer1K: 0.01, CachedInputPer1K: -0.001},
} {
_, err := NewTable(map[string]map[string]EntryJSON{"openai": {"m": bad}})
assert.Error(t, err, "case %q must be rejected so a corrupt config fails the chain build instead of mispricing", name)
}
}
func TestNewTable_NilAndEmpty(t *testing.T) {
tbl, err := NewTable(nil)
require.NoError(t, err, "nil map builds an empty (never-matching) table")
_, ok := tbl.Cost("openai", "gpt-4o", 1, 1, 0, 0)
assert.False(t, ok, "empty table prices nothing")
entries, err := NewEntries(nil)
require.NoError(t, err)
assert.Empty(t, entries, "nil in, empty (never-matching) map out for the per-record map")
}
// TestLookup_DatedAnthropicIDFallsBackToUndated covers a client pinning a
// release date on a model priced under its undated id. Without the
// fallback the request records no cost at all.
func TestLookup_DatedAnthropicIDFallsBackToUndated(t *testing.T) {
table, err := NewTable(map[string]map[string]EntryJSON{
"anthropic": {
"claude-sonnet-4-5": {InputPer1K: 0.003, OutputPer1K: 0.015},
},
})
require.NoError(t, err, "table must build from a valid defaults map")
entry, ok := table.Lookup("anthropic", "claude-sonnet-4-5-20250929")
require.True(t, ok, "a dated id must resolve to the undated entry")
assert.InDelta(t, 0.003, entry.InputPer1K, 1e-9, "dated id must bill at the registered rate")
_, ok = table.Lookup("anthropic", "claude-sonnet-9-9-20250929")
assert.False(t, ok, "an unknown family must stay unpriced")
}