Files
netbird/proxy/internal/llm/pricing/catalog_sync_test.go
Maycon Santos 3fac269506 [proxy, e2e] validate LLM cost calculation across providers and bill Converse cache tokens
Field report: a Bedrock Claude Sonnet 4.6 request showing 3 input / 1514
output tokens displayed a cost of $0.1372 instead of the expected $0.0227.
Root cause analysis: the calculation is correct — the request's first call
also wrote a ~30.5k-token prompt cache (cache_creation_input_tokens, billed
at 1.25x input per AWS pricing), which is folded into total_tokens and the
cost but not visible next to the input/output counts. Lock the pipeline down
with tests so any real calculation regression fails loudly:

- Add a provider cost matrix test driving the real proxy pipeline
  (llm_request_parser -> llm_response_parser -> cost_meter) with realistic
  wire fixtures for every metered surface (OpenAI JSON/SSE incl. cached
  subset, Anthropic JSON/SSE incl. cache buckets, Bedrock InvokeModel and
  Converse in both buffered and streaming form, Vertex path-routed, Kimi
  Anthropic-shape, unpriced gateway-prefixed ids) against the embedded
  default pricing table, asserting exact USD amounts derived from the
  published per-million prices. Covers the reported scenario byte-for-byte
  ($0.022719 bare, $0.137199 with the 30,528-token cache write) and would
  catch a per-token-instead-of-per-1k-chunk regression as a 1000x blowup.

- Pin the management catalog (dashboard-displayed prices) to the proxy's
  embedded pricing table so the two can never drift apart silently.

- Validate cost end-to-end in the live e2e provider matrix: each provider's
  ingested access-log row must carry a cost_usd matching the vendor's
  published per-1k rates applied to the row's token counts (cache-aware for
  the additive Anthropic/Bedrock buckets, zero for gateway-prefixed model
  ids the meter deliberately skips).

- Fix a real under-billing bug the audit surfaced: the Bedrock Converse
  shapes report prompt-cache usage as camelCase cacheReadInputTokens /
  cacheWriteInputTokens, which neither the buffered parser nor the
  converse-stream metadata handler read - cached Converse traffic was
  metered without its cache buckets. Parse both fields into the same Usage
  buckets as the InvokeModel snake_case fields.
2026-07-24 01:45:06 +00:00

63 lines
2.5 KiB
Go

package pricing
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
)
// catalogPricingProviders maps each metered catalog provider id to the
// pricing-table provider key(s) its requests are billed under (the
// llm.Parser surface the proxy meters that provider with). A catalog
// provider whose requests can hit more than one parser surface (Kimi
// serves both the OpenAI and Anthropic body shapes off one host) must
// carry matching entries in every listed table.
//
// Gateway/custom catalog entries (LiteLLM, Portkey, vLLM, …) publish no
// model list, so they never appear here.
var catalogPricingProviders = map[string][]string{
"openai_api": {"openai"},
"azure_openai_api": {"openai"},
"mistral_api": {"openai"},
"anthropic_api": {"anthropic"},
"vertex_ai_api": {"anthropic"}, // Anthropic-on-Vertex: bare claude-* ids, anthropic parser
"bedrock_api": {"bedrock"},
"kimi_api": {"openai", "anthropic"},
}
// TestDefaultPricing_MatchesCatalog pins the management catalog (the prices
// the dashboard displays) to the proxy's embedded default pricing table (the
// prices the cost meter bills). A drift between the two makes correct costs
// look wrong — the dashboard advertises one rate while the proxy meters
// another — so every catalog model must resolve to a pricing entry with
// byte-identical input/output per-1k rates.
func TestDefaultPricing_MatchesCatalog(t *testing.T) {
table := DefaultTable()
for _, p := range catalog.All() {
if len(p.Models) == 0 {
continue // gateways / custom endpoints publish no models
}
keys, metered := catalogPricingProviders[p.ID]
require.Truef(t, metered,
"catalog provider %q publishes models but has no pricing-table mapping — add it to catalogPricingProviders and defaults_pricing.yaml",
p.ID)
for _, m := range p.Models {
for _, key := range keys {
entry, ok := table.entries[key][m.ID]
require.Truef(t, ok,
"catalog model %s/%s must have a %q pricing entry or the cost meter silently skips it (cost.skipped=unknown_model)",
p.ID, m.ID, key)
assert.Equalf(t, m.InputPer1k, entry.InputPer1K,
"input rate drift for %s/%s: dashboard shows %v, proxy bills %v", p.ID, m.ID, m.InputPer1k, entry.InputPer1K)
assert.Equalf(t, m.OutputPer1k, entry.OutputPer1K,
"output rate drift for %s/%s: dashboard shows %v, proxy bills %v", p.ID, m.ID, m.OutputPer1k, entry.OutputPer1K)
}
}
}
}