Files
netbird/proxy/internal/llm/bedrock_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

81 lines
4.2 KiB
Go

package llm
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestBedrockParser_ParseResponse_Invoke(t *testing.T) {
body := []byte(`{"usage":{"input_tokens":13,"output_tokens":5,"cache_read_input_tokens":2,"cache_creation_input_tokens":4}}`)
u, err := BedrockParser{}.ParseResponse(200, "application/json", body)
require.NoError(t, err)
require.Equal(t, int64(13), u.InputTokens, "invoke input tokens")
require.Equal(t, int64(5), u.OutputTokens, "invoke output tokens")
require.Equal(t, int64(2), u.CachedInputTokens, "invoke cache-read tokens")
require.Equal(t, int64(4), u.CacheCreationTokens, "invoke cache-creation tokens")
require.Equal(t, int64(13+5+2+4), u.TotalTokens, "invoke total is additive")
}
func TestBedrockParser_ParseResponse_Converse(t *testing.T) {
body := []byte(`{"output":{"message":{"content":[{"text":"pong"}]}},"usage":{"inputTokens":11,"outputTokens":3,"totalTokens":14}}`)
u, err := BedrockParser{}.ParseResponse(200, "application/json", body)
require.NoError(t, err)
require.Equal(t, int64(11), u.InputTokens, "converse camelCase input tokens")
require.Equal(t, int64(3), u.OutputTokens, "converse camelCase output tokens")
require.Equal(t, int64(14), u.TotalTokens, "converse uses provider total")
}
// TestBedrockParser_ParseResponse_ConverseCacheBuckets proves the Converse
// camelCase cache fields (cacheReadInputTokens / cacheWriteInputTokens) land
// in the same Usage buckets as the InvokeModel snake_case fields — the cost
// meter bills them, so dropping them silently under-counts cached requests.
func TestBedrockParser_ParseResponse_ConverseCacheBuckets(t *testing.T) {
body := []byte(`{"usage":{"inputTokens":11,"outputTokens":3,"cacheReadInputTokens":7,"cacheWriteInputTokens":9}}`)
u, err := BedrockParser{}.ParseResponse(200, "application/json", body)
require.NoError(t, err)
require.Equal(t, int64(11), u.InputTokens, "converse input tokens")
require.Equal(t, int64(3), u.OutputTokens, "converse output tokens")
require.Equal(t, int64(7), u.CachedInputTokens, "converse cache-read tokens")
require.Equal(t, int64(9), u.CacheCreationTokens, "converse cache-write tokens")
require.Equal(t, int64(11+3+7+9), u.TotalTokens, "total backfill is additive when the provider omits totalTokens")
}
func TestBedrockParser_ParseResponse_StreamingUnsupported(t *testing.T) {
_, err := BedrockParser{}.ParseResponse(200, "application/vnd.amazon.eventstream", []byte("binary"))
require.ErrorIs(t, err, ErrStreamingUnsupported, "event-stream must route to the streaming accumulator")
}
func TestBedrockParser_ParseResponse_NonSuccess(t *testing.T) {
_, err := BedrockParser{}.ParseResponse(404, "application/json", []byte(`{"message":"gated"}`))
require.ErrorIs(t, err, ErrNotLLMResponse, "non-200 is not an LLM response")
}
func TestBedrockParser_ExtractCompletion(t *testing.T) {
invoke := BedrockParser{}.ExtractCompletion(200, "application/json", []byte(`{"content":[{"text":"a"},{"text":"b"}]}`))
require.Equal(t, "a\nb", invoke, "invoke completion joins content parts")
converse := BedrockParser{}.ExtractCompletion(200, "application/json", []byte(`{"output":{"message":{"content":[{"text":"x"}]}}}`))
require.Equal(t, "x", converse, "converse completion reads output.message.content")
}
func TestBedrockParser_ExtractPrompt(t *testing.T) {
invoke := BedrockParser{}.ExtractPrompt([]byte(`{"messages":[{"role":"user","content":"hi"}]}`))
require.Equal(t, "user: hi", invoke, "invoke prompt reads anthropic content string")
converse := BedrockParser{}.ExtractPrompt([]byte(`{"messages":[{"role":"user","content":[{"text":"hello"}]}]}`))
require.Equal(t, "user: hello", converse, "converse prompt reads content parts")
}
func TestBedrockParser_DetectFromURL(t *testing.T) {
require.True(t, BedrockParser{}.DetectFromURL("/model/eu.anthropic.claude/invoke"), "invoke path")
require.True(t, BedrockParser{}.DetectFromURL("/model/x/converse-stream"), "converse-stream path")
require.False(t, BedrockParser{}.DetectFromURL("/v1/chat/completions"), "openai path is not bedrock")
}
func TestBedrockParser_RegisteredByName(t *testing.T) {
p, ok := ParserByName(ProviderNameBedrock)
require.True(t, ok, "bedrock parser is registered")
require.Equal(t, ProviderNameBedrock, p.ProviderName())
}