mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-28 00:42:37 -04:00
Run 30061545790 validated 7/8 providers exactly; the kimi subtest failed only on the test's own input_tokens>0 precondition. Moonshot's Anthropic-compatible endpoint reports the whole prompt under the cache-read bucket (input_tokens=0, 90 cache tokens riding total_tokens), and the stored cost was exactly right: 64 out x $15/M + 90 cache-read x $0.30/M = $0.000987. Require positive output/total tokens instead and let the existing cache-aware bounds cover the zero-input shape.
417 lines
18 KiB
Go
417 lines
18 KiB
Go
//go:build e2e
|
||
|
||
package agentnetwork
|
||
|
||
import (
|
||
"context"
|
||
"os"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
|
||
"github.com/netbirdio/netbird/e2e/harness"
|
||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||
)
|
||
|
||
// publishedPer1k carries the vendors' PUBLISHED per-1k-token USD rates for the
|
||
// models the live matrix can drive, keyed by the normalized model id the proxy
|
||
// stamps on the access-log row. These are intentionally hardcoded from the
|
||
// public price lists (not read from the proxy's pricing table) so the e2e run
|
||
// cross-checks the whole billing pipeline against an independent source: a
|
||
// wrong embedded rate, a broken model normalization, or a per-token-instead-of
|
||
// -per-1k-chunk regression (a 1000× blowup) all fail the assertion.
|
||
var publishedPer1k = map[string]struct{ in, out float64 }{
|
||
"gpt-4o-mini": {0.00015, 0.0006}, // $0.15 / $0.60 per MTok
|
||
"gpt-4o": {0.0025, 0.01}, // $2.50 / $10 per MTok
|
||
"claude-haiku-4-5": {0.001, 0.005}, // $1 / $5 per MTok
|
||
"claude-sonnet-4-5": {0.003, 0.015}, // $3 / $15 per MTok
|
||
"claude-sonnet-4-6": {0.003, 0.015},
|
||
"kimi-k3": {0.003, 0.015},
|
||
"anthropic.claude-haiku-4-5": {0.001, 0.005}, // Bedrock mirrors first-party rates
|
||
"anthropic.claude-sonnet-4-5": {0.003, 0.015},
|
||
"anthropic.claude-sonnet-4-6": {0.003, 0.015},
|
||
}
|
||
|
||
// validateAccessLogCost recomputes the expected USD cost of a live access-log
|
||
// row from the published per-1k rates and asserts the stored cost_usd matches.
|
||
//
|
||
// Anthropic-shape providers report prompt-cache buckets ADDITIVELY: they are
|
||
// billed (read ≈0.1×, write ≈1.25× the input rate) and folded into
|
||
// total_tokens, but not into input_tokens/output_tokens — so when
|
||
// total > in + out the expectation widens to the [all-read, all-write] band
|
||
// for the extra tokens instead of failing on correct cache billing.
|
||
//
|
||
// Models the proxy deliberately does not price (gateway-prefixed ids like
|
||
// "openai/gpt-4o-mini") must store cost 0, never a guessed rate.
|
||
func validateAccessLogCost(t *testing.T, pc providerCase, row api.AgentNetworkAccessLog) {
|
||
t.Helper()
|
||
model := catalogModel(pc)
|
||
t.Logf("[cost] %s: model=%s input_tokens=%d output_tokens=%d total_tokens=%d stored cost_usd=$%.6f",
|
||
pc.name, model, row.InputTokens, row.OutputTokens, row.TotalTokens, row.CostUsd)
|
||
|
||
rates, known := publishedPer1k[model]
|
||
if !known {
|
||
if strings.Contains(model, "/") {
|
||
t.Logf("[cost] %s: gateway-prefixed model %q is deliberately unpriced — expecting cost_usd=0", pc.name, model)
|
||
assert.Zerof(t, row.CostUsd,
|
||
"gateway-prefixed model %q is not in the pricing table so the cost meter must skip (cost 0), got %v", model, row.CostUsd)
|
||
return
|
||
}
|
||
t.Logf("[cost] %s: no published rate on file for model %q (env-overridden?); skipping cost validation", pc.name, model)
|
||
return
|
||
}
|
||
|
||
// input_tokens may legitimately be 0: providers that cache aggressively
|
||
// (Moonshot/Kimi reports the whole prompt under the cache-read bucket)
|
||
// leave the non-cached input at zero, with the prompt riding total_tokens.
|
||
// Output and total must always be present on a priced row.
|
||
require.Positive(t, row.OutputTokens, "priced row must carry output tokens")
|
||
require.Positive(t, row.TotalTokens, "priced row must carry total tokens")
|
||
|
||
base := float64(row.InputTokens)/1000*rates.in + float64(row.OutputTokens)/1000*rates.out
|
||
|
||
// Cache buckets ride total_tokens only (additive Anthropic/Bedrock shape).
|
||
cacheTokens := row.TotalTokens - row.InputTokens - row.OutputTokens
|
||
if cacheTokens < 0 {
|
||
cacheTokens = 0
|
||
}
|
||
|
||
if cacheTokens == 0 {
|
||
t.Logf("[cost] %s: expecting %d×$%v/1k + %d×$%v/1k = $%.6f (no cache tokens)",
|
||
pc.name, row.InputTokens, rates.in, row.OutputTokens, rates.out, base)
|
||
assert.InDeltaf(t, base, row.CostUsd, 1e-6,
|
||
"cost for %s (%s): %d in × $%v/1k + %d out × $%v/1k must equal the stored cost",
|
||
pc.name, model, row.InputTokens, rates.in, row.OutputTokens, rates.out)
|
||
return
|
||
}
|
||
|
||
lo := base + float64(cacheTokens)/1000*rates.in*0.1 // whole bucket read from cache
|
||
hi := base + float64(cacheTokens)/1000*rates.in*1.25 // whole bucket written to cache
|
||
t.Logf("[cost] %s: %d prompt-cache tokens ride total_tokens — expecting $%.6f base + cache in [$%.6f, $%.6f]",
|
||
pc.name, cacheTokens, base, lo, hi)
|
||
assert.GreaterOrEqualf(t, row.CostUsd, lo-1e-6,
|
||
"cost for %s (%s) below the all-cache-read floor (base %v, %d cache tokens)", pc.name, model, base, cacheTokens)
|
||
assert.LessOrEqualf(t, row.CostUsd, hi+1e-6,
|
||
"cost for %s (%s) above the all-cache-write ceiling (base %v, %d cache tokens)", pc.name, model, base, cacheTokens)
|
||
}
|
||
|
||
// providerCase is one entry in the live provider matrix. The same scenario runs
|
||
// for every available provider; availability is keyed off env vars so the suite
|
||
// covers whatever credentials are present (source ~/.llm-keys locally / set the
|
||
// Actions secrets in CI).
|
||
type providerCase struct {
|
||
name string
|
||
catalogID string
|
||
upstream string
|
||
apiKey string
|
||
model string // body model (chat/messages) or path model@version (vertex)
|
||
kind string // harness.WireChat, harness.WireMessages, or harness.WireVertex
|
||
project string // vertex only: GCP project for the rawPredict path
|
||
region string // vertex only: GCP region for the rawPredict path
|
||
pathPrefix string // base-URL path prefix the agent carries (e.g. "/anthropic" for Kimi)
|
||
}
|
||
|
||
// availableProviders builds the matrix from the provider env vars that are set.
|
||
func availableProviders() []providerCase {
|
||
var ps []providerCase
|
||
if k := os.Getenv("OPENAI_TOKEN"); k != "" {
|
||
ps = append(ps, providerCase{name: "openai", catalogID: "openai_api", upstream: "https://api.openai.com", apiKey: k, model: "gpt-4o-mini", kind: harness.WireChat})
|
||
}
|
||
if k := os.Getenv("ANTHROPIC_TOKEN"); k != "" {
|
||
ps = append(ps, providerCase{name: "anthropic", catalogID: "anthropic_api", upstream: "https://api.anthropic.com", apiKey: k, model: "claude-haiku-4-5", kind: harness.WireMessages})
|
||
}
|
||
if k := os.Getenv("KIMI_TOKEN"); k != "" {
|
||
// Kimi (Moonshot AI) serves two body shapes from the same key: OpenAI
|
||
// Chat Completions on the bare host (/v1/...) and the Anthropic
|
||
// Messages API under the /anthropic path prefix (the endpoint
|
||
// Moonshot's Claude Code guide uses). The provider keeps the bare
|
||
// default upstream and the AGENT carries the /anthropic prefix in
|
||
// its base URL — exactly the documented Claude Code / Kimi CLI
|
||
// setup (ANTHROPIC_BASE_URL=https://<endpoint>/anthropic) — so one
|
||
// provider serves both shapes and the prefix rides through to
|
||
// Moonshot. Run the Anthropic shape, the flagship Claude Code path;
|
||
// the OpenAI wire shape is covered live by the other chat-shaped
|
||
// matrix providers, and Kimi-over-chat passed with kimi-k3 before
|
||
// the single-model constraint surfaced (run #73 on the kimi feature
|
||
// branch). The platform serves this account exactly ONE model —
|
||
// kimi-k3 (kimi-k2-thinking and even kimi-latest return
|
||
// resource_not_found_error on both surfaces).
|
||
ps = append(ps, providerCase{name: "kimi", catalogID: "kimi_api", upstream: "https://api.moonshot.ai", apiKey: k, model: "kimi-k3", kind: harness.WireMessages, pathPrefix: "/anthropic"})
|
||
}
|
||
if k, u := os.Getenv("VERCEL_TOKEN"), os.Getenv("VERCEL_URL"); k != "" && u != "" {
|
||
ps = append(ps, providerCase{name: "vercel", catalogID: "vercel_ai_gateway", upstream: u, apiKey: k, model: "openai/gpt-4o-mini", kind: harness.WireChat})
|
||
}
|
||
if k, u := os.Getenv("OPENROUTER_TOKEN"), os.Getenv("OPENROUTER_URL"); k != "" && u != "" {
|
||
// Distinct model string from Vercel so each provider routes unambiguously
|
||
// while all are enabled together.
|
||
ps = append(ps, providerCase{name: "openrouter", catalogID: "openrouter", upstream: u, apiKey: k, model: "openai/gpt-4o", kind: harness.WireChat})
|
||
}
|
||
if k, u := os.Getenv("CLOUDFLARE_TOKEN"), os.Getenv("CLOUDFLARE_URL"); k != "" && u != "" {
|
||
// Cloudflare AI Gateway routes by a provider segment in the URL path;
|
||
// append the openai provider unless the gateway URL already carries one.
|
||
if !strings.Contains(u, "/openai") {
|
||
u = strings.TrimRight(u, "/") + "/openai"
|
||
}
|
||
// Raw model (distinct string from OpenAI's gpt-4o-mini).
|
||
ps = append(ps, providerCase{name: "cloudflare", catalogID: "cloudflare_ai_gateway", upstream: u, apiKey: k, model: "gpt-4o", kind: harness.WireChat})
|
||
}
|
||
// Vertex (vertex_ai_api): Anthropic-on-Vertex, path-routed, SA-OAuth
|
||
// (api_key = keyfile::<SA>). The model travels in the rawPredict path rather
|
||
// than the body, so the provider is created without a models array. Region
|
||
// defaults to "global" (host aiplatform.googleapis.com); a real region uses
|
||
// <region>-aiplatform.googleapis.com.
|
||
if sa := os.Getenv("GOOGLE_VERTEX_SA_BASE64"); sa != "" {
|
||
project := os.Getenv("GOOGLE_VERTEX_PROJECT")
|
||
if project != "" {
|
||
region := os.Getenv("GOOGLE_VERTEX_REGION")
|
||
if region == "" {
|
||
region = "global"
|
||
}
|
||
host := "aiplatform.googleapis.com"
|
||
if region != "global" {
|
||
host = region + "-aiplatform.googleapis.com"
|
||
}
|
||
model := os.Getenv("GOOGLE_VERTEX_MODEL")
|
||
if model == "" {
|
||
model = "claude-sonnet-4-5@20250929"
|
||
}
|
||
ps = append(ps, providerCase{
|
||
name: "vertex", catalogID: "vertex_ai_api", upstream: "https://" + host,
|
||
apiKey: "keyfile::" + sa, model: model, kind: harness.WireVertex,
|
||
project: project, region: region,
|
||
})
|
||
}
|
||
}
|
||
|
||
// Bedrock: path-routed, bearer auth. Model is the FULL cross-region
|
||
// inference-profile id exactly as AWS issues it — region-family prefix
|
||
// plus the date/version suffix. A bare or wrong-region id makes Bedrock
|
||
// reject the request with "The provided model identifier is invalid"
|
||
// before any inference runs. The proxy normalizes this id to the catalog
|
||
// key (anthropic.claude-haiku-4-5) for routing/pricing/allowlists.
|
||
// Defaults pair eu-central-1 with the eu.* profile; AWS_REGION overrides
|
||
// the region and the prefix follows its family.
|
||
if k := os.Getenv("AWS_BEARER_TOKEN_BEDROCK"); k != "" {
|
||
region := os.Getenv("AWS_REGION")
|
||
if region == "" {
|
||
region = "eu-central-1"
|
||
}
|
||
// A valid Bedrock inference-profile id (region prefix + date + version),
|
||
// overridable per account. `global.` profiles can be invoked from any
|
||
// region; set AWS_BEDROCK_MODEL to match the enabled profile for the token.
|
||
model := os.Getenv("AWS_BEDROCK_MODEL")
|
||
if model == "" {
|
||
model = "global.anthropic.claude-haiku-4-5-20251001-v1:0"
|
||
}
|
||
ps = append(ps, providerCase{name: "bedrock", catalogID: "bedrock_api", upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k, model: model, kind: harness.WireBedrock})
|
||
}
|
||
return ps
|
||
}
|
||
|
||
// providerRequest builds a create request for a matrix provider: enabled, with
|
||
// a uniquely-priced model for body-routed providers and none for the
|
||
// path-routed Vertex (whose model lives in the request path).
|
||
func providerRequest(pc providerCase) api.AgentNetworkProviderRequest {
|
||
req := api.AgentNetworkProviderRequest{
|
||
Name: pc.name,
|
||
ProviderId: pc.catalogID,
|
||
UpstreamUrl: pc.upstream,
|
||
ApiKey: &pc.apiKey,
|
||
Enabled: ptr(true),
|
||
}
|
||
if pc.kind != harness.WireVertex {
|
||
// The router matches the normalized catalog id. Bedrock's request model
|
||
// travels as a region-prefixed inference-profile id in the URL path
|
||
// (us.anthropic...), which the router strips before matching, so register
|
||
// the normalized form here or routing fails as model_not_routable.
|
||
modelID := pc.model
|
||
if pc.kind == harness.WireBedrock {
|
||
modelID = catalogModel(pc)
|
||
}
|
||
req.Models = &[]api.AgentNetworkProviderModel{
|
||
{Id: modelID, InputPer1k: 0.001, OutputPer1k: 0.002},
|
||
}
|
||
}
|
||
return req
|
||
}
|
||
|
||
// TestProvidersMatrix is Pillar 3: it provisions every available provider (all
|
||
// enabled, each with a unique model so routing stays unambiguous), runs proxy +
|
||
// client once, and drives the same live chat-completion scenario through each
|
||
// provider over the WireGuard tunnel. Each provider must return 200 and produce
|
||
// an ingested access-log row.
|
||
func TestProvidersMatrix(t *testing.T) {
|
||
matrix := availableProviders()
|
||
if len(matrix) == 0 {
|
||
t.Skip("no provider keys set; source ~/.llm-keys to run the provider matrix")
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
|
||
defer cancel()
|
||
|
||
// Group + setup key the client joins into; the policy authorizes it.
|
||
grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-agents"})
|
||
require.NoError(t, err, "create agents group")
|
||
t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) })
|
||
|
||
ephemeral := false
|
||
sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{
|
||
Name: "e2e-client",
|
||
Type: "reusable",
|
||
ExpiresIn: 86400,
|
||
UsageLimit: 0,
|
||
AutoGroups: []string{grp.Id},
|
||
Ephemeral: &ephemeral,
|
||
})
|
||
require.NoError(t, err, "mint setup key")
|
||
require.NotEmpty(t, sk.Key, "setup key plaintext")
|
||
|
||
// Create every provider, all enabled, each with a unique model string so the
|
||
// proxy's connect-time snapshot carries them all and model→provider routing
|
||
// is unambiguous (provider toggles after connect don't reconcile to the
|
||
// proxy, so we enable everything up front). The first create bootstraps the
|
||
// cluster.
|
||
ids := make([]string, 0, len(matrix))
|
||
for i, pc := range matrix {
|
||
req := providerRequest(pc)
|
||
if i == 0 {
|
||
req.BootstrapCluster = ptr(harness.AgentNetworkCluster)
|
||
}
|
||
prov, perr := srv.CreateProvider(ctx, req)
|
||
require.NoError(t, perr, "create provider %s", pc.name)
|
||
ids = append(ids, prov.Id)
|
||
id := prov.Id
|
||
t.Cleanup(func() { _ = srv.DeleteProvider(context.Background(), id) })
|
||
}
|
||
|
||
enabled := true
|
||
pol, err := srv.CreatePolicy(ctx, api.AgentNetworkPolicyRequest{
|
||
Name: "e2e-allow",
|
||
Enabled: &enabled,
|
||
SourceGroups: []string{grp.Id},
|
||
DestinationProviderIds: ids,
|
||
// Token limit at the 60s window floor with caps far above the few hundred
|
||
// tokens this suite drives, so it never blocks traffic but switches on
|
||
// usage metering, which is what makes consumption rows get recorded.
|
||
Limits: &api.AgentNetworkPolicyLimits{
|
||
TokenLimit: api.AgentNetworkPolicyTokenLimit{
|
||
Enabled: true,
|
||
GroupCap: 10_000_000,
|
||
UserCap: 10_000_000,
|
||
WindowSeconds: 60,
|
||
},
|
||
},
|
||
})
|
||
require.NoError(t, err, "create policy")
|
||
t.Cleanup(func() { _ = srv.DeletePolicy(context.Background(), pol.Id) })
|
||
|
||
settings, err := srv.GetSettings(ctx)
|
||
require.NoError(t, err, "read settings for endpoint")
|
||
require.NotEmpty(t, settings.Endpoint, "agent-network endpoint must be assigned")
|
||
|
||
// Proxy (global CLI token) + client, brought up once.
|
||
proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-proxy")
|
||
require.NoError(t, err, "mint proxy token via CLI")
|
||
px, err := harness.StartProxy(ctx, srv, proxyToken)
|
||
require.NoError(t, err, "start proxy")
|
||
t.Cleanup(func() { _ = px.Terminate(context.Background()) })
|
||
|
||
cl, err := harness.StartClient(ctx, srv, sk.Key)
|
||
require.NoError(t, err, "start client")
|
||
t.Cleanup(func() { _ = cl.Terminate(context.Background()) })
|
||
|
||
require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management")
|
||
// Probe first: the GET resolves the endpoint (DNS error fails) and its first packet wakes the lazy proxy peer, so WaitProxyPeer sees it connected; any HTTP status counts.
|
||
proxyIP, err := cl.ResolveProxyIP(ctx, settings.Endpoint)
|
||
require.NoError(t, err, "resolve agent-network endpoint to proxy IP")
|
||
if err := cl.WaitProxyPeer(ctx, 180*time.Second); err != nil {
|
||
t.Fatalf("client did not see the proxy peer: %v\n=== proxy logs ===\n%s", err, px.Logs(context.Background()))
|
||
}
|
||
|
||
for _, pc := range matrix {
|
||
pc := pc
|
||
t.Run(pc.name, func(t *testing.T) {
|
||
before, _ := srv.ListAccessLogs(ctx)
|
||
|
||
// Unique per provider so we can find this provider's row by its
|
||
// session id and confirm the marker propagated end-to-end.
|
||
sessionID := "e2e-session-" + pc.name
|
||
|
||
// Retry briefly to absorb tunnel/DNS jitter on the first call.
|
||
var code int
|
||
var body string
|
||
deadline := time.Now().Add(90 * time.Second)
|
||
for time.Now().Before(deadline) {
|
||
var c int
|
||
var b string
|
||
var cerr error
|
||
switch pc.kind {
|
||
case harness.WireVertex:
|
||
c, b, cerr = cl.Vertex(ctx, settings.Endpoint, proxyIP, pc.project, pc.region, pc.model, "Reply with exactly: pong", sessionID)
|
||
case harness.WireBedrock:
|
||
c, b, cerr = cl.Bedrock(ctx, settings.Endpoint, proxyIP, pc.model, "Reply with exactly: pong", sessionID)
|
||
default:
|
||
c, b, cerr = cl.ChatPrefixed(ctx, settings.Endpoint, proxyIP, pc.pathPrefix, pc.kind, pc.model, "Reply with exactly: pong", sessionID)
|
||
}
|
||
if cerr == nil {
|
||
code, body = c, b
|
||
if code == 200 {
|
||
break
|
||
}
|
||
}
|
||
time.Sleep(5 * time.Second)
|
||
}
|
||
require.Equal(t, 200, code, "chat through %s (%s %s) should return 200; body: %s", pc.name, pc.kind, pc.model, body)
|
||
t.Logf("[chat] %s: %s %s returned 200 (session %s)", pc.name, pc.kind, pc.model, sessionID)
|
||
|
||
require.Eventually(t, func() bool {
|
||
logs, lerr := srv.ListAccessLogs(ctx)
|
||
return lerr == nil && logs.TotalRecords > before.TotalRecords
|
||
}, 30*time.Second, 2*time.Second, "an access-log row should be ingested for %s", pc.name)
|
||
|
||
// The session id sent as x-session-id must round-trip into the
|
||
// access-log row for this provider.
|
||
var row api.AgentNetworkAccessLog
|
||
require.Eventually(t, func() bool {
|
||
logs, lerr := srv.ListAccessLogs(ctx)
|
||
if lerr != nil {
|
||
return false
|
||
}
|
||
for _, r := range logs.Data {
|
||
if r.SessionId != nil && *r.SessionId == sessionID {
|
||
row = r
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}, 30*time.Second, 2*time.Second, "session id %q must be recorded in an access-log row for %s", sessionID, pc.name)
|
||
|
||
// The stored cost must match the vendor's published per-1k rates
|
||
// applied to the row's token counts (cache-aware for the additive
|
||
// Anthropic/Bedrock buckets, zero for unpriced gateway model ids).
|
||
validateAccessLogCost(t, pc, row)
|
||
})
|
||
}
|
||
|
||
// Metering: the policy's uncapped token limit switches on usage recording,
|
||
// so the live traffic just driven must surface as consumption rows with
|
||
// positive token counts. Consumption is account-scoped (keyed by source
|
||
// group / user and time window, not per provider), and ingest is async, so
|
||
// poll for any row that has booked tokens.
|
||
require.Eventually(t, func() bool {
|
||
rows, lerr := srv.ListConsumption(ctx)
|
||
if lerr != nil {
|
||
return false
|
||
}
|
||
for _, r := range rows {
|
||
if r.TokensInput > 0 && r.TokensOutput > 0 {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}, 60*time.Second, 3*time.Second, "consumption must be recorded with positive token counts after live traffic")
|
||
}
|