mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-19 05:09:06 +02:00
Merge remote-tracking branch 'origin/main' into refactor/permissions-manager
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
)
|
||||
|
||||
// Metadata keys the proxy stamps on agent-network access-log entries. These
|
||||
// mirror the constants in proxy/internal/middleware/keys.go and form the wire
|
||||
// contract between the proxy and management; management flattens them into
|
||||
// queryable columns. Keep in sync with the proxy side.
|
||||
const (
|
||||
metaKeyProvider = "llm.provider"
|
||||
metaKeyModel = "llm.model"
|
||||
metaKeyResolvedProviderID = "llm.resolved_provider_id"
|
||||
metaKeySelectedPolicyID = "llm.selected_policy_id"
|
||||
metaKeyPolicyDecision = "llm_policy.decision"
|
||||
metaKeyPolicyReason = "llm_policy.reason"
|
||||
metaKeyInputTokens = "llm.input_tokens" //nolint:gosec // metadata key name, not a credential
|
||||
metaKeyOutputTokens = "llm.output_tokens" //nolint:gosec // metadata key name, not a credential
|
||||
metaKeyTotalTokens = "llm.total_tokens" //nolint:gosec // metadata key name, not a credential
|
||||
metaKeyCachedInputTokens = "llm.cached_input_tokens" //nolint:gosec // metadata key name, not a credential
|
||||
metaKeyCacheCreationTokens = "llm.cache_creation_tokens" //nolint:gosec // metadata key name, not a credential
|
||||
metaKeyCostUSDInput = "cost.usd_input"
|
||||
metaKeyCostUSDCachedInput = "cost.usd_cached_input"
|
||||
metaKeyCostUSDCacheCreate = "cost.usd_cache_creation"
|
||||
metaKeyCostUSDOutput = "cost.usd_output"
|
||||
metaKeyStream = "llm.stream"
|
||||
metaKeySessionID = "llm.session_id"
|
||||
metaKeyAuthorisingGroups = "llm.authorising_groups"
|
||||
metaKeyRequestPrompt = "llm.request_prompt"
|
||||
metaKeyResponseCompletion = "llm.response_completion"
|
||||
)
|
||||
|
||||
// IngestAccessLog flattens the metadata-bearing reverse-proxy access-log entry
|
||||
// and persists it in the dedicated agent-network tables (instead of the shared
|
||||
// reverse-proxy table), in two parts:
|
||||
//
|
||||
// - The stripped usage record is written unconditionally — usage/cost is
|
||||
// collected on every request regardless of the account's log-collection
|
||||
// toggle (the proxy ships a usage-only entry when logging is disabled).
|
||||
// - The full access-log row (with request detail + prompt) is written only
|
||||
// when the account's EnableLogCollection setting is on. This setting read
|
||||
// is the authoritative gate; the proxy-side strip is defense in depth.
|
||||
func IngestAccessLog(ctx context.Context, s store.Store, logEntry *accesslogs.AccessLogEntry) error {
|
||||
entry, groups := flattenAccessLog(logEntry)
|
||||
|
||||
usage, usageGroups := usageFromFlattenedLog(entry, groups)
|
||||
if err := s.CreateAgentNetworkUsage(ctx, usage, usageGroups); err != nil {
|
||||
log.WithContext(ctx).WithFields(log.Fields{
|
||||
"account_id": entry.AccountID,
|
||||
"model": entry.Model,
|
||||
}).Errorf("failed to save agent-network usage: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
settings, err := s.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, entry.AccountID)
|
||||
if err != nil {
|
||||
// No settings row (or a transient read error) means we can't confirm
|
||||
// log collection is enabled — usage is already saved, so skip the full
|
||||
// row rather than fail the whole ingest.
|
||||
log.WithContext(ctx).Debugf("skipping full agent-network access-log row for account %s: %v", entry.AccountID, err)
|
||||
return nil
|
||||
}
|
||||
if !settings.EnableLogCollection {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.CreateAgentNetworkAccessLog(ctx, entry, groups); err != nil {
|
||||
log.WithContext(ctx).WithFields(log.Fields{
|
||||
"account_id": entry.AccountID,
|
||||
"service_id": entry.ServiceID,
|
||||
"model": entry.Model,
|
||||
"status": entry.StatusCode,
|
||||
}).Errorf("failed to save agent-network access log: %v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// flattenAccessLog converts a reverse-proxy AccessLogEntry (whose LLM
|
||||
// dimensions live in the opaque Metadata map) into the flattened
|
||||
// agent-network row + authorising-group child rows.
|
||||
func flattenAccessLog(e *accesslogs.AccessLogEntry) (*types.AgentNetworkAccessLog, []types.AgentNetworkAccessLogGroup) {
|
||||
meta := e.Metadata
|
||||
|
||||
var sourceIP string
|
||||
if e.GeoLocation.ConnectionIP != nil {
|
||||
sourceIP = e.GeoLocation.ConnectionIP.String()
|
||||
}
|
||||
|
||||
entry := &types.AgentNetworkAccessLog{
|
||||
ID: e.ID,
|
||||
AccountID: e.AccountID,
|
||||
ServiceID: e.ServiceID,
|
||||
Timestamp: e.Timestamp,
|
||||
UserID: e.UserId,
|
||||
SourceIP: sourceIP,
|
||||
Method: e.Method,
|
||||
Host: e.Host,
|
||||
Path: e.Path,
|
||||
Duration: e.Duration,
|
||||
StatusCode: e.StatusCode,
|
||||
AuthMethod: e.AuthMethodUsed,
|
||||
BytesUpload: e.BytesUpload,
|
||||
BytesDownload: e.BytesDownload,
|
||||
|
||||
Provider: meta[metaKeyProvider],
|
||||
Model: meta[metaKeyModel],
|
||||
SessionID: meta[metaKeySessionID],
|
||||
ResolvedProviderID: meta[metaKeyResolvedProviderID],
|
||||
SelectedPolicyID: meta[metaKeySelectedPolicyID],
|
||||
Decision: meta[metaKeyPolicyDecision],
|
||||
DenyReason: meta[metaKeyPolicyReason],
|
||||
InputTokens: parseMetaInt(meta, metaKeyInputTokens),
|
||||
OutputTokens: parseMetaInt(meta, metaKeyOutputTokens),
|
||||
TotalTokens: parseMetaInt(meta, metaKeyTotalTokens),
|
||||
CachedInputTokens: parseMetaInt(meta, metaKeyCachedInputTokens),
|
||||
CacheCreationTokens: parseMetaInt(meta, metaKeyCacheCreationTokens),
|
||||
InputCostUSD: parseMetaFloat(meta, metaKeyCostUSDInput),
|
||||
CachedInputCostUSD: parseMetaFloat(meta, metaKeyCostUSDCachedInput),
|
||||
CacheCreationCostUSD: parseMetaFloat(meta, metaKeyCostUSDCacheCreate),
|
||||
OutputCostUSD: parseMetaFloat(meta, metaKeyCostUSDOutput),
|
||||
Stream: parseMetaBool(meta, metaKeyStream),
|
||||
RequestPrompt: meta[metaKeyRequestPrompt],
|
||||
ResponseCompletion: meta[metaKeyResponseCompletion],
|
||||
}
|
||||
|
||||
var groups []types.AgentNetworkAccessLogGroup
|
||||
for _, gid := range parseGroupCSV(meta[metaKeyAuthorisingGroups]) {
|
||||
groups = append(groups, types.AgentNetworkAccessLogGroup{
|
||||
LogID: entry.ID,
|
||||
GroupID: gid,
|
||||
AccountID: entry.AccountID,
|
||||
})
|
||||
}
|
||||
return entry, groups
|
||||
}
|
||||
|
||||
// usageFromFlattenedLog derives the stripped usage record (and its group child
|
||||
// rows) from an already-flattened access-log entry. The usage row shares the
|
||||
// log's ID so the two correlate.
|
||||
func usageFromFlattenedLog(e *types.AgentNetworkAccessLog, groups []types.AgentNetworkAccessLogGroup) (*types.AgentNetworkUsage, []types.AgentNetworkUsageGroup) {
|
||||
usage := &types.AgentNetworkUsage{
|
||||
ID: e.ID,
|
||||
AccountID: e.AccountID,
|
||||
Timestamp: e.Timestamp,
|
||||
UserID: e.UserID,
|
||||
ResolvedProviderID: e.ResolvedProviderID,
|
||||
Provider: e.Provider,
|
||||
Model: e.Model,
|
||||
SessionID: e.SessionID,
|
||||
InputTokens: e.InputTokens,
|
||||
OutputTokens: e.OutputTokens,
|
||||
TotalTokens: e.TotalTokens,
|
||||
CachedInputTokens: e.CachedInputTokens,
|
||||
CacheCreationTokens: e.CacheCreationTokens,
|
||||
InputCostUSD: e.InputCostUSD,
|
||||
CachedInputCostUSD: e.CachedInputCostUSD,
|
||||
CacheCreationCostUSD: e.CacheCreationCostUSD,
|
||||
OutputCostUSD: e.OutputCostUSD,
|
||||
}
|
||||
|
||||
usageGroups := make([]types.AgentNetworkUsageGroup, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
usageGroups = append(usageGroups, types.AgentNetworkUsageGroup{
|
||||
UsageID: usage.ID,
|
||||
GroupID: g.GroupID,
|
||||
AccountID: g.AccountID,
|
||||
})
|
||||
}
|
||||
return usage, usageGroups
|
||||
}
|
||||
|
||||
// parseMetaInt parses a non-negative token count. Negative or unparseable
|
||||
// values are clamped to 0 so a malformed metric can't persist a negative
|
||||
// counter.
|
||||
func parseMetaInt(meta map[string]string, key string) int64 {
|
||||
if v, err := strconv.ParseInt(strings.TrimSpace(meta[key]), 10, 64); err == nil && v >= 0 {
|
||||
return v
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// parseMetaFloat parses a non-negative, finite cost. Negative, NaN, Inf, or
|
||||
// unparseable values are clamped to 0 so a malformed metric can't poison the
|
||||
// stored cost.
|
||||
func parseMetaFloat(meta map[string]string, key string) float64 {
|
||||
if v, err := strconv.ParseFloat(strings.TrimSpace(meta[key]), 64); err == nil && v >= 0 && !math.IsInf(v, 0) {
|
||||
return v
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func parseMetaBool(meta map[string]string, key string) bool {
|
||||
v, _ := strconv.ParseBool(strings.TrimSpace(meta[key]))
|
||||
return v
|
||||
}
|
||||
|
||||
// parseGroupCSV splits the comma-separated authorising-group id list the proxy
|
||||
// emits, trimming blanks and de-duplicating. Dedup matters because the group
|
||||
// rows are keyed by (log_id, group_id) / (usage_id, group_id): a repeated id
|
||||
// in the CSV would otherwise produce a duplicate primary key and fail the
|
||||
// insert transaction.
|
||||
func parseGroupCSV(raw string) []string {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
seen := make(map[string]struct{}, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
if _, dup := seen[p]; dup {
|
||||
continue
|
||||
}
|
||||
seen[p] = struct{}{}
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
)
|
||||
|
||||
// newIngestTestEntry builds an agent-network reverse-proxy access-log entry whose
|
||||
// LLM dimensions live in the opaque Metadata map, as the proxy ships it.
|
||||
func newIngestTestEntry() *accesslogs.AccessLogEntry {
|
||||
return &accesslogs.AccessLogEntry{
|
||||
ID: "log-1",
|
||||
AccountID: testAccountID,
|
||||
ServiceID: "svc-1",
|
||||
Timestamp: time.Now().UTC(),
|
||||
Method: "POST",
|
||||
Host: testEndpoint,
|
||||
Path: "/v1/chat/completions",
|
||||
StatusCode: 200,
|
||||
UserId: "user-1",
|
||||
AgentNetwork: true,
|
||||
Metadata: map[string]string{
|
||||
metaKeyProvider: "openai",
|
||||
metaKeyModel: "gpt-5.4",
|
||||
metaKeyResolvedProviderID: "prov-1",
|
||||
metaKeySessionID: "sess-1",
|
||||
metaKeyInputTokens: "100",
|
||||
metaKeyOutputTokens: "50",
|
||||
metaKeyTotalTokens: "1174",
|
||||
metaKeyCachedInputTokens: "256",
|
||||
metaKeyCacheCreationTokens: "768",
|
||||
metaKeyCostUSDInput: "0.0071",
|
||||
metaKeyCostUSDCachedInput: "0.0009",
|
||||
metaKeyCostUSDCacheCreate: "0.0020",
|
||||
metaKeyCostUSDOutput: "0.0023",
|
||||
metaKeyStream: "true",
|
||||
metaKeyRequestPrompt: "hello",
|
||||
metaKeyResponseCompletion: "world",
|
||||
// repeated id must be de-duplicated before the group rows insert.
|
||||
metaKeyAuthorisingGroups: "grp-eng,grp-eng,grp-ops",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestIngestAccessLog_RealStore_LogCollectionOff persists the usage ledger
|
||||
// unconditionally but skips the full access-log row when the account hasn't
|
||||
// opted into log collection.
|
||||
func TestIngestAccessLog_RealStore_LogCollectionOff(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||
require.NoError(t, err, "real sqlite test store must come up")
|
||||
defer cleanup()
|
||||
|
||||
settings := newSynthTestSettings()
|
||||
settings.EnableLogCollection = false
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, settings))
|
||||
|
||||
require.NoError(t, IngestAccessLog(ctx, s, newIngestTestEntry()))
|
||||
|
||||
usage, err := s.GetAgentNetworkUsageRows(ctx, store.LockingStrengthNone, testAccountID, types.AgentNetworkAccessLogFilter{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, usage, 1, "usage row must be written even with log collection off")
|
||||
assert.Equal(t, int64(100), usage[0].InputTokens, "input tokens must round-trip from metadata")
|
||||
assert.Equal(t, int64(50), usage[0].OutputTokens, "output tokens must round-trip from metadata")
|
||||
assert.Equal(t, int64(256), usage[0].CachedInputTokens, "cache-read tokens must round-trip from metadata")
|
||||
assert.Equal(t, int64(768), usage[0].CacheCreationTokens, "cache-write tokens must round-trip from metadata")
|
||||
// The per-bucket breakdown is the only cost state stored, and must survive
|
||||
// the write/read cycle as real columns — usage rows are the only cost
|
||||
// record for accounts with log collection off, so a dropped column here
|
||||
// loses the split permanently.
|
||||
assert.InDelta(t, 0.0071, usage[0].InputCostUSD, 1e-9, "input cost must round-trip from metadata")
|
||||
assert.InDelta(t, 0.0009, usage[0].CachedInputCostUSD, 1e-9, "cache-read cost must round-trip from metadata")
|
||||
assert.InDelta(t, 0.0020, usage[0].CacheCreationCostUSD, 1e-9, "cache-write cost must round-trip from metadata")
|
||||
assert.InDelta(t, 0.0023, usage[0].OutputCostUSD, 1e-9, "output cost must round-trip from metadata")
|
||||
// Aggregates are derived from the stored columns, never stored themselves.
|
||||
assert.InDelta(t, 0.0123, usage[0].TotalCostUSD(), 1e-9, "total is derived from the stored buckets")
|
||||
assert.InDelta(t, 0.0029, usage[0].CacheCostUSD(), 1e-9, "cache cost is derived from the two cache buckets")
|
||||
|
||||
logs, _, err := s.GetAgentNetworkAccessLogs(ctx, store.LockingStrengthNone, testAccountID, types.AgentNetworkAccessLogFilter{})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, logs, "full access-log row must be skipped while log collection is off")
|
||||
}
|
||||
|
||||
// TestIngestAccessLog_RealStore_LogCollectionOn writes both the usage ledger and
|
||||
// the full access-log row once the account opts in, carrying the request detail
|
||||
// and prompt through.
|
||||
func TestIngestAccessLog_RealStore_LogCollectionOn(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||
require.NoError(t, err, "real sqlite test store must come up")
|
||||
defer cleanup()
|
||||
|
||||
settings := newSynthTestSettings()
|
||||
settings.EnableLogCollection = true
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, settings))
|
||||
|
||||
require.NoError(t, IngestAccessLog(ctx, s, newIngestTestEntry()))
|
||||
|
||||
usage, err := s.GetAgentNetworkUsageRows(ctx, store.LockingStrengthNone, testAccountID, types.AgentNetworkAccessLogFilter{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, usage, 1, "usage row must be written when log collection is on")
|
||||
|
||||
logs, total, err := s.GetAgentNetworkAccessLogs(ctx, store.LockingStrengthNone, testAccountID, types.AgentNetworkAccessLogFilter{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), total, "exactly one access-log row expected")
|
||||
require.Len(t, logs, 1, "full access-log row must be written when log collection is on")
|
||||
assert.Equal(t, "gpt-5.4", logs[0].Model, "model must flatten from metadata")
|
||||
assert.Equal(t, int64(256), logs[0].CachedInputTokens, "cache-read tokens must flatten from metadata")
|
||||
assert.Equal(t, int64(768), logs[0].CacheCreationTokens, "cache-write tokens must flatten from metadata")
|
||||
assert.InDelta(t, 0.0029, logs[0].CacheCostUSD(), 1e-9, "cache cost is derived from the two cache buckets")
|
||||
assert.InDelta(t, 0.0123, logs[0].TotalCostUSD(), 1e-9, "total is derived from the stored buckets")
|
||||
assert.InDelta(t, 0.0071, logs[0].InputCostUSD, 1e-9, "input cost must flatten from metadata")
|
||||
assert.InDelta(t, 0.0009, logs[0].CachedInputCostUSD, 1e-9, "cache-read cost must flatten from metadata")
|
||||
assert.InDelta(t, 0.0020, logs[0].CacheCreationCostUSD, 1e-9, "cache-write cost must flatten from metadata")
|
||||
assert.InDelta(t, 0.0023, logs[0].OutputCostUSD, 1e-9, "output cost must flatten from metadata")
|
||||
assert.Equal(t, "hello", logs[0].RequestPrompt, "prompt must be retained when log collection is on")
|
||||
assert.Equal(t, "world", logs[0].ResponseCompletion, "completion must be retained when log collection is on")
|
||||
assert.True(t, logs[0].Stream, "stream flag must flatten from metadata")
|
||||
}
|
||||
|
||||
func TestParseGroupCSV_DedupAndTrim(t *testing.T) {
|
||||
assert.Nil(t, parseGroupCSV(""), "empty CSV yields no groups")
|
||||
assert.Equal(t, []string{"a", "b"}, parseGroupCSV(" a , b , a ,"),
|
||||
"group CSV must trim, drop blanks, and de-duplicate preserving first-seen order")
|
||||
}
|
||||
|
||||
func TestParseMetaInt_ClampsNegativeAndJunk(t *testing.T) {
|
||||
meta := map[string]string{"ok": " 42 ", "neg": "-5", "junk": "abc"}
|
||||
assert.Equal(t, int64(42), parseMetaInt(meta, "ok"), "valid count parses with surrounding space trimmed")
|
||||
assert.Equal(t, int64(0), parseMetaInt(meta, "neg"), "negative count clamps to 0")
|
||||
assert.Equal(t, int64(0), parseMetaInt(meta, "junk"), "unparseable count clamps to 0")
|
||||
assert.Equal(t, int64(0), parseMetaInt(meta, "missing"), "missing key clamps to 0")
|
||||
}
|
||||
|
||||
func TestParseMetaFloat_ClampsNegativeInfAndJunk(t *testing.T) {
|
||||
meta := map[string]string{"ok": "1.5", "neg": "-1", "inf": "Inf", "junk": "x"}
|
||||
assert.InDelta(t, 1.5, parseMetaFloat(meta, "ok"), 1e-9, "valid cost parses")
|
||||
assert.Equal(t, float64(0), parseMetaFloat(meta, "neg"), "negative cost clamps to 0")
|
||||
assert.Equal(t, float64(0), parseMetaFloat(meta, "inf"), "non-finite cost clamps to 0")
|
||||
assert.Equal(t, float64(0), parseMetaFloat(meta, "junk"), "unparseable cost clamps to 0")
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
)
|
||||
|
||||
// baseTime is a fixed reference so session timestamps (and therefore the
|
||||
// default MAX(timestamp) DESC ordering) are deterministic across runs.
|
||||
var baseTime = time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// accessLogRow builds an agent-network access-log row for the shared test
|
||||
// account. Functional options tweak the LLM dimensions a given test cares
|
||||
// about; everything else gets a sane, allow/200 default.
|
||||
func accessLogRow(id, sessionID string, ts time.Time, opts ...func(*types.AgentNetworkAccessLog)) *types.AgentNetworkAccessLog {
|
||||
e := &types.AgentNetworkAccessLog{
|
||||
ID: id,
|
||||
AccountID: testAccountID,
|
||||
ServiceID: "svc-1",
|
||||
Timestamp: ts,
|
||||
UserID: "user-1",
|
||||
SessionID: sessionID,
|
||||
Method: "POST",
|
||||
Host: testEndpoint,
|
||||
Path: "/v1/chat/completions",
|
||||
StatusCode: 200,
|
||||
Decision: "allow",
|
||||
Provider: "openai",
|
||||
Model: "gpt-5.4",
|
||||
ResolvedProviderID: "prov-1",
|
||||
InputTokens: 100,
|
||||
OutputTokens: 50,
|
||||
TotalTokens: 150,
|
||||
InputCostUSD: 0.01,
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(e)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func withUser(u string) func(*types.AgentNetworkAccessLog) {
|
||||
return func(e *types.AgentNetworkAccessLog) { e.UserID = u }
|
||||
}
|
||||
|
||||
func withModel(m string) func(*types.AgentNetworkAccessLog) {
|
||||
return func(e *types.AgentNetworkAccessLog) { e.Model = m }
|
||||
}
|
||||
|
||||
func withProvider(vendor, resolvedID string) func(*types.AgentNetworkAccessLog) {
|
||||
return func(e *types.AgentNetworkAccessLog) {
|
||||
e.Provider = vendor
|
||||
e.ResolvedProviderID = resolvedID
|
||||
}
|
||||
}
|
||||
|
||||
func withDeny(reason string) func(*types.AgentNetworkAccessLog) {
|
||||
return func(e *types.AgentNetworkAccessLog) {
|
||||
e.Decision = "deny"
|
||||
e.DenyReason = reason
|
||||
e.StatusCode = 403
|
||||
}
|
||||
}
|
||||
|
||||
func withTokens(in, out, total int64, cost float64) func(*types.AgentNetworkAccessLog) {
|
||||
return func(e *types.AgentNetworkAccessLog) {
|
||||
e.InputTokens = in
|
||||
e.OutputTokens = out
|
||||
e.TotalTokens = total
|
||||
e.InputCostUSD = cost
|
||||
}
|
||||
}
|
||||
|
||||
func withGroups(gids ...string) func(*types.AgentNetworkAccessLog) {
|
||||
return func(e *types.AgentNetworkAccessLog) { e.GroupIDs = gids }
|
||||
}
|
||||
|
||||
// seedAccessLogs writes rows (and their authorising-group child rows) directly
|
||||
// into the store, bypassing ingest so a test can control every dimension.
|
||||
func seedAccessLogs(t *testing.T, s store.Store, rows ...*types.AgentNetworkAccessLog) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
for _, r := range rows {
|
||||
var groups []types.AgentNetworkAccessLogGroup
|
||||
for _, g := range r.GroupIDs {
|
||||
groups = append(groups, types.AgentNetworkAccessLogGroup{
|
||||
LogID: r.ID,
|
||||
GroupID: g,
|
||||
AccountID: r.AccountID,
|
||||
})
|
||||
}
|
||||
require.NoError(t, s.CreateAgentNetworkAccessLog(ctx, r, groups), "seed access-log row %s", r.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func newSessionsTestStore(t *testing.T) store.Store {
|
||||
t.Helper()
|
||||
s, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir())
|
||||
require.NoError(t, err, "real sqlite test store must come up")
|
||||
t.Cleanup(cleanup)
|
||||
return s
|
||||
}
|
||||
|
||||
// sessionIDs projects the session ids from a page of session summaries, in
|
||||
// order, for concise ordering assertions.
|
||||
func sessionIDs(sessions []*types.AgentNetworkAccessLogSession) []string {
|
||||
out := make([]string, 0, len(sessions))
|
||||
for _, s := range sessions {
|
||||
out = append(out, s.SessionID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestAccessLogSessions_FoldAndAggregate verifies that multiple entries sharing
|
||||
// a session id fold into one summary with summed usage, distinct
|
||||
// provider/model lists, a deny rollup, and correct first/last activity bounds.
|
||||
func TestAccessLogSessions_FoldAndAggregate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newSessionsTestStore(t)
|
||||
|
||||
// sess-a: three entries spanning 3 minutes, two providers/models, one deny.
|
||||
seedAccessLogs(t, s,
|
||||
accessLogRow("a1", "sess-a", baseTime,
|
||||
withProvider("openai", "prov-openai"), withModel("gpt-5.4"),
|
||||
withTokens(100, 50, 150, 0.01), withGroups("grp-eng")),
|
||||
accessLogRow("a2", "sess-a", baseTime.Add(1*time.Minute),
|
||||
withProvider("anthropic", "prov-anthropic"), withModel("claude-haiku-4-5"),
|
||||
withTokens(200, 80, 280, 0.02), withGroups("grp-eng", "grp-ops")),
|
||||
accessLogRow("a3", "sess-a", baseTime.Add(2*time.Minute),
|
||||
withProvider("openai", "prov-openai"), withModel("gpt-5.4"),
|
||||
withTokens(10, 5, 15, 0.001), withDeny("llm_policy.token_cap_exceeded")),
|
||||
// sess-b: a single allow entry.
|
||||
accessLogRow("b1", "sess-b", baseTime.Add(30*time.Minute),
|
||||
withTokens(1, 2, 3, 0.5)),
|
||||
)
|
||||
|
||||
sessions, total, err := s.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, testAccountID, types.AgentNetworkAccessLogFilter{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(2), total, "two distinct sessions")
|
||||
require.Len(t, sessions, 2)
|
||||
|
||||
// Default sort is last-activity DESC, so sess-b (12:30) precedes sess-a (12:02).
|
||||
require.Equal(t, []string{"sess-b", "sess-a"}, sessionIDs(sessions))
|
||||
|
||||
a := sessions[1]
|
||||
assert.Equal(t, "sess-a", a.SessionID)
|
||||
assert.Equal(t, 3, a.RequestCount, "three requests folded")
|
||||
assert.Equal(t, int64(310), a.InputTokens, "input tokens summed")
|
||||
assert.Equal(t, int64(135), a.OutputTokens, "output tokens summed")
|
||||
assert.Equal(t, int64(445), a.TotalTokens, "total tokens summed")
|
||||
assert.InDelta(t, 0.031, a.TotalCostUSD(), 1e-9, "cost summed")
|
||||
assert.Equal(t, "deny", a.Decision, "any deny makes the session a deny")
|
||||
assert.ElementsMatch(t, []string{"openai", "anthropic"}, a.Providers, "distinct providers")
|
||||
assert.ElementsMatch(t, []string{"gpt-5.4", "claude-haiku-4-5"}, a.Models, "distinct models")
|
||||
assert.ElementsMatch(t, []string{"grp-eng", "grp-ops"}, a.GroupIDs, "union of authorising groups")
|
||||
assert.Equal(t, baseTime, a.StartedAt.UTC(), "started at is the earliest entry")
|
||||
assert.Equal(t, baseTime.Add(2*time.Minute), a.EndedAt.UTC(), "ended at is the latest entry")
|
||||
assert.Len(t, a.Entries, 3, "entries carried through")
|
||||
|
||||
b := sessions[0]
|
||||
assert.Equal(t, "sess-b", b.SessionID)
|
||||
assert.Equal(t, 1, b.RequestCount)
|
||||
assert.Equal(t, "allow", b.Decision)
|
||||
}
|
||||
|
||||
// TestAccessLogSessions_SessionlessRowsAreSingletons verifies that entries with
|
||||
// no session id each form their own singleton session keyed by the row id.
|
||||
func TestAccessLogSessions_SessionlessRowsAreSingletons(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newSessionsTestStore(t)
|
||||
|
||||
seedAccessLogs(t, s,
|
||||
accessLogRow("solo-1", "", baseTime),
|
||||
accessLogRow("solo-2", "", baseTime.Add(time.Minute)),
|
||||
// A real session with two entries, to prove they don't merge with the singletons.
|
||||
accessLogRow("g1", "sess-x", baseTime.Add(2*time.Minute)),
|
||||
accessLogRow("g2", "sess-x", baseTime.Add(3*time.Minute)),
|
||||
)
|
||||
|
||||
sessions, total, err := s.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, testAccountID, types.AgentNetworkAccessLogFilter{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(3), total, "two singletons + one grouped session")
|
||||
require.Len(t, sessions, 3)
|
||||
|
||||
for _, sess := range sessions {
|
||||
if sess.SessionID == "sess-x" {
|
||||
assert.Equal(t, 2, sess.RequestCount, "grouped session folds both entries")
|
||||
} else {
|
||||
assert.Empty(t, sess.SessionID, "singleton carries no session id")
|
||||
assert.Equal(t, 1, sess.RequestCount, "singleton has exactly one request")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccessLogSessions_Pagination verifies that paging returns the correct
|
||||
// slice of sessions in stable order, with a stable total across pages and no
|
||||
// overlap between pages.
|
||||
func TestAccessLogSessions_Pagination(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newSessionsTestStore(t)
|
||||
|
||||
// Five sessions, each a single entry, with increasing timestamps so the
|
||||
// default MAX(timestamp) DESC order is sess-5, sess-4, sess-3, sess-2, sess-1.
|
||||
rows := make([]*types.AgentNetworkAccessLog, 0, 5)
|
||||
for i := 1; i <= 5; i++ {
|
||||
rows = append(rows, accessLogRow(
|
||||
"row-"+itoa(i), "sess-"+itoa(i), baseTime.Add(time.Duration(i)*time.Minute)))
|
||||
}
|
||||
seedAccessLogs(t, s, rows...)
|
||||
|
||||
page := func(p int) []*types.AgentNetworkAccessLogSession {
|
||||
sessions, total, err := s.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, testAccountID,
|
||||
types.AgentNetworkAccessLogFilter{Page: p, PageSize: 2})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(5), total, "total session count is stable across pages")
|
||||
return sessions
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{"sess-5", "sess-4"}, sessionIDs(page(1)), "page 1: two newest")
|
||||
assert.Equal(t, []string{"sess-3", "sess-2"}, sessionIDs(page(2)), "page 2: next two")
|
||||
assert.Equal(t, []string{"sess-1"}, sessionIDs(page(3)), "page 3: remaining one")
|
||||
assert.Empty(t, page(4), "page 4: past the end is empty")
|
||||
}
|
||||
|
||||
// TestAccessLogSessions_Filtering verifies each filter is applied before
|
||||
// grouping, so the session set (and total) reflect only matching entries.
|
||||
func TestAccessLogSessions_Filtering(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newSessionsTestStore(t)
|
||||
|
||||
seedAccessLogs(t, s,
|
||||
accessLogRow("r1", "sess-1", baseTime.Add(1*time.Minute),
|
||||
withUser("alice"), withProvider("openai", "prov-openai"), withModel("gpt-5.4")),
|
||||
accessLogRow("r2", "sess-2", baseTime.Add(2*time.Minute),
|
||||
withUser("bob"), withProvider("anthropic", "prov-anthropic"), withModel("claude-haiku-4-5"),
|
||||
withDeny("llm_policy.no_authorized_provider"), withGroups("grp-ops")),
|
||||
accessLogRow("r3", "sess-3", baseTime.Add(3*time.Minute),
|
||||
withUser("alice"), withProvider("openai", "prov-openai"), withModel("gpt-5.4"),
|
||||
withGroups("grp-eng")),
|
||||
)
|
||||
|
||||
filterCases := []struct {
|
||||
name string
|
||||
filter types.AgentNetworkAccessLogFilter
|
||||
wantIDs []string
|
||||
wantTot int64
|
||||
}{
|
||||
{
|
||||
name: "by session id",
|
||||
filter: types.AgentNetworkAccessLogFilter{SessionID: strp("sess-2")},
|
||||
wantIDs: []string{"sess-2"},
|
||||
wantTot: 1,
|
||||
},
|
||||
{
|
||||
name: "by user id",
|
||||
filter: types.AgentNetworkAccessLogFilter{UserID: strp("alice")},
|
||||
wantIDs: []string{"sess-3", "sess-1"}, // last-activity DESC
|
||||
wantTot: 2,
|
||||
},
|
||||
{
|
||||
name: "by model",
|
||||
filter: types.AgentNetworkAccessLogFilter{Models: []string{"claude-haiku-4-5"}},
|
||||
wantIDs: []string{"sess-2"},
|
||||
wantTot: 1,
|
||||
},
|
||||
{
|
||||
name: "by resolved provider id",
|
||||
filter: types.AgentNetworkAccessLogFilter{ProviderIDs: []string{"prov-openai"}},
|
||||
wantIDs: []string{"sess-3", "sess-1"},
|
||||
wantTot: 2,
|
||||
},
|
||||
{
|
||||
name: "by decision deny",
|
||||
filter: types.AgentNetworkAccessLogFilter{Decision: strp("deny")},
|
||||
wantIDs: []string{"sess-2"},
|
||||
wantTot: 1,
|
||||
},
|
||||
{
|
||||
name: "by authorising group",
|
||||
filter: types.AgentNetworkAccessLogFilter{GroupIDs: []string{"grp-eng"}},
|
||||
wantIDs: []string{"sess-3"},
|
||||
wantTot: 1,
|
||||
},
|
||||
{
|
||||
name: "by date range excludes earlier",
|
||||
filter: types.AgentNetworkAccessLogFilter{
|
||||
StartDate: tp(baseTime.Add(90 * time.Second)), // after r1 (12:01), before r2 (12:02)
|
||||
},
|
||||
wantIDs: []string{"sess-3", "sess-2"},
|
||||
wantTot: 2,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range filterCases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
sessions, total, err := s.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, testAccountID, tc.filter)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.wantTot, total, "filtered total")
|
||||
assert.Equal(t, tc.wantIDs, sessionIDs(sessions), "filtered session ids in order")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccessLogSessions_SortByCost verifies session-level aggregate sorting:
|
||||
// ordering by summed cost, ascending and descending.
|
||||
func TestAccessLogSessions_SortByCost(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newSessionsTestStore(t)
|
||||
|
||||
// cheap: 0.01 total; mid: 0.05 total; pricey: 0.20 total (two entries).
|
||||
seedAccessLogs(t, s,
|
||||
accessLogRow("c1", "cheap", baseTime.Add(1*time.Minute), withTokens(1, 1, 2, 0.01)),
|
||||
accessLogRow("m1", "mid", baseTime.Add(2*time.Minute), withTokens(1, 1, 2, 0.05)),
|
||||
accessLogRow("p1", "pricey", baseTime.Add(3*time.Minute), withTokens(1, 1, 2, 0.15)),
|
||||
accessLogRow("p2", "pricey", baseTime.Add(4*time.Minute), withTokens(1, 1, 2, 0.05)),
|
||||
)
|
||||
|
||||
desc, total, err := s.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, testAccountID,
|
||||
types.AgentNetworkAccessLogFilter{SortBy: "cost_usd", SortOrder: "desc"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(3), total)
|
||||
assert.Equal(t, []string{"pricey", "mid", "cheap"}, sessionIDs(desc), "descending by summed cost")
|
||||
|
||||
asc, _, err := s.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, testAccountID,
|
||||
types.AgentNetworkAccessLogFilter{SortBy: "cost_usd", SortOrder: "asc"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"cheap", "mid", "pricey"}, sessionIDs(asc), "ascending by summed cost")
|
||||
}
|
||||
|
||||
// strp / tp / itoa are tiny local helpers to keep the filter table terse.
|
||||
func strp(s string) *string { return &s }
|
||||
|
||||
func tp(t time.Time) *time.Time { return &t }
|
||||
|
||||
func itoa(i int) string { return string(rune('0' + i)) }
|
||||
@@ -0,0 +1,15 @@
|
||||
package agentnetwork
|
||||
|
||||
import "github.com/netbirdio/netbird/management/server/affectedpeers"
|
||||
|
||||
// init registers the agent-network service synthesiser with the affectedpeers
|
||||
// resolver. Agent-network reverse-proxy services are synthesised on demand and
|
||||
// never persisted, so the resolver can't load them from the store; without them
|
||||
// it can't fold the embedded proxy peer into the affected set on a client
|
||||
// group/peer change, and the proxy never learns a newly authorised client until
|
||||
// it reconnects. Registered here (rather than via a direct
|
||||
// affectedpeers→agentnetwork import) to avoid an import cycle
|
||||
// (agentnetwork → account → affectedpeers).
|
||||
func init() {
|
||||
affectedpeers.SetAgentNetworkSynthesizer(SynthesizeServices)
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
)
|
||||
|
||||
// GetAgentConfigForUser returns the Agent Network setup the calling user's
|
||||
// groups authorize. It deliberately performs no role permission check:
|
||||
// the result is scoped to the caller's own groups, which is strictly
|
||||
// tighter than any role gate, so every authenticated user (any role) may
|
||||
// read it. The group source matches enforcement: the proxy authorizes
|
||||
// each Agent Network request against the calling user's groups as well —
|
||||
// session validation resolves them from the same user record's
|
||||
// auto-groups — so this answer and the proxy's verdict are computed from
|
||||
// the same memberships.
|
||||
func (m *managerImpl) GetAgentConfigForUser(ctx context.Context, accountID, userID string) (*types.AgentConfig, error) {
|
||||
user, err := m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user: %w", err)
|
||||
}
|
||||
return m.agentConfigForGroups(ctx, accountID, user.AutoGroups)
|
||||
}
|
||||
|
||||
// agentConfigForGroups computes the effective Agent Network setup for
|
||||
// a set of caller groups: the account endpoint plus, per authorized
|
||||
// provider, the effective model set. It mirrors what the proxy enforces
|
||||
// at request time — the policy filter matches filterApplicablePolicies,
|
||||
// the model logic matches policyPermitsModel, and orphan providers
|
||||
// (enabled but referenced by no applicable policy) are omitted just like
|
||||
// the router synthesizer omits them — so the answer never advertises
|
||||
// anything the proxy would refuse.
|
||||
//
|
||||
// Configured tracks the account, not the caller: once the account has an
|
||||
// endpoint every member gets it, with Providers empty for those no policy
|
||||
// covers yet. The dashboard shows each user the same connection config
|
||||
// regardless of role, and an empty provider list tells them to ask for
|
||||
// access. Only the account having no Agent Network at all reads as not
|
||||
// configured. Providers stays caller-scoped either way — the endpoint on
|
||||
// its own authorizes nothing, and the proxy still refuses every request
|
||||
// no policy permits.
|
||||
func (m *managerImpl) agentConfigForGroups(ctx context.Context, accountID string, groupIDs []string) (*types.AgentConfig, error) {
|
||||
notConfigured := &types.AgentConfig{Providers: []types.AgentConfigProvider{}}
|
||||
|
||||
settings, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID)
|
||||
switch {
|
||||
case err == nil:
|
||||
case isNotFound(err):
|
||||
return notConfigured, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("get agent network settings: %w", err)
|
||||
}
|
||||
if settings.Endpoint() == "" {
|
||||
return notConfigured, nil
|
||||
}
|
||||
|
||||
authorized, applicable, err := m.authorizedProvidersForGroups(ctx, accountID, groupIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := &types.AgentConfig{
|
||||
Configured: true,
|
||||
Endpoint: "https://" + settings.Endpoint(),
|
||||
Providers: make([]types.AgentConfigProvider, 0, len(authorized)),
|
||||
}
|
||||
if len(authorized) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var guardrailsByID map[string]*types.Guardrail
|
||||
if anyPolicyHasGuardrails(applicable) {
|
||||
guardrailsByID, err = m.loadGuardrailsByID(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, p := range authorized {
|
||||
allAllowed, models := effectiveModelsForProvider(p, policiesForProvider(applicable, p.ID), guardrailsByID)
|
||||
flavor := ""
|
||||
if entry, ok := catalog.Lookup(p.ProviderID); ok {
|
||||
flavor = entry.ParserID
|
||||
}
|
||||
out.Providers = append(out.Providers, types.AgentConfigProvider{
|
||||
Name: p.Name,
|
||||
CatalogID: p.ProviderID,
|
||||
APIFlavor: flavor,
|
||||
AllModelsAllowed: allAllowed,
|
||||
Models: models,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// authorizedProvidersForGroups returns the enabled providers referenced
|
||||
// by at least one enabled policy whose source groups intersect groupIDs —
|
||||
// the providers the caller's own policies authorize — in created_at order
|
||||
// with ID tiebreak, the same deterministic order the router synthesizer
|
||||
// presents. The applicable policies come back alongside so callers that
|
||||
// need per-provider policy context (the setup's model computation) don't
|
||||
// re-filter. Both the self-service setup answer and the caller-scoped
|
||||
// provider list are built from this selection, so what the dashboard
|
||||
// offers and what the proxy enforces never diverge.
|
||||
func (m *managerImpl) authorizedProvidersForGroups(ctx context.Context, accountID string, groupIDs []string) ([]*types.Provider, []*types.Policy, error) {
|
||||
policies, err := m.store.GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, accountID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("list account policies: %w", err)
|
||||
}
|
||||
applicable := filterPoliciesByGroups(policies, groupIDs)
|
||||
if len(applicable) == 0 {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
providers, err := m.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("list account providers: %w", err)
|
||||
}
|
||||
|
||||
// filterEnabledProviders carries the enabled filter and the
|
||||
// created_at/ID order shared with the router synthesizer.
|
||||
enabled := filterEnabledProviders(providers)
|
||||
authorized := make([]*types.Provider, 0, len(enabled))
|
||||
for _, p := range enabled {
|
||||
if len(policiesForProvider(applicable, p.ID)) == 0 {
|
||||
continue
|
||||
}
|
||||
authorized = append(authorized, p)
|
||||
}
|
||||
return authorized, applicable, nil
|
||||
}
|
||||
|
||||
// filterPoliciesByGroups returns the enabled policies whose SourceGroups
|
||||
// intersect the caller's groups. Same group matching as
|
||||
// filterApplicablePolicies, without the per-provider filter — the setup
|
||||
// answer spans every provider the caller can reach.
|
||||
func filterPoliciesByGroups(policies []*types.Policy, groupIDs []string) []*types.Policy {
|
||||
groupSet := make(map[string]struct{}, len(groupIDs))
|
||||
for _, g := range groupIDs {
|
||||
if g != "" {
|
||||
groupSet[g] = struct{}{}
|
||||
}
|
||||
}
|
||||
out := make([]*types.Policy, 0, len(policies))
|
||||
for _, p := range policies {
|
||||
if p == nil || !p.Enabled {
|
||||
continue
|
||||
}
|
||||
if !anyGroupMatches(p.SourceGroups, groupSet) {
|
||||
continue
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// policiesForProvider returns the subset of policies targeting the
|
||||
// provider, order preserved.
|
||||
func policiesForProvider(policies []*types.Policy, providerID string) []*types.Policy {
|
||||
out := make([]*types.Policy, 0, len(policies))
|
||||
for _, p := range policies {
|
||||
if sliceContains(p.DestinationProviderIDs, providerID) {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// effectiveModelsForProvider derives the caller's effective model set for
|
||||
// one provider from the applicable policies that target it, mirroring
|
||||
// policyPermitsModel: a policy with no allowlist-enabled guardrail is
|
||||
// unrestricted, and one unrestricted policy makes the whole provider
|
||||
// unrestricted (the proxy would admit any model through it). Otherwise
|
||||
// the union of the policies' allowlists applies, intersected with the
|
||||
// provider's declared models when the operator declared any — the router
|
||||
// only claims declared models, so an allowlisted-but-undeclared model is
|
||||
// unreachable and must not be advertised. With no declared models the
|
||||
// router claims every model, so the allowlist union stands alone.
|
||||
// Allowlist entries and declared ids both compare through the canonical
|
||||
// id the proxy's parser emits, so an allowlist may hold either form: the
|
||||
// raw declared id the dashboard's picker copies from the provider, or
|
||||
// the stripped id the parser matches at request time.
|
||||
func effectiveModelsForProvider(provider *types.Provider, policies []*types.Policy, guardrailsByID map[string]*types.Guardrail) (bool, []string) {
|
||||
restricted := true
|
||||
union := make([]string, 0)
|
||||
seen := make(map[string]struct{})
|
||||
for _, p := range policies {
|
||||
policyRestricted := false
|
||||
for _, gID := range p.GuardrailIDs {
|
||||
g, ok := guardrailsByID[gID]
|
||||
if !ok || g == nil || !g.Checks.ModelAllowlist.Enabled {
|
||||
continue
|
||||
}
|
||||
policyRestricted = true
|
||||
for _, model := range g.Checks.ModelAllowlist.Models {
|
||||
key := canonicalModelKey(provider.ProviderID, model)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[key]; dup {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
union = append(union, key)
|
||||
}
|
||||
}
|
||||
if !policyRestricted {
|
||||
restricted = false
|
||||
}
|
||||
}
|
||||
|
||||
declared := declaredModelIDs(provider)
|
||||
if !restricted {
|
||||
return true, declared
|
||||
}
|
||||
if len(provider.Models) == 0 {
|
||||
// No operator declaration: the router claims every model, so the
|
||||
// allowlist union is the effective set as-is.
|
||||
return false, union
|
||||
}
|
||||
out := make([]string, 0, len(declared))
|
||||
for _, id := range declared {
|
||||
// Compare through the canonical id the proxy's parser emits — a
|
||||
// Bedrock declaration may carry the region/version form
|
||||
// ("eu.anthropic.claude-...-v1:0") that the parser strips at
|
||||
// request time, and the raw forms would never intersect. The
|
||||
// declared id itself is what gets advertised, matching the
|
||||
// router's route claim.
|
||||
if _, ok := seen[canonicalModelKey(provider.ProviderID, id)]; ok {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return false, out
|
||||
}
|
||||
|
||||
// canonicalModelKey builds the compare key for a model id: lowercased,
|
||||
// trimmed, and canonicalized through the provider-aware normalization the
|
||||
// proxy's parser applies. Lowercase/trim comes FIRST — the path-style
|
||||
// strippers anchor on a lowercase id's tail, so a trailing space or a
|
||||
// case-variant geography/version would otherwise survive into the key.
|
||||
func canonicalModelKey(catalogProviderID, id string) string {
|
||||
return normaliseModelID(normalizePricingModelID(catalogProviderID, normaliseModelID(id)))
|
||||
}
|
||||
|
||||
// providerModelsByID maps effective model ids (as effectiveModelsForProvider
|
||||
// returns them) back onto the operator's declared entries, keeping the
|
||||
// declared casing and prices. With no operator declaration the ids are the
|
||||
// allowlist union and have no declared entry to map to, so bare entries are
|
||||
// synthesized — the router claims every model in that case, so those ids are
|
||||
// reachable and belong in the answer.
|
||||
func providerModelsByID(provider *types.Provider, ids []string) []types.ProviderModel {
|
||||
if len(provider.Models) == 0 {
|
||||
out := make([]types.ProviderModel, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
out = append(out, types.ProviderModel{ID: id})
|
||||
}
|
||||
return out
|
||||
}
|
||||
keep := make(map[string]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
keep[normaliseModelID(id)] = struct{}{}
|
||||
}
|
||||
out := make([]types.ProviderModel, 0, len(ids))
|
||||
for _, m := range provider.Models {
|
||||
if _, ok := keep[normaliseModelID(m.ID)]; ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// declaredModelIDs returns the models a provider exposes: the operator's
|
||||
// curated list when present, otherwise the catalog entry's models (an
|
||||
// empty operator list means "all catalog models"). Gateway/custom catalog
|
||||
// entries declare no models, so the result may be empty.
|
||||
func declaredModelIDs(provider *types.Provider) []string {
|
||||
if ids := providerModelIDs(provider); len(ids) > 0 {
|
||||
return ids
|
||||
}
|
||||
entry, ok := catalog.Lookup(provider.ProviderID)
|
||||
if !ok {
|
||||
return []string{}
|
||||
}
|
||||
out := make([]string, 0, len(entry.Models))
|
||||
for _, m := range entry.Models {
|
||||
if m.ID != "" {
|
||||
out = append(out, m.ID)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetAgentConfigForUser on the mock manager reports "not configured" so tests
|
||||
// that don't care about setup still compile.
|
||||
func (*mockManager) GetAgentConfigForUser(_ context.Context, _, _ string) (*types.AgentConfig, error) {
|
||||
return &types.AgentConfig{Providers: []types.AgentConfigProvider{}}, nil
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/permissions"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
nbtypes "github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
// These tests drive the effective-setup computation through the real
|
||||
// sqlite store, mirroring the policyselect realstore suite: assert on
|
||||
// observable answers (configured / providers / models), not on which
|
||||
// store methods get called. The computation must agree with what the
|
||||
// proxy enforces — policy filtering matches filterApplicablePolicies,
|
||||
// model logic matches policyPermitsModel, and orphan providers are
|
||||
// omitted like the router synthesizer omits them.
|
||||
|
||||
func newAgentConfigTestMgr(t *testing.T) (*managerImpl, store.Store) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||
require.NoError(t, err, "real sqlite test store must come up")
|
||||
t.Cleanup(cleanup)
|
||||
return &managerImpl{store: s}, s
|
||||
}
|
||||
|
||||
// newSetupTestGuardrail returns an allowlist-enabled guardrail.
|
||||
func newSetupTestGuardrail(id string, models ...string) *types.Guardrail {
|
||||
return &types.Guardrail{
|
||||
ID: id,
|
||||
AccountID: testAccountID,
|
||||
Name: "allowlist " + id,
|
||||
Checks: types.GuardrailChecks{
|
||||
ModelAllowlist: types.GuardrailModelAllowlist{Enabled: true, Models: models},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentConfig_RealStore_NoSettingsRow(t *testing.T) {
|
||||
mgr, _ := newAgentConfigTestMgr(t)
|
||||
|
||||
setup, err := mgr.agentConfigForGroups(context.Background(), testAccountID, []string{"grp-eng"})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, setup.Configured, "account without settings must read as not configured")
|
||||
assert.Empty(t, setup.Endpoint)
|
||||
assert.Empty(t, setup.Providers)
|
||||
}
|
||||
|
||||
func TestAgentConfig_RealStore_NoApplicablePolicy(t *testing.T) {
|
||||
mgr, s := newAgentConfigTestMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
provider := newSynthTestProvider()
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
|
||||
|
||||
setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-other"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, setup.Configured, "the account is set up, so every member reads as configured")
|
||||
assert.Equal(t, "https://"+testEndpoint, setup.Endpoint, "every member gets the same connection config")
|
||||
assert.Empty(t, setup.Providers, "a caller no policy covers is authorized for nothing")
|
||||
}
|
||||
|
||||
func TestAgentConfig_RealStore_UnrestrictedPolicyListsDeclaredModels(t *testing.T) {
|
||||
mgr, s := newAgentConfigTestMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
provider := newSynthTestProvider()
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
|
||||
|
||||
setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, setup.Configured)
|
||||
assert.Equal(t, "https://"+testEndpoint, setup.Endpoint)
|
||||
require.Len(t, setup.Providers, 1)
|
||||
p := setup.Providers[0]
|
||||
assert.Equal(t, "OpenAI", p.Name)
|
||||
assert.Equal(t, "openai_api", p.CatalogID)
|
||||
assert.Equal(t, "openai", p.APIFlavor)
|
||||
assert.True(t, p.AllModelsAllowed, "policy without allowlist guardrail is unrestricted")
|
||||
assert.Equal(t, []string{"gpt-5.4"}, p.Models, "declared models listed as a courtesy")
|
||||
}
|
||||
|
||||
func TestAgentConfig_RealStore_AllowlistIntersectsDeclaredModels(t *testing.T) {
|
||||
mgr, s := newAgentConfigTestMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
provider := newSynthTestProvider()
|
||||
provider.Models = []types.ProviderModel{{ID: "gpt-5.4"}, {ID: "gpt-4o"}}
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
// Allowlist admits gpt-5.4 (declared, odd casing/spacing) and gpt-4.1
|
||||
// (NOT declared — the router would never route it, so it must not be
|
||||
// advertised).
|
||||
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", " GPT-5.4 ", "gpt-4.1")))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "guard-1")))
|
||||
|
||||
setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, setup.Providers, 1)
|
||||
p := setup.Providers[0]
|
||||
assert.False(t, p.AllModelsAllowed)
|
||||
assert.Equal(t, []string{"gpt-5.4"}, p.Models, "allowlist ∩ declared, in declared order and casing")
|
||||
}
|
||||
|
||||
func TestAgentConfig_RealStore_AllowlistMatchesBedrockDeclaredIDsCanonically(t *testing.T) {
|
||||
mgr, s := newAgentConfigTestMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
// A Bedrock operator typically declares the region/version form the
|
||||
// vendor lists, while the allowlist holds the canonical id the proxy's
|
||||
// parser emits at request time. The intersection must compare through
|
||||
// the same normalization the parser applies, and the declared (raw)
|
||||
// id is what gets advertised — it is what the router claims.
|
||||
provider := newSynthTestProvider()
|
||||
provider.ProviderID = "bedrock_api"
|
||||
provider.Name = "Bedrock"
|
||||
provider.Models = []types.ProviderModel{
|
||||
{ID: "eu.anthropic.claude-sonnet-4-5-20250929-v1:0"},
|
||||
{ID: "eu.amazon.nova-pro-v1:0"},
|
||||
}
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", "anthropic.claude-sonnet-4-5")))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "guard-1")))
|
||||
|
||||
setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, setup.Providers, 1)
|
||||
p := setup.Providers[0]
|
||||
assert.False(t, p.AllModelsAllowed)
|
||||
assert.Equal(t, []string{"eu.anthropic.claude-sonnet-4-5-20250929-v1:0"}, p.Models,
|
||||
"the allowlisted canonical id must admit the declared region/version form, and only it")
|
||||
}
|
||||
|
||||
func TestAgentConfig_RealStore_AllowlistHoldsRawDeclaredIDs(t *testing.T) {
|
||||
// The dashboard's allowlist picker copies the provider's declared ids
|
||||
// verbatim, so for path-style providers the allowlist carries the
|
||||
// region/version form rather than the canonical id the parser emits.
|
||||
// Both forms must admit the declared model.
|
||||
cases := []struct {
|
||||
name string
|
||||
catalogID string
|
||||
declared string
|
||||
allowlist string
|
||||
}{
|
||||
{"bedrock", "bedrock_api", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", ""},
|
||||
{"vertex", "vertex_ai_api", "claude-sonnet-4-5@20250929", ""},
|
||||
// The geography/version strippers anchor on a lowercase tail, so a
|
||||
// case-variant entry must be lowercased before canonicalization or
|
||||
// the prefix and suffix survive into the compare key.
|
||||
{"bedrock-case-variant", "bedrock_api", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
" EU.Anthropic.Claude-Sonnet-4-5-20250929-V1:0 "},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mgr, s := newAgentConfigTestMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
allowlisted := tc.allowlist
|
||||
if allowlisted == "" {
|
||||
allowlisted = tc.declared
|
||||
}
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
provider := newSynthTestProvider()
|
||||
provider.ProviderID = tc.catalogID
|
||||
provider.Name = tc.name
|
||||
provider.Models = []types.ProviderModel{{ID: tc.declared}}
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", allowlisted)))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "guard-1")))
|
||||
|
||||
setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, setup.Providers, 1)
|
||||
p := setup.Providers[0]
|
||||
assert.False(t, p.AllModelsAllowed)
|
||||
assert.Equal(t, []string{tc.declared}, p.Models,
|
||||
"an allowlist holding the raw declared id must admit that declared model")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentConfig_RealStore_UnrestrictedPolicyWinsOverRestricted(t *testing.T) {
|
||||
mgr, s := newAgentConfigTestMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
provider := newSynthTestProvider()
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", "gpt-5.4")))
|
||||
restricted := newSynthTestPolicy(provider.ID, "grp-eng", "guard-1")
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, restricted))
|
||||
open := newSynthTestPolicy(provider.ID, "grp-eng", "")
|
||||
open.ID = "pol-2"
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, open))
|
||||
|
||||
setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, setup.Providers, 1)
|
||||
assert.True(t, setup.Providers[0].AllModelsAllowed,
|
||||
"one applicable policy without an allowlist makes the provider unrestricted — the proxy would admit any model through it")
|
||||
}
|
||||
|
||||
func TestAgentConfig_RealStore_AllowlistUnionAcrossPolicies(t *testing.T) {
|
||||
mgr, s := newAgentConfigTestMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
provider := newSynthTestProvider()
|
||||
provider.Models = []types.ProviderModel{{ID: "gpt-5.4"}, {ID: "gpt-4o"}, {ID: "o4-mini"}}
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", "gpt-5.4")))
|
||||
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-2", "gpt-4o")))
|
||||
p1 := newSynthTestPolicy(provider.ID, "grp-eng", "guard-1")
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, p1))
|
||||
p2 := newSynthTestPolicy(provider.ID, "grp-eng", "guard-2")
|
||||
p2.ID = "pol-2"
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, p2))
|
||||
|
||||
setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, setup.Providers, 1)
|
||||
p := setup.Providers[0]
|
||||
assert.False(t, p.AllModelsAllowed)
|
||||
assert.ElementsMatch(t, []string{"gpt-5.4", "gpt-4o"}, p.Models, "union of allowlists across applicable policies")
|
||||
}
|
||||
|
||||
func TestAgentConfig_RealStore_OrphanAndDisabledProvidersOmitted(t *testing.T) {
|
||||
mgr, s := newAgentConfigTestMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
// Orphan: enabled but referenced by no policy.
|
||||
orphan := newSynthTestProvider()
|
||||
orphan.ID = "prov-orphan"
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, orphan))
|
||||
// Disabled but referenced by an applicable policy.
|
||||
disabled := newSynthTestProvider()
|
||||
disabled.ID = "prov-disabled"
|
||||
disabled.Enabled = false
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, disabled))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(disabled.ID, "grp-eng", "")))
|
||||
|
||||
setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, setup.Configured)
|
||||
assert.Empty(t, setup.Providers, "neither an orphan nor a disabled provider is reachable for the caller")
|
||||
}
|
||||
|
||||
func TestAgentConfig_RealStore_DisabledPolicyIgnored(t *testing.T) {
|
||||
mgr, s := newAgentConfigTestMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
provider := newSynthTestProvider()
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
policy := newSynthTestPolicy(provider.ID, "grp-eng", "")
|
||||
policy.Enabled = false
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy))
|
||||
|
||||
setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, setup.Configured)
|
||||
assert.Empty(t, setup.Providers, "a disabled policy authorizes nothing")
|
||||
}
|
||||
|
||||
func TestAgentConfig_RealStore_UndeclaredModelsUseAllowlistAsIs(t *testing.T) {
|
||||
mgr, s := newAgentConfigTestMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
// Gateway-style provider: no declared models — the router claims every
|
||||
// model, so the allowlist union is the effective set on its own.
|
||||
provider := newSynthTestProvider()
|
||||
provider.ProviderID = "litellm_proxy"
|
||||
provider.Name = "LiteLLM"
|
||||
provider.Models = nil
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-1", "claude-sonnet-4-5")))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "guard-1")))
|
||||
|
||||
setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, setup.Providers, 1)
|
||||
p := setup.Providers[0]
|
||||
assert.False(t, p.AllModelsAllowed)
|
||||
assert.Equal(t, []string{"claude-sonnet-4-5"}, p.Models)
|
||||
}
|
||||
|
||||
func TestAgentConfig_RealStore_ProvidersInCreatedAtOrder(t *testing.T) {
|
||||
mgr, s := newAgentConfigTestMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
newer := newSynthTestProvider()
|
||||
newer.ID = "prov-newer"
|
||||
newer.Name = "Newer"
|
||||
newer.CreatedAt = time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, newer))
|
||||
older := newSynthTestProvider()
|
||||
older.ID = "prov-older"
|
||||
older.Name = "Older"
|
||||
older.CreatedAt = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, older))
|
||||
|
||||
policy := newSynthTestPolicy(newer.ID, "grp-eng", "")
|
||||
policy.DestinationProviderIDs = []string{newer.ID, older.ID}
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy))
|
||||
|
||||
setup, err := mgr.agentConfigForGroups(ctx, testAccountID, []string{"grp-eng"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, setup.Providers, 2)
|
||||
assert.Equal(t, "Older", setup.Providers[0].Name)
|
||||
assert.Equal(t, "Newer", setup.Providers[1].Name)
|
||||
}
|
||||
|
||||
// TestGetAgentConfigForUser_RealStore pins the self-service entry point: the
|
||||
// user's group memberships (AutoGroups — the same groups the user's peers
|
||||
// carry) scope the providers, while the account's endpoint reaches every
|
||||
// member — a user outside every policy gets the config with nothing
|
||||
// authorized in it.
|
||||
func TestGetAgentConfigForUser_RealStore(t *testing.T) {
|
||||
mgr, s := newAgentConfigTestMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
provider := newSynthTestProvider()
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
|
||||
|
||||
// users.account_id is a foreign key into accounts, enforced on
|
||||
// MySQL/Postgres, so the account row must exist before its users.
|
||||
require.NoError(t, s.SaveAccount(ctx, &nbtypes.Account{Id: testAccountID}))
|
||||
require.NoError(t, s.SaveUser(ctx, &nbtypes.User{
|
||||
Id: "user-in", AccountID: testAccountID, Role: nbtypes.UserRoleUser, AutoGroups: []string{"grp-eng"},
|
||||
}))
|
||||
require.NoError(t, s.SaveUser(ctx, &nbtypes.User{
|
||||
Id: "user-out", AccountID: testAccountID, Role: nbtypes.UserRoleUser, AutoGroups: []string{"grp-other"},
|
||||
}))
|
||||
|
||||
setupIn, err := mgr.GetAgentConfigForUser(ctx, testAccountID, "user-in")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, setupIn.Configured)
|
||||
require.Len(t, setupIn.Providers, 1)
|
||||
|
||||
setupOut, err := mgr.GetAgentConfigForUser(ctx, testAccountID, "user-out")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, setupOut.Configured, "the account is set up, so the user reads as configured")
|
||||
assert.Equal(t, "https://"+testEndpoint, setupOut.Endpoint)
|
||||
assert.Empty(t, setupOut.Providers, "user outside the policy's source groups is authorized for nothing")
|
||||
}
|
||||
|
||||
// TestGetUsageOverview_RealStore_SelfScoped pins the self-scope fallback:
|
||||
// a caller without the account-wide usage grant gets the same aggregation
|
||||
// the admin overview serves, but only ever their own rows — a user_id
|
||||
// filter for someone else must be overridden, not honored, and never
|
||||
// denied. A caller holding the grant keeps the account-wide view.
|
||||
func TestGetUsageOverview_RealStore_SelfScoped(t *testing.T) {
|
||||
mgr, s := newAgentConfigTestMgr(t)
|
||||
mgr.permissionsManager = permissions.NewManager(s)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
require.NoError(t, s.SaveAccount(ctx, &nbtypes.Account{Id: testAccountID}))
|
||||
require.NoError(t, s.SaveUser(ctx, &nbtypes.User{
|
||||
Id: "user-a", AccountID: testAccountID, Role: nbtypes.UserRoleUser,
|
||||
}))
|
||||
require.NoError(t, s.SaveUser(ctx, &nbtypes.User{
|
||||
Id: "admin", AccountID: testAccountID, Role: nbtypes.UserRoleAdmin,
|
||||
}))
|
||||
|
||||
own1 := newIngestTestEntry()
|
||||
own1.ID, own1.UserId = "log-own-1", "user-a"
|
||||
own2 := newIngestTestEntry()
|
||||
own2.ID, own2.UserId = "log-own-2", "user-a"
|
||||
other := newIngestTestEntry()
|
||||
other.ID, other.UserId = "log-other", "user-b"
|
||||
for _, e := range []*accesslogs.AccessLogEntry{own1, own2, other} {
|
||||
require.NoError(t, IngestAccessLog(ctx, s, e))
|
||||
}
|
||||
|
||||
otherID := "user-b"
|
||||
filter := types.AgentNetworkAccessLogFilter{UserID: &otherID}
|
||||
buckets, err := mgr.GetUsageOverview(ctx, testAccountID, "user-a", filter, types.ParseUsageGranularity(""))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, buckets, 1, "same-day rows aggregate into one daily bucket")
|
||||
assert.Equal(t, int64(200), buckets[0].InputTokens, "only the caller's two rows count — the foreign user_id filter is overridden")
|
||||
assert.Equal(t, int64(100), buckets[0].OutputTokens)
|
||||
|
||||
adminBuckets, err := mgr.GetUsageOverview(ctx, testAccountID, "admin", types.AgentNetworkAccessLogFilter{}, types.ParseUsageGranularity(""))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, adminBuckets, 1)
|
||||
assert.Equal(t, int64(300), adminBuckets[0].InputTokens, "the account-wide grant keeps the unscoped view")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
// TestClaudeLineupSelectable pins the models Claude Code resolves to by
|
||||
// default. A model absent from the lineup can't be ticked on a provider
|
||||
// record, so llm_router denies it as not-routable and the operator has no
|
||||
// way to authorise the client's own default.
|
||||
func TestClaudeLineupSelectable(t *testing.T) {
|
||||
for providerID, wanted := range map[string][]string{
|
||||
"anthropic_api": {"claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5"},
|
||||
"bedrock_api": {"anthropic.claude-opus-5", "anthropic.claude-sonnet-5", "anthropic.claude-haiku-4-5"},
|
||||
"vertex_ai_api": {"claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5"},
|
||||
} {
|
||||
provider, ok := Lookup(providerID)
|
||||
require.True(t, ok, "catalog must define %s", providerID)
|
||||
|
||||
selectable := make(map[string]Model, len(provider.Models))
|
||||
for _, m := range provider.Models {
|
||||
selectable[m.ID] = m
|
||||
}
|
||||
for _, id := range wanted {
|
||||
model, found := selectable[id]
|
||||
require.True(t, found, "%s must offer %s", providerID, id)
|
||||
assert.NotEmpty(t, model.Label, "%s/%s needs a label for the picker", providerID, id)
|
||||
assert.Positive(t, model.InputPer1k, "%s/%s needs an input rate", providerID, id)
|
||||
assert.Positive(t, model.OutputPer1k, "%s/%s needs an output rate", providerID, id)
|
||||
assert.Positive(t, model.ContextWindow, "%s/%s needs a context window", providerID, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentgatewayCatalogEntry(t *testing.T) {
|
||||
entry, ok := Lookup("agentgateway")
|
||||
require.True(t, ok, "agentgateway must be available in the provider catalog")
|
||||
|
||||
assert.Equal(t, KindGateway, entry.Kind, "agentgateway must be grouped with AI gateways")
|
||||
assert.Empty(t, entry.DefaultHost, "operators must provide their agentgateway proxy URL")
|
||||
assert.Equal(t, "Authorization", entry.AuthHeaderName)
|
||||
assert.Equal(t, "Bearer ${API_KEY}", entry.AuthHeaderTemplate)
|
||||
assert.Equal(t, "application/json", entry.DefaultContentType)
|
||||
assert.Empty(t, entry.ParserID, "URL detection must select the OpenAI or Anthropic parser")
|
||||
assert.Equal(t, []string{"openai", "anthropic"}, entry.RouterVendors,
|
||||
"agentgateway must accept both parser surfaces")
|
||||
assert.Equal(t, []string{"openai", "anthropic"}, entry.PricingSurfaces,
|
||||
"agentgateway models can use either pricing surface")
|
||||
assert.Empty(t, entry.Models, "an empty model list makes agentgateway a catch-all route")
|
||||
require.NotNil(t, entry.Discovery)
|
||||
assert.Empty(t, entry.Discovery.Host, "discovery must use the configured proxy URL")
|
||||
assert.Equal(t, "/v1/models", entry.Discovery.Path)
|
||||
assert.Equal(t, ShapeOpenAIData, entry.Discovery.Shape)
|
||||
assert.True(t, entry.Discovery.ExactModelsOnly,
|
||||
"wildcard model semantics are not supported by NetBird")
|
||||
|
||||
require.NotNil(t, entry.IdentityInjection)
|
||||
require.NotNil(t, entry.IdentityInjection.HeaderPair)
|
||||
assert.Nil(t, entry.IdentityInjection.JSONMetadata)
|
||||
assert.False(t, entry.IdentityInjection.HeaderPair.Customizable,
|
||||
"NetBird identity header names are part of the integration contract")
|
||||
assert.Equal(t, "x-netbird-user-id", entry.IdentityInjection.HeaderPair.EndUserIDHeader)
|
||||
assert.Equal(t, "x-netbird-groups", entry.IdentityInjection.HeaderPair.TagsHeader)
|
||||
assert.False(t, entry.IdentityInjection.HeaderPair.EndUserIDInBody)
|
||||
assert.False(t, entry.IdentityInjection.HeaderPair.TagsInBody)
|
||||
}
|
||||
|
||||
func TestAgentgatewayCatalogAPIResponse(t *testing.T) {
|
||||
entry, ok := Lookup("agentgateway")
|
||||
require.True(t, ok)
|
||||
|
||||
resp := entry.ToAPIResponse()
|
||||
assert.Equal(t, "agentgateway", resp.Id)
|
||||
assert.Equal(t, api.AgentNetworkCatalogProviderKindGateway, resp.Kind)
|
||||
assert.Empty(t, resp.Models)
|
||||
require.NotNil(t, resp.IdentityInjection)
|
||||
require.NotNil(t, resp.IdentityInjection.HeaderPair)
|
||||
assert.False(t, resp.IdentityInjection.HeaderPair.Customizable)
|
||||
assert.Equal(t, "x-netbird-user-id", resp.IdentityInjection.HeaderPair.EndUserIdHeader)
|
||||
assert.Equal(t, "x-netbird-groups", resp.IdentityInjection.HeaderPair.TagsHeader)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/modeldiscovery"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
// ModelLister is the vendor-facing half of the credential check.
|
||||
// modeldiscovery.Client is the only production implementation; it is an
|
||||
// interface because the check runs on a write path, so without a seam every
|
||||
// test that saves a provider would reach a vendor to do it.
|
||||
type ModelLister interface {
|
||||
Fetch(ctx context.Context, req modeldiscovery.Request) ([]modeldiscovery.Model, error)
|
||||
}
|
||||
|
||||
// checkProviderCredential refuses a record whose upstream or credential the
|
||||
// vendor will not accept.
|
||||
//
|
||||
// It reuses the discovery Fetch rather than a lighter status probe so it
|
||||
// exercises the path the model picker takes: a URL answering 200 with a login
|
||||
// page fails here instead of producing an empty picker later.
|
||||
func (m *managerImpl) checkProviderCredential(ctx context.Context, provider *types.Provider) error {
|
||||
// A record that asks the proxy to skip certificate verification is one this
|
||||
// check cannot speak for. Discovery verifies certificates, so a self-hosted
|
||||
// endpoint behind a self-signed one would be refused for a reason the
|
||||
// operator already told us to ignore — a lockout of exactly the setup the
|
||||
// flag exists for. Sending the credential over a connection management
|
||||
// declines to verify is the other way out, and a worse one.
|
||||
if provider.SkipTLSVerification {
|
||||
log.WithContext(ctx).Debugf("agent network provider %s not credential-checked: tls verification is disabled for it", provider.ProviderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := m.modelDiscovery.Fetch(ctx, modeldiscovery.Request{
|
||||
CatalogID: provider.ProviderID,
|
||||
UpstreamURL: provider.UpstreamURL,
|
||||
APIKey: provider.APIKey,
|
||||
})
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
message, blocking := credentialCheckFailure(err)
|
||||
if !blocking {
|
||||
log.WithContext(ctx).Debugf("agent network provider %s not credential-checked: %v", provider.ProviderID, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteError logs only what we return, and that carries no status code,
|
||||
// so the vendor's number is recorded here or nowhere.
|
||||
log.WithContext(ctx).Infof("agent network provider %s failed its credential check: %v", provider.ProviderID, err)
|
||||
|
||||
return status.Errorf(status.InvalidArgument, "%s", message)
|
||||
}
|
||||
|
||||
// discoveryFailure renders a failed model listing for the operator who pressed
|
||||
// the button. Every outcome here is something they did or configured — a key
|
||||
// the vendor refused, an upstream that does not answer — so it owes them the
|
||||
// same sentence a refused save gives, not the generic 500 an unclassified
|
||||
// error turns into.
|
||||
//
|
||||
// ErrNoDiscovery and ErrInvalidRequest pass through untouched: the handler
|
||||
// already maps them, and "this provider has no listing endpoint" is a fact
|
||||
// about the catalog rather than a failure to report as one.
|
||||
func discoveryFailure(ctx context.Context, catalogID string, err error) error {
|
||||
if errors.Is(err, modeldiscovery.ErrNoDiscovery) || errors.Is(err, modeldiscovery.ErrInvalidRequest) {
|
||||
return err
|
||||
}
|
||||
|
||||
message, _ := credentialCheckFailure(err)
|
||||
if message == "" {
|
||||
return err
|
||||
}
|
||||
|
||||
// The operator's message carries no status code, so the vendor's number is
|
||||
// recorded here or nowhere.
|
||||
log.WithContext(ctx).Infof("agent network model discovery for %s failed: %v", catalogID, err)
|
||||
|
||||
return status.Errorf(status.InvalidArgument, "%s", message)
|
||||
}
|
||||
|
||||
// credentialCheckFailure renders a discovery failure as the sentence the
|
||||
// provider form shows, and reports whether it should block the write.
|
||||
//
|
||||
// The strings survive WriteError lowercasing them, and never echo the
|
||||
// operator's URL: paths are case-sensitive, so an echoed URL comes back
|
||||
// altered and describes something they did not type.
|
||||
func credentialCheckFailure(err error) (message string, blocking bool) {
|
||||
// Not checkable. The record may be perfectly good and we have no way to
|
||||
// ask, so reporting a failure would be a guess.
|
||||
switch {
|
||||
case errors.Is(err, modeldiscovery.ErrNoDiscovery),
|
||||
errors.Is(err, modeldiscovery.ErrNoDiscoveryHost),
|
||||
errors.Is(err, modeldiscovery.ErrPrivateHost):
|
||||
return "", false
|
||||
}
|
||||
|
||||
var vendor *modeldiscovery.VendorStatusError
|
||||
if errors.As(err, &vendor) {
|
||||
switch vendor.Status {
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
return "the provider rejected the credential", true
|
||||
case http.StatusNotFound, http.StatusMethodNotAllowed:
|
||||
return "the upstream url did not answer a model listing", true
|
||||
default:
|
||||
// 5xx and 429 included: an outage still leaves the record
|
||||
// unverified, which is what this refuses to save.
|
||||
return "the provider returned an error", true
|
||||
}
|
||||
}
|
||||
|
||||
var unreachable *modeldiscovery.UnreachableError
|
||||
if errors.As(err, &unreachable) {
|
||||
if reason := unreachable.Reason(); reason != "" {
|
||||
return "the upstream url could not be reached: " + reason, true
|
||||
}
|
||||
return "the upstream url could not be reached", true
|
||||
}
|
||||
|
||||
if errors.Is(err, modeldiscovery.ErrUnparseableListing) {
|
||||
return "the upstream url answered, but not with a model listing", true
|
||||
}
|
||||
|
||||
// Ours rather than the vendor's — a request this code built badly, or a
|
||||
// catalog entry that does not match its parser. Still unverified, so it
|
||||
// still blocks.
|
||||
return "the provider could not be checked", true
|
||||
}
|
||||
@@ -0,0 +1,605 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/modeldiscovery"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/permissions/modules"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/permissions/operations"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
// stubLister stands in for the vendor on the write path. It records what it
|
||||
// was asked so a test can assert not only that the check ran, but that it ran
|
||||
// against the right upstream and the right credential — and, for an edit that
|
||||
// touches neither, that it did not run at all.
|
||||
type stubLister struct {
|
||||
err error
|
||||
requests []modeldiscovery.Request
|
||||
}
|
||||
|
||||
func (s *stubLister) Fetch(_ context.Context, req modeldiscovery.Request) ([]modeldiscovery.Model, error) {
|
||||
s.requests = append(s.requests, req)
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return []modeldiscovery.Model{{ID: "a-model", PricingKnown: true}}, nil
|
||||
}
|
||||
|
||||
func (s *stubLister) calls() int { return len(s.requests) }
|
||||
|
||||
func (s *stubLister) only(t *testing.T) modeldiscovery.Request {
|
||||
t.Helper()
|
||||
require.Len(t, s.requests, 1, "the vendor must be asked exactly once")
|
||||
return s.requests[0]
|
||||
}
|
||||
|
||||
// TestCredentialCheckFailure_SeparatesTheUrlFromTheCredential is the contract
|
||||
// the provider form is written against: an operator gets told which of the two
|
||||
// fields they have to look at, and the message says so without a status code
|
||||
// and without echoing the URL back at them.
|
||||
func TestCredentialCheckFailure_SeparatesTheUrlFromTheCredential(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "401 is the credential",
|
||||
err: &modeldiscovery.VendorStatusError{Provider: "OpenAI", Status: 401},
|
||||
want: "the provider rejected the credential",
|
||||
},
|
||||
{
|
||||
name: "403 is the credential",
|
||||
err: &modeldiscovery.VendorStatusError{Provider: "Bedrock", Status: 403},
|
||||
want: "the provider rejected the credential",
|
||||
},
|
||||
{
|
||||
// The host authenticated us fine and then said it has no such
|
||||
// endpoint, which is the URL being wrong rather than the key.
|
||||
name: "404 is the url",
|
||||
err: &modeldiscovery.VendorStatusError{Provider: "OpenAI", Status: 404},
|
||||
want: "the upstream url did not answer a model listing",
|
||||
},
|
||||
{
|
||||
name: "405 is the url",
|
||||
err: &modeldiscovery.VendorStatusError{Provider: "OpenAI", Status: 405},
|
||||
want: "the upstream url did not answer a model listing",
|
||||
},
|
||||
{
|
||||
name: "500 is the vendor",
|
||||
err: &modeldiscovery.VendorStatusError{Provider: "Anthropic", Status: 500},
|
||||
want: "the provider returned an error",
|
||||
},
|
||||
{
|
||||
name: "503 is the vendor",
|
||||
err: &modeldiscovery.VendorStatusError{Provider: "Anthropic", Status: 503},
|
||||
want: "the provider returned an error",
|
||||
},
|
||||
{
|
||||
name: "429 is the vendor",
|
||||
err: &modeldiscovery.VendorStatusError{Provider: "Anthropic", Status: 429},
|
||||
want: "the provider returned an error",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, blocking := credentialCheckFailure(tc.err)
|
||||
require.True(t, blocking, "a vendor refusal must block the write")
|
||||
require.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCredentialCheckFailure_NamesTheTransportFault covers the failures that
|
||||
// never reached the vendor. The distinction inside them is worth keeping: a
|
||||
// refused connection is a wrong port and an unknown host is a wrong hostname,
|
||||
// and an operator staring at a URL they believe in needs to be told which.
|
||||
func TestCredentialCheckFailure_NamesTheTransportFault(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "unknown host",
|
||||
err: &net.DNSError{Err: "no such host", Name: "api.example.com", IsNotFound: true},
|
||||
want: "the upstream url could not be reached: no such host",
|
||||
},
|
||||
{
|
||||
name: "dns failure that is not a missing name",
|
||||
err: &net.DNSError{Err: "server misbehaving", Name: "api.example.com"},
|
||||
want: "the upstream url could not be reached: dns lookup failed",
|
||||
},
|
||||
{
|
||||
name: "connection refused",
|
||||
err: &net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED},
|
||||
want: "the upstream url could not be reached: connection refused",
|
||||
},
|
||||
{
|
||||
name: "host unreachable",
|
||||
err: &net.OpError{Op: "dial", Net: "tcp", Err: syscall.EHOSTUNREACH},
|
||||
want: "the upstream url could not be reached: host unreachable",
|
||||
},
|
||||
{
|
||||
name: "timeout",
|
||||
err: fmt.Errorf("dial: %w", os.ErrDeadlineExceeded),
|
||||
want: "the upstream url could not be reached: connection timed out",
|
||||
},
|
||||
{
|
||||
name: "context deadline",
|
||||
err: fmt.Errorf("dial: %w", context.DeadlineExceeded),
|
||||
want: "the upstream url could not be reached: connection timed out",
|
||||
},
|
||||
{
|
||||
name: "untrusted certificate",
|
||||
err: &tls.CertificateVerificationError{},
|
||||
want: "the upstream url could not be reached: tls certificate not trusted",
|
||||
},
|
||||
{
|
||||
name: "plaintext service on an https url",
|
||||
err: tls.RecordHeaderError{Msg: "first record does not look like a TLS handshake"},
|
||||
want: "the upstream url could not be reached: not a tls endpoint",
|
||||
},
|
||||
{
|
||||
// Nothing we recognise. Better to say only that it could not be
|
||||
// reached than to paste a Go error into the provider form.
|
||||
name: "cause we do not recognise",
|
||||
err: errors.New("something went sideways"),
|
||||
want: "the upstream url could not be reached",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
wrapped := &modeldiscovery.UnreachableError{Provider: "OpenAI", Err: tc.err}
|
||||
got, blocking := credentialCheckFailure(wrapped)
|
||||
require.True(t, blocking, "an unreachable upstream must block the write")
|
||||
require.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCredentialCheckFailure_AnAnsweringUrlThatIsNotTheApi covers the case a
|
||||
// status probe would wave through: the host is up, the credential was accepted
|
||||
// or not required, and the body is a login page. Reusing the discovery parser
|
||||
// for the check is what catches it.
|
||||
func TestCredentialCheckFailure_AnAnsweringUrlThatIsNotTheApi(t *testing.T) {
|
||||
err := fmt.Errorf("%w: decode model listing: unexpected token", modeldiscovery.ErrUnparseableListing)
|
||||
|
||||
got, blocking := credentialCheckFailure(err)
|
||||
require.True(t, blocking)
|
||||
require.Equal(t, "the upstream url answered, but not with a model listing", got)
|
||||
}
|
||||
|
||||
// TestCredentialCheckFailure_WhatCannotBeCheckedIsNotAFailure pins the
|
||||
// difference between "this record is wrong" and "we have no way to ask". A
|
||||
// gateway with no listing endpoint, a Bedrock record pointed at a proxy, and a
|
||||
// self-hosted endpoint the proxy reaches through the tunnel are all legitimate
|
||||
// providers. Blocking them would make the feature a lockout.
|
||||
func TestCredentialCheckFailure_WhatCannotBeCheckedIsNotAFailure(t *testing.T) {
|
||||
cases := map[string]error{
|
||||
"no listing endpoint": modeldiscovery.ErrNoDiscovery,
|
||||
"no derivable host": fmt.Errorf("%w: %w: bedrock", modeldiscovery.ErrInvalidRequest, modeldiscovery.ErrNoDiscoveryHost),
|
||||
"private upstream": fmt.Errorf("%w: %w: 10.0.0.5", modeldiscovery.ErrInvalidRequest, modeldiscovery.ErrPrivateHost),
|
||||
}
|
||||
|
||||
for name, err := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
message, blocking := credentialCheckFailure(err)
|
||||
require.False(t, blocking, "a provider we cannot check must still save")
|
||||
require.Empty(t, message)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCredentialCheckFailure_AnUnrecognisedFailureStillBlocks covers a fault of
|
||||
// ours rather than the vendor's — a malformed request this code built, or a
|
||||
// catalog entry whose parser does not match its endpoint. The record went
|
||||
// unverified either way, and silently saving what we could not check is the
|
||||
// thing this feature exists to prevent.
|
||||
func TestCredentialCheckFailure_AnUnrecognisedFailureStillBlocks(t *testing.T) {
|
||||
message, blocking := credentialCheckFailure(errors.New("no parser for listing shape \"\""))
|
||||
require.True(t, blocking)
|
||||
require.Equal(t, "the provider could not be checked", message)
|
||||
}
|
||||
|
||||
// newCheckedProvider returns a record shaped the way the handler guarantees
|
||||
// one: a known catalog id, a public upstream and a key.
|
||||
func newCheckedProvider(accountID string) *types.Provider {
|
||||
provider := types.NewProvider(accountID)
|
||||
provider.ProviderID = "openai_api"
|
||||
provider.Name = "openai"
|
||||
provider.UpstreamURL = "https://api.openai.com"
|
||||
provider.APIKey = "sk-good"
|
||||
provider.Enabled = true
|
||||
return provider
|
||||
}
|
||||
|
||||
// TestCreateProvider_RefusesARecordTheVendorRejects is the whole point of the
|
||||
// feature: a key with a character missing used to save cleanly and surface
|
||||
// minutes later as a failed request with nothing pointing back at the record.
|
||||
func TestCreateProvider_RefusesARecordTheVendorRejects(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
f.vendor.err = &modeldiscovery.VendorStatusError{Provider: "OpenAI", Status: 401}
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
|
||||
|
||||
_, err := f.manager.CreateProvider(ctx, "user1", newCheckedProvider("account1"))
|
||||
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "the provider rejected the credential")
|
||||
|
||||
var sErr *status.Error
|
||||
require.ErrorAs(t, err, &sErr)
|
||||
require.Equal(t, status.InvalidArgument, sErr.Type(), "the refusal must reach the caller as a 422")
|
||||
|
||||
stored, err := f.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, "account1")
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, stored, "a record that failed its check must not be written")
|
||||
}
|
||||
|
||||
// TestCreateProvider_ChecksTheCredentialItWasGiven pins what the vendor is
|
||||
// asked with, since a check run against the wrong upstream or a stale key
|
||||
// would pass while proving nothing.
|
||||
func TestCreateProvider_ChecksTheCredentialItWasGiven(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
|
||||
|
||||
_, err := f.manager.CreateProvider(ctx, "user1", newCheckedProvider("account1"))
|
||||
require.NoError(t, err)
|
||||
|
||||
asked := f.vendor.only(t)
|
||||
require.Equal(t, "openai_api", asked.CatalogID)
|
||||
require.Equal(t, "https://api.openai.com", asked.UpstreamURL)
|
||||
require.Equal(t, "sk-good", asked.APIKey)
|
||||
}
|
||||
|
||||
// TestUpdateProvider_AUrlOnlyChangeIsCheckedAgainstTheStoredKey covers the
|
||||
// case that shaped where the check sits. The key never returns to the browser,
|
||||
// so an operator editing only the URL has none to offer — the stored one is
|
||||
// the only credential there is, and the new URL still has to be proven with
|
||||
// it.
|
||||
func TestUpdateProvider_AUrlOnlyChangeIsCheckedAgainstTheStoredKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
|
||||
|
||||
created, err := f.manager.CreateProvider(ctx, "user1", newCheckedProvider("account1"))
|
||||
require.NoError(t, err)
|
||||
f.vendor.requests = nil
|
||||
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Update, true)
|
||||
edit := newCheckedProvider("account1")
|
||||
edit.ID = created.ID
|
||||
edit.UpstreamURL = "https://gateway.example.com"
|
||||
edit.APIKey = "" // the form sends no key when it was not retyped
|
||||
|
||||
_, err = f.manager.UpdateProvider(ctx, "user1", edit)
|
||||
require.NoError(t, err)
|
||||
|
||||
asked := f.vendor.only(t)
|
||||
require.Equal(t, "https://gateway.example.com", asked.UpstreamURL, "the new url must be what gets tested")
|
||||
require.Equal(t, "sk-good", asked.APIKey, "and the stored key must be what tests it")
|
||||
}
|
||||
|
||||
// TestUpdateProvider_AFailedRotationLeavesTheWorkingKeyInPlace is the
|
||||
// half-applied state the check must never produce: refusing the new key while
|
||||
// having already replaced the old one would take the provider down.
|
||||
func TestUpdateProvider_AFailedRotationLeavesTheWorkingKeyInPlace(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
|
||||
|
||||
created, err := f.manager.CreateProvider(ctx, "user1", newCheckedProvider("account1"))
|
||||
require.NoError(t, err)
|
||||
|
||||
f.vendor.err = &modeldiscovery.VendorStatusError{Provider: "OpenAI", Status: 403}
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Update, true)
|
||||
rotation := newCheckedProvider("account1")
|
||||
rotation.ID = created.ID
|
||||
rotation.APIKey = "sk-typo"
|
||||
|
||||
_, err = f.manager.UpdateProvider(ctx, "user1", rotation)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "the provider rejected the credential")
|
||||
|
||||
stored, err := f.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, "account1", created.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "sk-good", stored.APIKey, "the rejected key must not have replaced the working one")
|
||||
}
|
||||
|
||||
// TestUpdateProvider_AnEditTouchingNeitherFieldAsksNoVendor keeps renames,
|
||||
// model rows and price edits off the vendor's doorstep. They have nothing new
|
||||
// to prove, and making them wait on a vendor — or fail because one is having a
|
||||
// bad day — would be a tax on edits that carry no risk.
|
||||
func TestUpdateProvider_AnEditTouchingNeitherFieldAsksNoVendor(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
|
||||
|
||||
created, err := f.manager.CreateProvider(ctx, "user1", newCheckedProvider("account1"))
|
||||
require.NoError(t, err)
|
||||
f.vendor.requests = nil
|
||||
// Any call at all now would fail the update, which is what makes the
|
||||
// assertion below load-bearing rather than decorative.
|
||||
f.vendor.err = &modeldiscovery.VendorStatusError{Provider: "OpenAI", Status: 500}
|
||||
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Update, true)
|
||||
rename := newCheckedProvider("account1")
|
||||
rename.ID = created.ID
|
||||
rename.Name = "openai-renamed"
|
||||
rename.APIKey = ""
|
||||
|
||||
_, err = f.manager.UpdateProvider(ctx, "user1", rename)
|
||||
require.NoError(t, err, "an edit that changes neither url nor key must not be checked")
|
||||
require.Zero(t, f.vendor.calls(), "and must not reach the vendor at all")
|
||||
}
|
||||
|
||||
// TestCreateProvider_AProviderWeCannotCheckStillSaves covers the eleven
|
||||
// catalog entries with no listing endpoint, a Bedrock record behind a proxy,
|
||||
// and a self-hosted endpoint on a private network. None of those are evidence
|
||||
// the record is wrong, and refusing them would make this a lockout.
|
||||
func TestCreateProvider_AProviderWeCannotCheckStillSaves(t *testing.T) {
|
||||
cases := map[string]error{
|
||||
"gateway with no listing endpoint": modeldiscovery.ErrNoDiscovery,
|
||||
"bedrock behind a proxy": fmt.Errorf("%w: %w", modeldiscovery.ErrInvalidRequest, modeldiscovery.ErrNoDiscoveryHost),
|
||||
"self-hosted on a private network": fmt.Errorf("%w: %w", modeldiscovery.ErrInvalidRequest, modeldiscovery.ErrPrivateHost),
|
||||
}
|
||||
|
||||
for name, vendorErr := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
f.vendor.err = vendorErr
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
|
||||
|
||||
created, err := f.manager.CreateProvider(ctx, "user1", newCheckedProvider("account1"))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, created)
|
||||
|
||||
stored, err := f.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, "account1")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, stored, 1, "a provider we cannot check must still be written")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoveryFailure_TellsTheOperatorWhatWentWrong covers the button, not the
|
||||
// save. Pressing "Load models from provider" against a bad key used to answer
|
||||
// "internal server error", which names neither the thing that failed nor
|
||||
// anything the operator could act on — every outcome here is their key or their
|
||||
// URL.
|
||||
func TestDiscoveryFailure_TellsTheOperatorWhatWentWrong(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
"refused credential": {
|
||||
err: &modeldiscovery.VendorStatusError{Provider: "Bedrock", Status: 403},
|
||||
want: "the provider rejected the credential",
|
||||
},
|
||||
"upstream that is not the api": {
|
||||
err: &modeldiscovery.VendorStatusError{Provider: "OpenAI", Status: 404},
|
||||
want: "the upstream url did not answer a model listing",
|
||||
},
|
||||
"upstream that does not resolve": {
|
||||
err: &modeldiscovery.UnreachableError{
|
||||
Provider: "OpenAI",
|
||||
Err: &net.DNSError{Err: "no such host", Name: "api.example.com", IsNotFound: true},
|
||||
},
|
||||
want: "the upstream url could not be reached: no such host",
|
||||
},
|
||||
"vendor having a bad day": {
|
||||
err: &modeldiscovery.VendorStatusError{Provider: "Anthropic", Status: 503},
|
||||
want: "the provider returned an error",
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
err := discoveryFailure(context.Background(), "openai_api", tc.err)
|
||||
require.EqualError(t, err, tc.want)
|
||||
|
||||
var sErr *status.Error
|
||||
require.ErrorAs(t, err, &sErr)
|
||||
require.Equal(t, status.InvalidArgument, sErr.Type(),
|
||||
"a failure the operator caused must not read as a server fault")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoveryFailure_LeavesTheCatalogFactsAlone keeps the two outcomes the
|
||||
// handler already maps. A provider with no listing endpoint is a fact about the
|
||||
// catalog entry, and the caller falls back to the catalog's own models rather
|
||||
// than showing an error at all — rewriting it as a refusal would turn a normal
|
||||
// path into one.
|
||||
func TestDiscoveryFailure_LeavesTheCatalogFactsAlone(t *testing.T) {
|
||||
for name, err := range map[string]error{
|
||||
"no listing endpoint": modeldiscovery.ErrNoDiscovery,
|
||||
"bad request": fmt.Errorf("%w: unknown catalog provider", modeldiscovery.ErrInvalidRequest),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
require.Equal(t, err, discoveryFailure(context.Background(), "openai_api", err),
|
||||
"the handler's own mapping must still see the original error")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverProviderModels_SurfacesTheVendorRefusal drives the manager rather
|
||||
// than the classifier, so a future refactor that stops translating on this path
|
||||
// fails here rather than silently going back to 500s.
|
||||
func TestDiscoverProviderModels_SurfacesTheVendorRefusal(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
f.vendor.err = &modeldiscovery.VendorStatusError{Provider: "OpenAI", Status: 401}
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
|
||||
|
||||
_, err := f.manager.DiscoverProviderModels(ctx, "account1", "user1", modeldiscovery.Request{
|
||||
CatalogID: "openai_api",
|
||||
UpstreamURL: "https://api.openai.com",
|
||||
APIKey: "sk-wrong",
|
||||
}, "")
|
||||
|
||||
require.EqualError(t, err, "the provider rejected the credential")
|
||||
}
|
||||
|
||||
// TestDiscoverProviderModels_ListsAgainstTheUrlOnTheForm covers the edit the
|
||||
// operator cannot otherwise make: the upstream has been retyped and the
|
||||
// credential has not, because the API never returned it to be retyped. Naming
|
||||
// the record supplies the key; the request supplies the URL under test.
|
||||
func TestDiscoverProviderModels_ListsAgainstTheUrlOnTheForm(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
// Twice: the create, and the listing, which is gated on Create too.
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
|
||||
|
||||
created, err := f.manager.CreateProvider(ctx, "user1", newCheckedProvider("account1"))
|
||||
require.NoError(t, err)
|
||||
f.vendor.requests = nil
|
||||
|
||||
_, err = f.manager.DiscoverProviderModels(ctx, "account1", "user1", modeldiscovery.Request{
|
||||
CatalogID: "openai_api",
|
||||
UpstreamURL: "https://gateway.example.com",
|
||||
}, created.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
asked := f.vendor.only(t)
|
||||
require.Equal(t, "https://gateway.example.com", asked.UpstreamURL, "the typed url must be the one listed against")
|
||||
require.Equal(t, "sk-good", asked.APIKey, "and the stored key must be what lists it")
|
||||
}
|
||||
|
||||
// TestDiscoverProviderModels_FallsBackToTheStoredUrl keeps the plain refresh
|
||||
// working: a request naming only the record still reaches the saved upstream.
|
||||
func TestDiscoverProviderModels_FallsBackToTheStoredUrl(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
|
||||
|
||||
created, err := f.manager.CreateProvider(ctx, "user1", newCheckedProvider("account1"))
|
||||
require.NoError(t, err)
|
||||
stored := f.vendor.only(t).UpstreamURL
|
||||
f.vendor.requests = nil
|
||||
|
||||
_, err = f.manager.DiscoverProviderModels(ctx, "account1", "user1", modeldiscovery.Request{
|
||||
CatalogID: "openai_api",
|
||||
}, created.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, stored, f.vendor.only(t).UpstreamURL)
|
||||
}
|
||||
|
||||
// TestUpdateProvider_MovingARecordToAnotherVendorIsChecked covers the edit that
|
||||
// changes neither field the vendor judges and still invalidates both. The
|
||||
// catalog entry decides which vendor is asked and under which auth header, so
|
||||
// the unchanged credential is now being offered somewhere it has never been
|
||||
// accepted.
|
||||
func TestUpdateProvider_MovingARecordToAnotherVendorIsChecked(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
|
||||
|
||||
created, err := f.manager.CreateProvider(ctx, "user1", newCheckedProvider("account1"))
|
||||
require.NoError(t, err)
|
||||
f.vendor.requests = nil
|
||||
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Update, true)
|
||||
edit := newCheckedProvider("account1")
|
||||
edit.ID = created.ID
|
||||
edit.ProviderID = "anthropic_api"
|
||||
edit.APIKey = ""
|
||||
|
||||
_, err = f.manager.UpdateProvider(ctx, "user1", edit)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "anthropic_api", f.vendor.only(t).CatalogID,
|
||||
"the new vendor is the one that has to accept the key")
|
||||
}
|
||||
|
||||
// TestCreateProvider_ASkipTlsRecordIsNotCheckedAgainstItsCertificate covers the
|
||||
// lockout the check would otherwise be: the flag exists for a self-hosted
|
||||
// endpoint behind a certificate nothing public can verify, and discovery
|
||||
// verifies certificates. Refusing the save would reject the record for the one
|
||||
// reason the operator already declared they accept.
|
||||
func TestCreateProvider_ASkipTlsRecordIsNotCheckedAgainstItsCertificate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
f.vendor.err = &modeldiscovery.UnreachableError{
|
||||
Provider: "OpenAI",
|
||||
Err: &tls.CertificateVerificationError{},
|
||||
}
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
|
||||
|
||||
provider := newCheckedProvider("account1")
|
||||
provider.SkipTLSVerification = true
|
||||
|
||||
created, err := f.manager.CreateProvider(ctx, "user1", provider)
|
||||
require.NoError(t, err, "a record we were told not to verify must still save")
|
||||
require.NotEmpty(t, created.ID)
|
||||
require.Zero(t, f.vendor.calls(), "and the vendor must not be asked at all")
|
||||
}
|
||||
|
||||
// TestCreateProvider_TheStoredKeyIsTheOneThatWasChecked pins the two halves to
|
||||
// one value. The vendor call trims the credential before building its auth
|
||||
// header; the synthesiser substitutes the stored one verbatim. A key pasted
|
||||
// with surrounding whitespace would otherwise pass its check and then fail
|
||||
// every request the provider serves.
|
||||
func TestCreateProvider_TheStoredKeyIsTheOneThatWasChecked(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
|
||||
|
||||
provider := newCheckedProvider("account1")
|
||||
provider.APIKey = " sk-good\n"
|
||||
|
||||
created, err := f.manager.CreateProvider(ctx, "user1", provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "sk-good", f.vendor.only(t).APIKey, "the vendor is asked about the trimmed key")
|
||||
|
||||
stored, err := f.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, "account1", created.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "sk-good", stored.APIKey, "and that is the one the proxy will send")
|
||||
}
|
||||
|
||||
// TestUpdateProvider_TurningTlsVerificationBackOnChecksTheRecord covers the
|
||||
// hole the skip-TLS exemption opens on its own. Such a record is stored without
|
||||
// ever being checked, so the moment verification is switched back on is the
|
||||
// first moment it can be checked at all — and none of the three fields the
|
||||
// re-check usually watches has to move for that to happen.
|
||||
func TestUpdateProvider_TurningTlsVerificationBackOnChecksTheRecord(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
|
||||
|
||||
unchecked := newCheckedProvider("account1")
|
||||
unchecked.SkipTLSVerification = true
|
||||
created, err := f.manager.CreateProvider(ctx, "user1", unchecked)
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, f.vendor.calls(), "the create was exempt")
|
||||
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Update, true)
|
||||
edit := newCheckedProvider("account1")
|
||||
edit.ID = created.ID
|
||||
edit.APIKey = ""
|
||||
edit.SkipTLSVerification = false
|
||||
|
||||
_, err = f.manager.UpdateProvider(ctx, "user1", edit)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, f.vendor.calls(), "switching verification on must check what was never checked")
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
"github.com/netbirdio/netbird/shared/management/http/util"
|
||||
)
|
||||
|
||||
// addAccessLogEndpoints registers the read-only, server-side-filtered
|
||||
// agent-network access-log listing and the aggregated usage overview.
|
||||
func (h *handler) addAccessLogEndpoints(router *mux.Router) {
|
||||
router.HandleFunc("/agent-network/access-logs", h.listAccessLogs).Methods("GET", "OPTIONS")
|
||||
router.HandleFunc("/agent-network/access-log-sessions", h.listAccessLogSessions).Methods("GET", "OPTIONS")
|
||||
router.HandleFunc("/agent-network/usage/overview", h.getUsageOverview).Methods("GET", "OPTIONS")
|
||||
}
|
||||
|
||||
func (h *handler) getUsageOverview(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
// Reuse the access-log filter for the shared date/user/group/provider/model
|
||||
// params; pagination/sort/search are irrelevant for an aggregate.
|
||||
var filter types.AgentNetworkAccessLogFilter
|
||||
if err := filter.ParseFromRequest(r); err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
// Bound the aggregation window so an unbounded or over-wide query can't load
|
||||
// an account's entire usage history into memory.
|
||||
filter.ApplyUsageOverviewBounds(time.Now())
|
||||
granularity := types.ParseUsageGranularity(r.URL.Query().Get("granularity"))
|
||||
|
||||
buckets, err := h.manager.GetUsageOverview(r.Context(), userAuth.AccountId, userAuth.UserId, filter, granularity)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]api.AgentNetworkUsageBucket, 0, len(buckets))
|
||||
for _, b := range buckets {
|
||||
out = append(out, b.ToAPIResponse())
|
||||
}
|
||||
util.WriteJSONObject(r.Context(), w, out)
|
||||
}
|
||||
|
||||
func (h *handler) listAccessLogs(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
var filter types.AgentNetworkAccessLogFilter
|
||||
if err := filter.ParseFromRequest(r); err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
rows, total, err := h.manager.ListAccessLogs(r.Context(), userAuth.AccountId, userAuth.UserId, filter)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
data := make([]api.AgentNetworkAccessLog, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
data = append(data, row.ToAPIResponse())
|
||||
}
|
||||
|
||||
pageSize := filter.GetLimit()
|
||||
totalPages := 0
|
||||
if pageSize > 0 {
|
||||
totalPages = int((total + int64(pageSize) - 1) / int64(pageSize))
|
||||
}
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, api.AgentNetworkAccessLogsResponse{
|
||||
Data: data,
|
||||
Page: filter.Page,
|
||||
PageSize: pageSize,
|
||||
TotalRecords: int(total),
|
||||
TotalPages: totalPages,
|
||||
})
|
||||
}
|
||||
|
||||
// listAccessLogSessions returns the access logs grouped by session: the page
|
||||
// unit is a session (total counts sessions), each carrying an aggregate summary
|
||||
// and its ordered entries. Accepts the same filters as listAccessLogs.
|
||||
func (h *handler) listAccessLogSessions(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
var filter types.AgentNetworkAccessLogFilter
|
||||
if err := filter.ParseFromRequest(r); err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
sessions, total, err := h.manager.ListAccessLogSessions(r.Context(), userAuth.AccountId, userAuth.UserId, filter)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
data := make([]api.AgentNetworkAccessLogSession, 0, len(sessions))
|
||||
for _, sess := range sessions {
|
||||
data = append(data, sess.ToAPIResponse())
|
||||
}
|
||||
|
||||
pageSize := filter.GetLimit()
|
||||
totalPages := 0
|
||||
if pageSize > 0 {
|
||||
totalPages = int((total + int64(pageSize) - 1) / int64(pageSize))
|
||||
}
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, api.AgentNetworkAccessLogSessionsResponse{
|
||||
Data: data,
|
||||
Page: filter.Page,
|
||||
PageSize: pageSize,
|
||||
TotalRecords: int(total),
|
||||
TotalPages: totalPages,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
"github.com/netbirdio/netbird/shared/management/http/util"
|
||||
)
|
||||
|
||||
// addAgentConfigEndpoints registers the self-service agent-config route.
|
||||
// It is available to every authenticated user regardless of role: the
|
||||
// providers in the response are scoped strictly to the caller, which is
|
||||
// tighter than any role gate could be. The caller's own usage and requests are served by
|
||||
// the regular usage/logs endpoints, which self-scope for callers without
|
||||
// the account-wide grants.
|
||||
func (h *handler) addAgentConfigEndpoints(router *mux.Router) {
|
||||
router.HandleFunc("/agent-network/agent-config", h.getAgentConfig).Methods("GET", "OPTIONS")
|
||||
}
|
||||
|
||||
func (h *handler) getAgentConfig(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
setup, err := h.manager.GetAgentConfigForUser(r.Context(), userAuth.AccountId, userAuth.UserId)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, agentConfigToAPI(setup))
|
||||
}
|
||||
|
||||
func agentConfigToAPI(setup *types.AgentConfig) api.AgentNetworkAgentConfig {
|
||||
providers := make([]api.AgentNetworkAgentConfigProvider, 0, len(setup.Providers))
|
||||
for _, p := range setup.Providers {
|
||||
providers = append(providers, api.AgentNetworkAgentConfigProvider{
|
||||
Name: p.Name,
|
||||
CatalogId: p.CatalogID,
|
||||
ApiFlavor: p.APIFlavor,
|
||||
AllModelsAllowed: p.AllModelsAllowed,
|
||||
Models: p.Models,
|
||||
})
|
||||
}
|
||||
return api.AgentNetworkAgentConfig{
|
||||
Configured: setup.Configured,
|
||||
Endpoint: setup.Endpoint,
|
||||
Providers: providers,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
"github.com/netbirdio/netbird/shared/management/http/util"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
// addBudgetRuleEndpoints registers the account-level budget rule routes.
|
||||
func (h *handler) addBudgetRuleEndpoints(router *mux.Router) {
|
||||
router.HandleFunc("/agent-network/budget-rules", h.getAllBudgetRules).Methods("GET", "OPTIONS")
|
||||
router.HandleFunc("/agent-network/budget-rules", h.createBudgetRule).Methods("POST", "OPTIONS")
|
||||
router.HandleFunc("/agent-network/budget-rules/{ruleId}", h.getBudgetRule).Methods("GET", "OPTIONS")
|
||||
router.HandleFunc("/agent-network/budget-rules/{ruleId}", h.updateBudgetRule).Methods("PUT", "OPTIONS")
|
||||
router.HandleFunc("/agent-network/budget-rules/{ruleId}", h.deleteBudgetRule).Methods("DELETE", "OPTIONS")
|
||||
}
|
||||
|
||||
func (h *handler) getAllBudgetRules(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
rules, err := h.manager.GetAllBudgetRules(r.Context(), userAuth.AccountId, userAuth.UserId)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]*api.AgentNetworkBudgetRule, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
out = append(out, rule.ToAPIResponse())
|
||||
}
|
||||
util.WriteJSONObject(r.Context(), w, out)
|
||||
}
|
||||
|
||||
func (h *handler) getBudgetRule(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
ruleID := mux.Vars(r)["ruleId"]
|
||||
if ruleID == "" {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "budget rule ID is required"), w)
|
||||
return
|
||||
}
|
||||
|
||||
rule, err := h.manager.GetBudgetRule(r.Context(), userAuth.AccountId, userAuth.UserId, ruleID)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
util.WriteJSONObject(r.Context(), w, rule.ToAPIResponse())
|
||||
}
|
||||
|
||||
func (h *handler) createBudgetRule(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
var req api.AgentNetworkBudgetRuleRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateBudgetRule(&req); err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
rule := types.NewAccountBudgetRule(userAuth.AccountId)
|
||||
rule.FromAPIRequest(&req)
|
||||
|
||||
created, err := h.manager.CreateBudgetRule(r.Context(), userAuth.UserId, rule)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
util.WriteJSONObject(r.Context(), w, created.ToAPIResponse())
|
||||
}
|
||||
|
||||
func (h *handler) updateBudgetRule(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
ruleID := mux.Vars(r)["ruleId"]
|
||||
if ruleID == "" {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "budget rule ID is required"), w)
|
||||
return
|
||||
}
|
||||
|
||||
var req api.AgentNetworkBudgetRuleRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateBudgetRule(&req); err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
rule := &types.AccountBudgetRule{ID: ruleID, AccountID: userAuth.AccountId}
|
||||
rule.FromAPIRequest(&req)
|
||||
|
||||
updated, err := h.manager.UpdateBudgetRule(r.Context(), userAuth.UserId, rule)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse())
|
||||
}
|
||||
|
||||
func (h *handler) deleteBudgetRule(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
ruleID := mux.Vars(r)["ruleId"]
|
||||
if ruleID == "" {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "budget rule ID is required"), w)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.manager.DeleteBudgetRule(r.Context(), userAuth.AccountId, userAuth.UserId, ruleID); err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
|
||||
}
|
||||
|
||||
// validateBudgetRule rejects malformed budget rules. It reuses the policy limit
|
||||
// validation since the cap shape is identical, and rejects empty target entries.
|
||||
func validateBudgetRule(req *api.AgentNetworkBudgetRuleRequest) error {
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
return status.Errorf(status.InvalidArgument, "name is required")
|
||||
}
|
||||
if req.TargetGroups != nil {
|
||||
for _, id := range *req.TargetGroups {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return status.Errorf(status.InvalidArgument, "target_groups must not contain empty entries")
|
||||
}
|
||||
}
|
||||
}
|
||||
if req.TargetUsers != nil {
|
||||
for _, id := range *req.TargetUsers {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return status.Errorf(status.InvalidArgument, "target_users must not contain empty entries")
|
||||
}
|
||||
}
|
||||
}
|
||||
return validatePolicyLimits(req.Limits)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
// TestBudgetRuleHandler_RoundTrip seeds a budget rule via the store and asserts
|
||||
// the GET wire shape carries targets and the reused PolicyLimits cap shape. The
|
||||
// create/update/delete success paths go through accountManager.StoreEvent which
|
||||
// this fixture doesn't wire — they are covered by the manager-level no-mock
|
||||
// test (TestAgentNetwork_BudgetRuleCRUD_RealManager).
|
||||
func TestBudgetRuleHandler_RoundTrip(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
rule := &agentNetworkTypes.AccountBudgetRule{
|
||||
ID: "ainbud_test",
|
||||
AccountID: testAccountID,
|
||||
Name: "org-monthly",
|
||||
Enabled: true,
|
||||
TargetGroups: []string{"grp-eng"},
|
||||
TargetUsers: []string{"user-alice"},
|
||||
Limits: agentNetworkTypes.PolicyLimits{
|
||||
TokenLimit: agentNetworkTypes.PolicyTokenLimit{Enabled: true, GroupCap: 100000, UserCap: 10000, WindowSeconds: 2_592_000},
|
||||
BudgetLimit: agentNetworkTypes.PolicyBudgetLimit{Enabled: true, GroupCapUsd: 500, WindowSeconds: 2_592_000},
|
||||
},
|
||||
}
|
||||
require.NoError(t, f.store.SaveAgentNetworkBudgetRule(context.Background(), rule))
|
||||
|
||||
rec := f.do(t, http.MethodGet, "/agent-network/budget-rules/"+rule.ID, "")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "GET must succeed: %s", rec.Body.String())
|
||||
|
||||
var got api.AgentNetworkBudgetRule
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
assert.Equal(t, "org-monthly", got.Name, "name must round-trip")
|
||||
assert.Equal(t, []string{"grp-eng"}, got.TargetGroups, "target groups must round-trip")
|
||||
assert.Equal(t, []string{"user-alice"}, got.TargetUsers, "target users must round-trip")
|
||||
assert.Equal(t, int64(100000), got.Limits.TokenLimit.GroupCap, "token group cap must round-trip")
|
||||
assert.Equal(t, int64(2_592_000), got.Limits.BudgetLimit.WindowSeconds, "budget window must round-trip")
|
||||
}
|
||||
|
||||
// TestBudgetRuleHandler_ListReturnsArray asserts the list endpoint returns a
|
||||
// JSON array (never null) for an account with no rules.
|
||||
func TestBudgetRuleHandler_ListReturnsArray(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
rec := f.do(t, http.MethodGet, "/agent-network/budget-rules", "")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "GET must succeed: %s", rec.Body.String())
|
||||
assert.Equal(t, "[]", trimSpace(rec.Body.String()), "empty account must return an empty array, not null")
|
||||
}
|
||||
|
||||
// TestBudgetRuleHandler_RejectsMissingName covers the validation path (which
|
||||
// runs before the manager call, so it works without a wired accountManager).
|
||||
func TestBudgetRuleHandler_RejectsMissingName(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
body := `{
|
||||
"name": "",
|
||||
"limits": {
|
||||
"token_limit": {"enabled": false, "group_cap": 0, "user_cap": 0, "window_seconds": 0},
|
||||
"budget_limit": {"enabled": false, "group_cap_usd": 0, "user_cap_usd": 0, "window_seconds": 0}
|
||||
}
|
||||
}`
|
||||
rec := f.do(t, http.MethodPost, "/agent-network/budget-rules", body)
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code,
|
||||
"missing name must be rejected as a validation error (not a route/auth 4xx): got %d body=%s", rec.Code, rec.Body.String())
|
||||
assert.Contains(t, rec.Body.String(), "name",
|
||||
"rejection body must name the offending field, proving the validation path: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// TestBudgetRuleHandler_RejectsSubMinuteWindow proves budget rules reuse the
|
||||
// policy-limit validation (enabled limit needs window >= 60s).
|
||||
func TestBudgetRuleHandler_RejectsSubMinuteWindow(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
body := `{
|
||||
"name": "bad-window",
|
||||
"limits": {
|
||||
"token_limit": {"enabled": true, "group_cap": 1000, "user_cap": 0, "window_seconds": 30},
|
||||
"budget_limit": {"enabled": false, "group_cap_usd": 0, "user_cap_usd": 0, "window_seconds": 0}
|
||||
}
|
||||
}`
|
||||
rec := f.do(t, http.MethodPost, "/agent-network/budget-rules", body)
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code,
|
||||
"sub-minute window must be rejected as a validation error (not a route/auth 4xx): got %d body=%s", rec.Code, rec.Body.String())
|
||||
assert.Contains(t, rec.Body.String(), "window_seconds",
|
||||
"rejection body must name the offending window_seconds field, proving the validation path: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// TestSettingsHandler_GetExposesCollectionToggles asserts the GET settings wire
|
||||
// shape carries the account-level collection toggles after a store seed.
|
||||
func TestSettingsHandler_GetExposesCollectionToggles(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
require.NoError(t, f.store.SaveAgentNetworkSettings(context.Background(), &agentNetworkTypes.Settings{
|
||||
AccountID: testAccountID,
|
||||
Domain: "violet.eu.proxy.netbird.io",
|
||||
ProxyAddress: "eu.proxy.netbird.io",
|
||||
EnableLogCollection: true,
|
||||
EnablePromptCollection: true,
|
||||
RedactPii: false,
|
||||
}))
|
||||
|
||||
rec := f.do(t, http.MethodGet, "/agent-network/settings", "")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "GET must succeed: %s", rec.Body.String())
|
||||
|
||||
var got api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
assert.True(t, got.EnableLogCollection, "log collection toggle must surface on the wire")
|
||||
assert.True(t, got.EnablePromptCollection, "prompt collection toggle must surface on the wire")
|
||||
assert.False(t, got.RedactPii, "redact toggle must surface its false value")
|
||||
assert.Equal(t, "violet.eu.proxy.netbird.io", got.Endpoint, "endpoint stays computed from immutable cluster+subdomain")
|
||||
}
|
||||
|
||||
func trimSpace(s string) string {
|
||||
for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == ' ' || s[len(s)-1] == '\t' || s[len(s)-1] == '\r') {
|
||||
s = s[:len(s)-1]
|
||||
}
|
||||
for len(s) > 0 && (s[0] == '\n' || s[0] == ' ' || s[0] == '\t' || s[0] == '\r') {
|
||||
s = s[1:]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
"github.com/netbirdio/netbird/shared/management/http/util"
|
||||
)
|
||||
|
||||
// addConsumptionEndpoints registers the read-only Agent Network
|
||||
// consumption listing — backs the dashboard's basic counter view.
|
||||
func (h *handler) addConsumptionEndpoints(router *mux.Router) {
|
||||
router.HandleFunc("/agent-network/consumption", h.listConsumption).Methods("GET", "OPTIONS")
|
||||
}
|
||||
|
||||
func (h *handler) listConsumption(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.manager.ListConsumption(r.Context(), userAuth.AccountId, userAuth.UserId)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]api.AgentNetworkConsumption, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, consumptionToAPI(row))
|
||||
}
|
||||
util.WriteJSONObject(r.Context(), w, out)
|
||||
}
|
||||
|
||||
func consumptionToAPI(c *types.Consumption) api.AgentNetworkConsumption {
|
||||
windowStart := c.WindowStartUTC
|
||||
updatedAt := c.UpdatedAt
|
||||
return api.AgentNetworkConsumption{
|
||||
DimensionKind: api.AgentNetworkConsumptionDimensionKind(c.DimensionKind),
|
||||
DimensionId: c.DimensionID,
|
||||
WindowSeconds: c.WindowSeconds,
|
||||
WindowStartUtc: windowStart,
|
||||
TokensInput: c.TokensInput,
|
||||
TokensOutput: c.TokensOutput,
|
||||
CostUsd: c.CostUSD,
|
||||
UpdatedAt: &updatedAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
"github.com/netbirdio/netbird/shared/management/http/util"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
// addGuardrailEndpoints registers all Agent Network guardrail routes.
|
||||
func (h *handler) addGuardrailEndpoints(router *mux.Router) {
|
||||
router.HandleFunc("/agent-network/guardrails", h.getAllGuardrails).Methods("GET", "OPTIONS")
|
||||
router.HandleFunc("/agent-network/guardrails", h.createGuardrail).Methods("POST", "OPTIONS")
|
||||
router.HandleFunc("/agent-network/guardrails/{guardrailId}", h.getGuardrail).Methods("GET", "OPTIONS")
|
||||
router.HandleFunc("/agent-network/guardrails/{guardrailId}", h.updateGuardrail).Methods("PUT", "OPTIONS")
|
||||
router.HandleFunc("/agent-network/guardrails/{guardrailId}", h.deleteGuardrail).Methods("DELETE", "OPTIONS")
|
||||
}
|
||||
|
||||
func (h *handler) getAllGuardrails(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
guardrails, err := h.manager.GetAllGuardrails(r.Context(), userAuth.AccountId, userAuth.UserId)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]*api.AgentNetworkGuardrail, 0, len(guardrails))
|
||||
for _, g := range guardrails {
|
||||
out = append(out, g.ToAPIResponse())
|
||||
}
|
||||
util.WriteJSONObject(r.Context(), w, out)
|
||||
}
|
||||
|
||||
func (h *handler) getGuardrail(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
guardrailID := mux.Vars(r)["guardrailId"]
|
||||
if guardrailID == "" {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "guardrail ID is required"), w)
|
||||
return
|
||||
}
|
||||
|
||||
guardrail, err := h.manager.GetGuardrail(r.Context(), userAuth.AccountId, userAuth.UserId, guardrailID)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
util.WriteJSONObject(r.Context(), w, guardrail.ToAPIResponse())
|
||||
}
|
||||
|
||||
func (h *handler) createGuardrail(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
var req api.AgentNetworkGuardrailRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateGuardrail(&req); err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
guardrail := types.NewGuardrail(userAuth.AccountId)
|
||||
guardrail.FromAPIRequest(&req)
|
||||
|
||||
created, err := h.manager.CreateGuardrail(r.Context(), userAuth.UserId, guardrail)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, created.ToAPIResponse())
|
||||
}
|
||||
|
||||
func (h *handler) updateGuardrail(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
guardrailID := mux.Vars(r)["guardrailId"]
|
||||
if guardrailID == "" {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "guardrail ID is required"), w)
|
||||
return
|
||||
}
|
||||
|
||||
var req api.AgentNetworkGuardrailRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateGuardrail(&req); err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
guardrail := &types.Guardrail{
|
||||
ID: guardrailID,
|
||||
AccountID: userAuth.AccountId,
|
||||
}
|
||||
guardrail.FromAPIRequest(&req)
|
||||
|
||||
updated, err := h.manager.UpdateGuardrail(r.Context(), userAuth.UserId, guardrail)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse())
|
||||
}
|
||||
|
||||
func (h *handler) deleteGuardrail(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
guardrailID := mux.Vars(r)["guardrailId"]
|
||||
if guardrailID == "" {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "guardrail ID is required"), w)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.manager.DeleteGuardrail(r.Context(), userAuth.AccountId, userAuth.UserId, guardrailID); err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
|
||||
}
|
||||
|
||||
func validateGuardrail(req *api.AgentNetworkGuardrailRequest) error {
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
return status.Errorf(status.InvalidArgument, "name is required")
|
||||
}
|
||||
|
||||
c := req.Checks
|
||||
if c.ModelAllowlist.Enabled {
|
||||
for _, id := range c.ModelAllowlist.Models {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return status.Errorf(status.InvalidArgument, "model_allowlist.models must not contain empty entries")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/server/account"
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
nbtypes "github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/auth"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
const (
|
||||
testAccountID = "acc-1"
|
||||
testUserID = "user-bob"
|
||||
)
|
||||
|
||||
// agentNetworkHandlerFixture builds a real agentnetwork.Manager with
|
||||
// a sqlite store and an always-allow permissions mock, then exposes
|
||||
// the HTTP handlers via a gorilla router. Tests issue requests
|
||||
// through httptest and assert on the wire shape — the same path the
|
||||
// dashboard exercises.
|
||||
type agentNetworkHandlerFixture struct {
|
||||
store store.Store
|
||||
manager agentnetwork.Manager
|
||||
router *mux.Router
|
||||
}
|
||||
|
||||
func newAgentNetworkHandlerFixture(t *testing.T) *agentNetworkHandlerFixture {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("sqlite store not properly supported on Windows yet")
|
||||
}
|
||||
t.Setenv("NETBIRD_STORE_ENGINE", string(nbtypes.SqliteStoreEngine))
|
||||
|
||||
st, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(cleanUp)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
perms := permissions.NewMockManager(ctrl)
|
||||
// Always-allow: the handler tests are about wire shape, not
|
||||
// authz. Authz is covered by the manager's own tests.
|
||||
perms.EXPECT().
|
||||
ValidateUserPermissions(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
Return(true, context.Background(), nil).
|
||||
AnyTimes()
|
||||
|
||||
// Swallow activity events so the mutation paths (create/update/delete)
|
||||
// are exercisable through the HTTP layer.
|
||||
accounts := account.NewMockManager(ctrl)
|
||||
accounts.EXPECT().
|
||||
StoreEvent(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
AnyTimes()
|
||||
accounts.EXPECT().
|
||||
UpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
AnyTimes()
|
||||
|
||||
manager := agentnetwork.NewManager(st, perms, accounts, nil)
|
||||
h := &handler{manager: manager}
|
||||
|
||||
router := mux.NewRouter()
|
||||
router.HandleFunc("/agent-network/providers", h.createProvider).Methods("POST")
|
||||
router.HandleFunc("/agent-network/providers/{providerId}", h.getProvider).Methods("GET")
|
||||
router.HandleFunc("/agent-network/providers/{providerId}", h.updateProvider).Methods("PUT")
|
||||
h.addPolicyEndpoints(router)
|
||||
h.addConsumptionEndpoints(router)
|
||||
h.addBudgetRuleEndpoints(router)
|
||||
h.addSettingsEndpoints(router)
|
||||
|
||||
return &agentNetworkHandlerFixture{
|
||||
store: st,
|
||||
manager: manager,
|
||||
router: router,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *agentNetworkHandlerFixture) do(t *testing.T, method, path, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var reader io.Reader
|
||||
if body != "" {
|
||||
reader = strings.NewReader(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, reader)
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{
|
||||
UserId: testUserID,
|
||||
AccountId: testAccountID,
|
||||
})
|
||||
rec := httptest.NewRecorder()
|
||||
f.router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// seedProvider persists a minimal provider record so policy create
|
||||
// passes the manager's destination_provider_ids existence check.
|
||||
func (f *agentNetworkHandlerFixture) seedProvider(t *testing.T, id string) {
|
||||
t.Helper()
|
||||
require.NoError(t, f.store.SaveAgentNetworkProvider(context.Background(), &agentNetworkTypes.Provider{
|
||||
ID: id,
|
||||
AccountID: testAccountID,
|
||||
ProviderID: "openai_api",
|
||||
Name: "test-" + id,
|
||||
UpstreamURL: "https://api.openai.com",
|
||||
APIKey: "sk-test",
|
||||
Enabled: true,
|
||||
SessionPrivateKey: "test-priv-key",
|
||||
SessionPublicKey: "test-pub-key",
|
||||
}))
|
||||
}
|
||||
|
||||
// TestPolicyHandler_WindowSecondsRoundTrip ports bash 10 to Go:
|
||||
// assert that a policy with window_seconds on both Token + Budget
|
||||
// halves round-trips through GET unchanged AND that legacy
|
||||
// window_hours / window_days are absent from the JSON response. We
|
||||
// seed the policy directly via the store rather than POST-ing
|
||||
// because the create path goes through the manager's
|
||||
// accountManager.StoreEvent which we don't wire in this fixture; the
|
||||
// on-wire shape is what matters here, and the POST validation path
|
||||
// is covered separately by the RejectsSubMinuteWindow test.
|
||||
func TestPolicyHandler_WindowSecondsRoundTrip(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
policy := &agentNetworkTypes.Policy{
|
||||
ID: "ainpol_test",
|
||||
AccountID: testAccountID,
|
||||
Name: "round-trip",
|
||||
Enabled: true,
|
||||
SourceGroups: []string{"grp-engineers"},
|
||||
DestinationProviderIDs: []string{"prov-1"},
|
||||
Limits: agentNetworkTypes.PolicyLimits{
|
||||
TokenLimit: agentNetworkTypes.PolicyTokenLimit{Enabled: true, GroupCap: 10000, UserCap: 5000, WindowSeconds: 86_400},
|
||||
BudgetLimit: agentNetworkTypes.PolicyBudgetLimit{Enabled: true, GroupCapUsd: 10.0, UserCapUsd: 2.5, WindowSeconds: 2_592_000},
|
||||
},
|
||||
}
|
||||
require.NoError(t, f.store.SaveAgentNetworkPolicy(context.Background(), policy))
|
||||
|
||||
rec := f.do(t, http.MethodGet, "/agent-network/policies/"+policy.ID, "")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "GET must succeed: %s", rec.Body.String())
|
||||
|
||||
var got api.AgentNetworkPolicy
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
assert.Equal(t, int64(86_400), got.Limits.TokenLimit.WindowSeconds, "token_limit.window_seconds must round-trip")
|
||||
assert.Equal(t, int64(2_592_000), got.Limits.BudgetLimit.WindowSeconds, "budget_limit.window_seconds must round-trip")
|
||||
|
||||
// Legacy field names must NOT appear in the response — would
|
||||
// signal that the management server is still emitting the old
|
||||
// shape and would fool a v1 dashboard into rendering days/hours.
|
||||
assert.NotContains(t, rec.Body.String(), "window_hours",
|
||||
"legacy window_hours field must be absent from the on-wire response")
|
||||
assert.NotContains(t, rec.Body.String(), "window_days",
|
||||
"legacy window_days field must be absent from the on-wire response")
|
||||
}
|
||||
|
||||
// TestPolicyHandler_RejectsSubMinuteWindow ports bash 20 to Go: an
|
||||
// enabled limit with window_seconds < 60 must surface as a 4xx
|
||||
// because anything finer than per-minute produces an untenable
|
||||
// volume of consumption rows for a feature whose value comes from
|
||||
// per-window cap enforcement.
|
||||
func TestPolicyHandler_RejectsSubMinuteWindow(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
f.seedProvider(t, "prov-1")
|
||||
|
||||
body := `{
|
||||
"name": "sub-minute-window",
|
||||
"enabled": true,
|
||||
"source_groups": ["grp-engineers"],
|
||||
"destination_provider_ids": ["prov-1"],
|
||||
"guardrail_ids": [],
|
||||
"limits": {
|
||||
"token_limit": {"enabled": true, "group_cap": 10000, "user_cap": 5000, "window_seconds": 30},
|
||||
"budget_limit": {"enabled": false, "group_cap_usd": 0, "user_cap_usd": 0, "window_seconds": 0}
|
||||
}
|
||||
}`
|
||||
rec := f.do(t, http.MethodPost, "/agent-network/policies", body)
|
||||
// 422 specifically (InvalidArgument) proves the window-validation path —
|
||||
// a route miss would be 404 and an auth failure 403, so a generic 4xx
|
||||
// would let those false-pass.
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code,
|
||||
"enabled token_limit with window_seconds<60 must be rejected as a validation error: got %d body=%s", rec.Code, rec.Body.String())
|
||||
assert.Contains(t, rec.Body.String(), "window_seconds",
|
||||
"rejection body must name the offending window_seconds field, proving it's the validation path: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// TestConsumptionHandler_EmptyAccountReturnsArray ports bash 30 to
|
||||
// Go: GET /agent-network/consumption on a clean account always
|
||||
// returns a JSON array (possibly empty), never a 404 / 500. The
|
||||
// dashboard depends on this shape to render its empty state.
|
||||
func TestConsumptionHandler_EmptyAccountReturnsArray(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
rec := f.do(t, http.MethodGet, "/agent-network/consumption", "")
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var rows []api.AgentNetworkConsumption
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &rows),
|
||||
"response must always be a JSON array — even when empty: %s", rec.Body.String())
|
||||
assert.Empty(t, rows)
|
||||
}
|
||||
|
||||
// TestConsumptionHandler_PopulatedAccountListsRows mirrors the
|
||||
// /consumption read after a few RecordConsumption calls. Validates
|
||||
// the wire shape carries every field the dashboard reads (dim_kind,
|
||||
// dim_id, window_seconds, window_start_utc, tokens, cost_usd) and
|
||||
// rows are ordered window-newest-first.
|
||||
func TestConsumptionHandler_PopulatedAccountListsRows(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
require.NoError(t, f.manager.RecordConsumption(
|
||||
context.Background(), testAccountID,
|
||||
agentNetworkTypes.DimensionGroup, "grp-engineers",
|
||||
86_400, 100, 50, 0.0125,
|
||||
))
|
||||
require.NoError(t, f.manager.RecordConsumption(
|
||||
context.Background(), testAccountID,
|
||||
agentNetworkTypes.DimensionUser, testUserID,
|
||||
86_400, 100, 50, 0.0125,
|
||||
))
|
||||
|
||||
rec := f.do(t, http.MethodGet, "/agent-network/consumption", "")
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var rows []api.AgentNetworkConsumption
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &rows))
|
||||
require.Len(t, rows, 2, "two RecordConsumption calls must yield two rows")
|
||||
|
||||
// Index by dim_kind so we can assert the full wire shape of each row,
|
||||
// including the dimension id and the aligned window start the dashboard
|
||||
// keys on. Both rows share totals and window.
|
||||
byKind := make(map[string]api.AgentNetworkConsumption, len(rows))
|
||||
for _, row := range rows {
|
||||
assert.Equal(t, int64(100), row.TokensInput)
|
||||
assert.Equal(t, int64(50), row.TokensOutput)
|
||||
assert.InDelta(t, 0.0125, row.CostUsd, 1e-9)
|
||||
assert.Equal(t, int64(86_400), row.WindowSeconds)
|
||||
assert.False(t, row.WindowStartUtc.IsZero(), "window_start_utc must be set on every row")
|
||||
byKind[string(row.DimensionKind)] = row
|
||||
}
|
||||
|
||||
groupRow, ok := byKind["group"]
|
||||
require.True(t, ok, "group dimension must surface")
|
||||
assert.Equal(t, "grp-engineers", groupRow.DimensionId, "group row must carry the source group id as dimension_id")
|
||||
|
||||
userRow, ok := byKind["user"]
|
||||
require.True(t, ok, "user dimension must surface")
|
||||
assert.Equal(t, testUserID, userRow.DimensionId, "user row must carry the user id as dimension_id")
|
||||
|
||||
// Both rows fall in the same aligned window (same length, recorded
|
||||
// together), so window_start_utc must match across them.
|
||||
assert.Equal(t, groupRow.WindowStartUtc, userRow.WindowStartUtc,
|
||||
"rows recorded in the same window must share the aligned window_start_utc")
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/modeldiscovery"
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
"github.com/netbirdio/netbird/shared/auth"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
// discoveryManagerStub records what the handler asked for and returns a canned
|
||||
// answer. The Manager interface is embedded rather than implemented: only the
|
||||
// one method is reachable from this handler, and a call to any other should
|
||||
// fail loudly rather than silently return a zero value.
|
||||
type discoveryManagerStub struct {
|
||||
agentnetwork.Manager
|
||||
|
||||
gotReq modeldiscovery.Request
|
||||
gotRecordID string
|
||||
models []modeldiscovery.Model
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *discoveryManagerStub) DiscoverProviderModels(
|
||||
_ context.Context, _, _ string, req modeldiscovery.Request, recordID string,
|
||||
) ([]modeldiscovery.Model, error) {
|
||||
s.gotReq = req
|
||||
s.gotRecordID = recordID
|
||||
return s.models, s.err
|
||||
}
|
||||
|
||||
// postDiscovery drives the handler with an authenticated request.
|
||||
func postDiscovery(t *testing.T, stub *discoveryManagerStub, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
h := &handler{manager: stub}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/agent-network/catalog/providers/models", strings.NewReader(body))
|
||||
req = req.WithContext(nbcontext.SetUserAuthInContext(req.Context(), auth.UserAuth{
|
||||
AccountId: "acc-1",
|
||||
UserId: "user-1",
|
||||
}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.discoverProviderModels(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestDiscoverModelsReturnsTheVendorList(t *testing.T) {
|
||||
stub := &discoveryManagerStub{models: []modeldiscovery.Model{
|
||||
{ID: "eu.anthropic.claude-haiku-4-5-20251001-v1:0", Label: "EU Claude Haiku 4.5", PricingKnown: true},
|
||||
{ID: "global.cohere.embed-v4:0", Label: "Global Cohere Embed v4"},
|
||||
// A vendor that supplies no display name at all. Bedrock does for
|
||||
// every profile, but the OpenAI listing carries none.
|
||||
{ID: "gpt-4o-mini", PricingKnown: true},
|
||||
}}
|
||||
|
||||
rec := postDiscovery(t, stub, `{
|
||||
"catalog_provider_id":"bedrock_api",
|
||||
"upstream_url":"https://bedrock-runtime.eu-central-1.amazonaws.com",
|
||||
"api_key":"aws-bearer"
|
||||
}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
|
||||
var out api.AgentNetworkModelDiscoveryResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out))
|
||||
require.Len(t, out.Models, 3)
|
||||
|
||||
assert.Equal(t, "eu.anthropic.claude-haiku-4-5-20251001-v1:0", out.Models[0].Id)
|
||||
assert.True(t, out.Models[0].PricingKnown)
|
||||
// An unpriced model must say so rather than arriving indistinguishable
|
||||
// from a priced one: registering it silently would meter at zero.
|
||||
assert.False(t, out.Models[1].PricingKnown)
|
||||
|
||||
require.NotNil(t, out.Models[0].Label, "the vendor supplied a display name")
|
||||
assert.Equal(t, "EU Claude Haiku 4.5", *out.Models[0].Label)
|
||||
// A vendor that supplies no name must omit the key rather than send an
|
||||
// empty string: the dashboard falls back to the id on absence, and would
|
||||
// render a blank row for "".
|
||||
assert.Nil(t, out.Models[2].Label, "an absent label must not serialize")
|
||||
assert.NotContains(t, rec.Body.String(), `"label":""`)
|
||||
|
||||
assert.Equal(t, "bedrock_api", stub.gotReq.CatalogID)
|
||||
assert.Equal(t, "aws-bearer", stub.gotReq.APIKey)
|
||||
// The upstream is what the region is read back out of for Bedrock, so
|
||||
// losing it here would break discovery for every regional provider.
|
||||
assert.Equal(t, "https://bedrock-runtime.eu-central-1.amazonaws.com", stub.gotReq.UpstreamURL)
|
||||
assert.Empty(t, stub.gotRecordID)
|
||||
}
|
||||
|
||||
func TestDiscoverModelsUsesAStoredRecordWithoutAKey(t *testing.T) {
|
||||
stub := &discoveryManagerStub{}
|
||||
|
||||
rec := postDiscovery(t, stub, `{"catalog_provider_id":"openai_api","provider_id":"prov-42"}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
|
||||
// The dashboard refreshes a saved provider's list without ever holding
|
||||
// the credential, so the record id has to reach the manager.
|
||||
assert.Equal(t, "prov-42", stub.gotRecordID)
|
||||
assert.Empty(t, stub.gotReq.APIKey)
|
||||
}
|
||||
|
||||
// TestDiscoverModelsRefusesMixedCredentials covers the case where a caller
|
||||
// names a saved provider AND supplies a key. Accepting it would run an
|
||||
// arbitrary credential under the identity of a record the caller may only be
|
||||
// permitted to read.
|
||||
func TestDiscoverModelsRefusesMixedCredentials(t *testing.T) {
|
||||
stub := &discoveryManagerStub{}
|
||||
|
||||
rec := postDiscovery(t, stub, `{
|
||||
"catalog_provider_id":"openai_api",
|
||||
"provider_id":"prov-42",
|
||||
"api_key":"sk-attacker"
|
||||
}`)
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
assert.Empty(t, stub.gotRecordID, "the request must be refused before it reaches the manager")
|
||||
}
|
||||
|
||||
// TestDiscoverModelsReportsNoDiscoveryDistinctly matters because the caller
|
||||
// falls back to the catalog's own model list on this outcome. Collapsing it
|
||||
// into a generic 500 would turn "this provider has no listing endpoint" into
|
||||
// "something went wrong", and the form would show an error instead of a list.
|
||||
func TestDiscoverModelsReportsNoDiscoveryDistinctly(t *testing.T) {
|
||||
stub := &discoveryManagerStub{err: modeldiscovery.ErrNoDiscovery}
|
||||
|
||||
rec := postDiscovery(t, stub, `{"catalog_provider_id":"litellm_proxy","upstream_url":"https://gw.example.com","api_key":"sk"}`)
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code)
|
||||
}
|
||||
|
||||
// TestDiscoverModelsTrimsTheCatalogID pins that the id the emptiness check
|
||||
// accepts is the id the manager receives. A padded value that clears the check
|
||||
// but reaches the catalog untrimmed misses the lookup, and the operator is told
|
||||
// their provider does not exist.
|
||||
func TestDiscoverModelsTrimsTheCatalogID(t *testing.T) {
|
||||
stub := &discoveryManagerStub{}
|
||||
|
||||
rec := postDiscovery(t, stub, `{"catalog_provider_id":" openai_api ","api_key":"sk"}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
|
||||
assert.Equal(t, "openai_api", stub.gotReq.CatalogID)
|
||||
}
|
||||
|
||||
// TestDiscoverModelsReportsCallerInputAsBadRequest covers the other half of the
|
||||
// error mapping. These failures are all reachable from a well-formed request
|
||||
// with a bad field value, so answering 500 both misinforms the operator and
|
||||
// puts their typo into the server's error rate.
|
||||
func TestDiscoverModelsReportsCallerInputAsBadRequest(t *testing.T) {
|
||||
stub := &discoveryManagerStub{
|
||||
err: fmt.Errorf("%w: unknown catalog provider %q", modeldiscovery.ErrInvalidRequest, "nope"),
|
||||
}
|
||||
|
||||
rec := postDiscovery(t, stub, `{"catalog_provider_id":"nope","api_key":"sk"}`)
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
assert.Contains(t, rec.Body.String(), "unknown catalog provider")
|
||||
}
|
||||
|
||||
func TestDiscoverModelsRejectsMalformedRequests(t *testing.T) {
|
||||
for name, body := range map[string]string{
|
||||
"not json": `{`,
|
||||
"no catalog provider": `{"api_key":"sk"}`,
|
||||
"blank catalog provider": `{"catalog_provider_id":" ","api_key":"sk"}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
stub := &discoveryManagerStub{}
|
||||
rec := postDiscovery(t, stub, body)
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
"github.com/netbirdio/netbird/shared/management/http/util"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
// minWindowSeconds is the floor enforced on enabled token / budget
|
||||
// limit windows. One minute is short enough for fine-grained burst
|
||||
// control without producing untenable consumption-row volume at scale.
|
||||
const minWindowSeconds int64 = 60
|
||||
|
||||
// addPolicyEndpoints registers all Agent Network policy routes on the
|
||||
// shared handler.
|
||||
func (h *handler) addPolicyEndpoints(router *mux.Router) {
|
||||
router.HandleFunc("/agent-network/policies", h.getAllPolicies).Methods("GET", "OPTIONS")
|
||||
router.HandleFunc("/agent-network/policies", h.createPolicy).Methods("POST", "OPTIONS")
|
||||
router.HandleFunc("/agent-network/policies/{policyId}", h.getPolicy).Methods("GET", "OPTIONS")
|
||||
router.HandleFunc("/agent-network/policies/{policyId}", h.updatePolicy).Methods("PUT", "OPTIONS")
|
||||
router.HandleFunc("/agent-network/policies/{policyId}", h.deletePolicy).Methods("DELETE", "OPTIONS")
|
||||
}
|
||||
|
||||
func (h *handler) getAllPolicies(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
policies, err := h.manager.GetAllPolicies(r.Context(), userAuth.AccountId, userAuth.UserId)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]*api.AgentNetworkPolicy, 0, len(policies))
|
||||
for _, p := range policies {
|
||||
out = append(out, p.ToAPIResponse())
|
||||
}
|
||||
util.WriteJSONObject(r.Context(), w, out)
|
||||
}
|
||||
|
||||
func (h *handler) getPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
policyID := mux.Vars(r)["policyId"]
|
||||
if policyID == "" {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "policy ID is required"), w)
|
||||
return
|
||||
}
|
||||
|
||||
policy, err := h.manager.GetPolicy(r.Context(), userAuth.AccountId, userAuth.UserId, policyID)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
util.WriteJSONObject(r.Context(), w, policy.ToAPIResponse())
|
||||
}
|
||||
|
||||
func (h *handler) createPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
var req api.AgentNetworkPolicyRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validatePolicy(&req); err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
policy := types.NewPolicy(userAuth.AccountId)
|
||||
policy.FromAPIRequest(&req)
|
||||
|
||||
created, err := h.manager.CreatePolicy(r.Context(), userAuth.UserId, policy)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, created.ToAPIResponse())
|
||||
}
|
||||
|
||||
func (h *handler) updatePolicy(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
policyID := mux.Vars(r)["policyId"]
|
||||
if policyID == "" {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "policy ID is required"), w)
|
||||
return
|
||||
}
|
||||
|
||||
var req api.AgentNetworkPolicyRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validatePolicy(&req); err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
policy := &types.Policy{
|
||||
ID: policyID,
|
||||
AccountID: userAuth.AccountId,
|
||||
}
|
||||
policy.FromAPIRequest(&req)
|
||||
|
||||
updated, err := h.manager.UpdatePolicy(r.Context(), userAuth.UserId, policy)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse())
|
||||
}
|
||||
|
||||
func (h *handler) deletePolicy(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
policyID := mux.Vars(r)["policyId"]
|
||||
if policyID == "" {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "policy ID is required"), w)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.manager.DeletePolicy(r.Context(), userAuth.AccountId, userAuth.UserId, policyID); err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
|
||||
}
|
||||
|
||||
func validatePolicy(req *api.AgentNetworkPolicyRequest) error {
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
return status.Errorf(status.InvalidArgument, "name is required")
|
||||
}
|
||||
if len(req.SourceGroups) == 0 {
|
||||
return status.Errorf(status.InvalidArgument, "source_groups must contain at least one group id")
|
||||
}
|
||||
for _, id := range req.SourceGroups {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return status.Errorf(status.InvalidArgument, "source_groups must not contain empty entries")
|
||||
}
|
||||
}
|
||||
if len(req.DestinationProviderIds) == 0 {
|
||||
return status.Errorf(status.InvalidArgument, "destination_provider_ids must contain at least one provider id")
|
||||
}
|
||||
for _, id := range req.DestinationProviderIds {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return status.Errorf(status.InvalidArgument, "destination_provider_ids must not contain empty entries")
|
||||
}
|
||||
}
|
||||
if req.GuardrailIds != nil {
|
||||
for _, id := range *req.GuardrailIds {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return status.Errorf(status.InvalidArgument, "guardrail_ids must not contain empty entries")
|
||||
}
|
||||
}
|
||||
}
|
||||
if req.Limits != nil {
|
||||
if err := validatePolicyLimits(*req.Limits); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePolicyLimits(l api.AgentNetworkPolicyLimits) error {
|
||||
if l.TokenLimit.Enabled {
|
||||
if l.TokenLimit.WindowSeconds < minWindowSeconds {
|
||||
return status.Errorf(status.InvalidArgument, "limits.token_limit.window_seconds must be at least %d (one minute) when enabled", minWindowSeconds)
|
||||
}
|
||||
if l.TokenLimit.GroupCap < 0 {
|
||||
return status.Errorf(status.InvalidArgument, "limits.token_limit.group_cap must not be negative")
|
||||
}
|
||||
if l.TokenLimit.UserCap < 0 {
|
||||
return status.Errorf(status.InvalidArgument, "limits.token_limit.user_cap must not be negative")
|
||||
}
|
||||
if l.TokenLimit.GroupCap == 0 && l.TokenLimit.UserCap == 0 {
|
||||
return status.Errorf(status.InvalidArgument, "limits.token_limit requires group_cap or user_cap to be greater than zero when enabled")
|
||||
}
|
||||
}
|
||||
if l.BudgetLimit.Enabled {
|
||||
if l.BudgetLimit.WindowSeconds < minWindowSeconds {
|
||||
return status.Errorf(status.InvalidArgument, "limits.budget_limit.window_seconds must be at least %d (one minute) when enabled", minWindowSeconds)
|
||||
}
|
||||
if l.BudgetLimit.GroupCapUsd < 0 {
|
||||
return status.Errorf(status.InvalidArgument, "limits.budget_limit.group_cap_usd must not be negative")
|
||||
}
|
||||
if l.BudgetLimit.UserCapUsd < 0 {
|
||||
return status.Errorf(status.InvalidArgument, "limits.budget_limit.user_cap_usd must not be negative")
|
||||
}
|
||||
if l.BudgetLimit.GroupCapUsd == 0 && l.BudgetLimit.UserCapUsd == 0 {
|
||||
return status.Errorf(status.InvalidArgument, "limits.budget_limit requires group_cap_usd or user_cap_usd to be greater than zero when enabled")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
// Package handlers serves the Agent Network HTTP API.
|
||||
//
|
||||
// All persistence is delegated to agentnetwork.Manager so this layer only
|
||||
// translates between the wire format (api.AgentNetworkProvider*) and the
|
||||
// domain types.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/modeldiscovery"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
"github.com/netbirdio/netbird/shared/management/http/util"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
manager agentnetwork.Manager
|
||||
}
|
||||
|
||||
// RegisterEndpoints registers all Agent Network routes.
|
||||
func RegisterEndpoints(manager agentnetwork.Manager, router *mux.Router) {
|
||||
h := &handler{manager: manager}
|
||||
router.HandleFunc("/agent-network/catalog/providers", h.getCatalogProviders).Methods("GET", "OPTIONS")
|
||||
router.HandleFunc("/agent-network/catalog/providers/models", h.discoverProviderModels).Methods("POST", "OPTIONS")
|
||||
router.HandleFunc("/agent-network/providers", h.getAllProviders).Methods("GET", "OPTIONS")
|
||||
router.HandleFunc("/agent-network/providers", h.createProvider).Methods("POST", "OPTIONS")
|
||||
router.HandleFunc("/agent-network/providers/{providerId}", h.getProvider).Methods("GET", "OPTIONS")
|
||||
router.HandleFunc("/agent-network/providers/{providerId}", h.updateProvider).Methods("PUT", "OPTIONS")
|
||||
router.HandleFunc("/agent-network/providers/{providerId}", h.deleteProvider).Methods("DELETE", "OPTIONS")
|
||||
h.addPolicyEndpoints(router)
|
||||
h.addGuardrailEndpoints(router)
|
||||
h.addSettingsEndpoints(router)
|
||||
h.addConsumptionEndpoints(router)
|
||||
h.addAccessLogEndpoints(router)
|
||||
h.addBudgetRuleEndpoints(router)
|
||||
h.addAgentConfigEndpoints(router)
|
||||
}
|
||||
|
||||
func (h *handler) getCatalogProviders(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := nbcontext.GetUserAuthFromContext(r.Context()); err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
entries := catalog.All()
|
||||
out := make([]api.AgentNetworkCatalogProvider, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
resp := e.ToAPIResponse()
|
||||
applyDefaultPricing(e, &resp)
|
||||
out = append(out, resp)
|
||||
}
|
||||
util.WriteJSONObject(r.Context(), w, out)
|
||||
}
|
||||
|
||||
// discoverProviderModels asks the vendor which models the operator's own
|
||||
// credential can reach, so the provider form can offer a live list rather than
|
||||
// only the static catalog.
|
||||
func (h *handler) discoverProviderModels(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
var body api.AgentNetworkModelDiscoveryRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
util.WriteErrorResponse("invalid json", http.StatusBadRequest, w)
|
||||
return
|
||||
}
|
||||
// Trimmed once and carried, not trimmed for the emptiness test and then
|
||||
// discarded: a padded " openai_api " would clear the check here and miss
|
||||
// the catalog lookup, reporting the provider as unknown.
|
||||
catalogID := strings.TrimSpace(body.CatalogProviderId)
|
||||
if catalogID == "" {
|
||||
util.WriteErrorResponse("catalog_provider_id is required", http.StatusBadRequest, w)
|
||||
return
|
||||
}
|
||||
|
||||
recordID := strValue(body.ProviderId)
|
||||
req := modeldiscovery.Request{
|
||||
CatalogID: catalogID,
|
||||
UpstreamURL: strValue(body.UpstreamUrl),
|
||||
APIKey: strValue(body.ApiKey),
|
||||
}
|
||||
// One source of credential or the other, never a mix: taking a key from
|
||||
// the request while addressing a saved record would let a caller run an
|
||||
// arbitrary credential against a provider they can only read.
|
||||
if recordID != "" && req.APIKey != "" {
|
||||
util.WriteErrorResponse("provide either provider_id or api_key, not both", http.StatusBadRequest, w)
|
||||
return
|
||||
}
|
||||
|
||||
models, err := h.manager.DiscoverProviderModels(r.Context(), userAuth.AccountId, userAuth.UserId, req, recordID)
|
||||
if err != nil {
|
||||
// A provider with no listing endpoint is a fact about the catalog
|
||||
// entry, not a failure: the caller falls back to the catalog's own
|
||||
// models, so it must be able to tell the two apart.
|
||||
if errors.Is(err, modeldiscovery.ErrNoDiscovery) {
|
||||
util.WriteErrorResponse(err.Error(), http.StatusUnprocessableEntity, w)
|
||||
return
|
||||
}
|
||||
// An unknown provider, an unusable upstream, a missing region or a
|
||||
// missing key are all things the caller sent, reachable from a
|
||||
// well-formed request. Reporting them as 500 tells the operator the
|
||||
// server broke and buries genuine faults in the error rate.
|
||||
if errors.Is(err, modeldiscovery.ErrInvalidRequest) {
|
||||
util.WriteErrorResponse(err.Error(), http.StatusBadRequest, w)
|
||||
return
|
||||
}
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
out := api.AgentNetworkModelDiscoveryResponse{Models: make([]api.AgentNetworkDiscoveredModel, 0, len(models))}
|
||||
for _, m := range models {
|
||||
entry := api.AgentNetworkDiscoveredModel{
|
||||
Id: m.ID,
|
||||
PricingKnown: m.PricingKnown,
|
||||
// Sent even when zero: the form prefills every discovered model as
|
||||
// an editable row, and an unpriced one is shown at zero and flagged
|
||||
// rather than left out.
|
||||
InputPer1k: m.InputPer1k,
|
||||
OutputPer1k: m.OutputPer1k,
|
||||
// Cache rates stay absent when unset, matching the catalog
|
||||
// response — a zero would read as "free", not "not applicable".
|
||||
CachedInputPer1k: positiveRatePtr(m.CachedInputPer1k),
|
||||
CacheReadPer1k: positiveRatePtr(m.CacheReadPer1k),
|
||||
CacheCreationPer1k: positiveRatePtr(m.CacheCreationPer1k),
|
||||
}
|
||||
if m.Label != "" {
|
||||
label := m.Label
|
||||
entry.Label = &label
|
||||
}
|
||||
out.Models = append(out.Models, entry)
|
||||
}
|
||||
util.WriteJSONObject(r.Context(), w, out)
|
||||
}
|
||||
|
||||
// strValue reads an optional string field, treating absent as empty.
|
||||
func strValue(v *string) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(*v)
|
||||
}
|
||||
|
||||
// applyDefaultPricing overwrites the catalog response's model rates with
|
||||
// the LIVE default pricing table, which may differ from the compiled-in
|
||||
// catalog rates when the operator provides a defaults_llm_pricing.yaml.
|
||||
// This keeps the dashboard's model-row prefill identical to what the
|
||||
// proxy will actually bill — the same table the synthesizer ships.
|
||||
func applyDefaultPricing(cp catalog.Provider, resp *api.AgentNetworkCatalogProvider) {
|
||||
if len(cp.PricingSurfaces) == 0 {
|
||||
return
|
||||
}
|
||||
for i := range resp.Models {
|
||||
m := &resp.Models[i]
|
||||
e, ok := pricing.LookupDefault(cp.PricingSurfaces, m.Id)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
m.InputPer1k = e.InputPer1k
|
||||
m.OutputPer1k = e.OutputPer1k
|
||||
m.CachedInputPer1k = positiveRatePtr(e.CachedInputPer1k)
|
||||
m.CacheReadPer1k = positiveRatePtr(e.CacheReadPer1k)
|
||||
m.CacheCreationPer1k = positiveRatePtr(e.CacheCreationPer1k)
|
||||
}
|
||||
}
|
||||
|
||||
// positiveRatePtr renders a cache rate for the API: absent (nil) when
|
||||
// unset, matching the catalog response convention.
|
||||
func positiveRatePtr(v float64) *float64 {
|
||||
if v <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
func (h *handler) getAllProviders(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
providers, err := h.manager.GetAllProviders(r.Context(), userAuth.AccountId, userAuth.UserId)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]*api.AgentNetworkProvider, 0, len(providers))
|
||||
for _, p := range providers {
|
||||
out = append(out, p.ToAPIResponse())
|
||||
}
|
||||
util.WriteJSONObject(r.Context(), w, out)
|
||||
}
|
||||
|
||||
func (h *handler) getProvider(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
providerID := mux.Vars(r)["providerId"]
|
||||
if providerID == "" {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "provider ID is required"), w)
|
||||
return
|
||||
}
|
||||
|
||||
provider, err := h.manager.GetProvider(r.Context(), userAuth.AccountId, userAuth.UserId, providerID)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
util.WriteJSONObject(r.Context(), w, provider.ToAPIResponse())
|
||||
}
|
||||
|
||||
func (h *handler) createProvider(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
var req api.AgentNetworkProviderRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validate(&req, true); err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
provider := types.NewProvider(userAuth.AccountId)
|
||||
provider.FromAPIRequest(&req)
|
||||
|
||||
created, err := h.manager.CreateProvider(r.Context(), userAuth.UserId, provider)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, created.ToAPIResponse())
|
||||
}
|
||||
|
||||
func (h *handler) updateProvider(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
providerID := mux.Vars(r)["providerId"]
|
||||
if providerID == "" {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "provider ID is required"), w)
|
||||
return
|
||||
}
|
||||
|
||||
var req api.AgentNetworkProviderRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validate(&req, false); err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
provider := &types.Provider{
|
||||
ID: providerID,
|
||||
AccountID: userAuth.AccountId,
|
||||
}
|
||||
provider.FromAPIRequest(&req)
|
||||
|
||||
updated, err := h.manager.UpdateProvider(r.Context(), userAuth.UserId, provider)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse())
|
||||
}
|
||||
|
||||
func (h *handler) deleteProvider(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
providerID := mux.Vars(r)["providerId"]
|
||||
if providerID == "" {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "provider ID is required"), w)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.manager.DeleteProvider(r.Context(), userAuth.AccountId, userAuth.UserId, providerID); err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
|
||||
}
|
||||
|
||||
func validate(req *api.AgentNetworkProviderRequest, requireAPIKey bool) error {
|
||||
if strings.TrimSpace(req.ProviderId) == "" {
|
||||
return status.Errorf(status.InvalidArgument, "provider_id is required")
|
||||
}
|
||||
if !catalog.IsKnown(req.ProviderId) {
|
||||
return status.Errorf(status.InvalidArgument, "provider_id %q is not a known catalog provider", req.ProviderId)
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
return status.Errorf(status.InvalidArgument, "name is required")
|
||||
}
|
||||
if strings.TrimSpace(req.UpstreamUrl) == "" {
|
||||
return status.Errorf(status.InvalidArgument, "upstream_url is required")
|
||||
}
|
||||
u, err := url.Parse(strings.TrimSpace(req.UpstreamUrl))
|
||||
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
return status.Errorf(status.InvalidArgument, "upstream_url must be a full http(s) URL")
|
||||
}
|
||||
if requireAPIKey && (req.ApiKey == nil || strings.TrimSpace(*req.ApiKey) == "") {
|
||||
return status.Errorf(status.InvalidArgument, "api_key is required")
|
||||
}
|
||||
// An update omits api_key to keep the stored credential. A key that is
|
||||
// present but blank is not that: Provider.FromAPIRequest drops it exactly
|
||||
// as if it were absent, so a rotation the operator believes they performed
|
||||
// would answer 200 having changed nothing. Refuse it here, where the
|
||||
// request still carries the difference between absent and blank.
|
||||
if req.ApiKey != nil && strings.TrimSpace(*req.ApiKey) == "" {
|
||||
return status.Errorf(status.InvalidArgument, "api_key must be omitted to keep the stored credential rather than sent blank")
|
||||
}
|
||||
if req.Models != nil {
|
||||
for i, m := range *req.Models {
|
||||
if err := validateModel(i, m); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateModel is the single ingress guard for operator-entered pricing:
|
||||
// these rates are synthesized into the proxy's cost_meter config verbatim,
|
||||
// and a negative or non-finite rate there would poison every cost the
|
||||
// proxy records, so reject at the API boundary.
|
||||
func validateModel(i int, m api.AgentNetworkProviderModel) error {
|
||||
if strings.TrimSpace(m.Id) == "" {
|
||||
return status.Errorf(status.InvalidArgument, "models[%d]: id is required", i)
|
||||
}
|
||||
rates := map[string]*float64{
|
||||
"input_per_1k": &m.InputPer1k,
|
||||
"output_per_1k": &m.OutputPer1k,
|
||||
"cached_input_per_1k": m.CachedInputPer1k,
|
||||
"cache_read_per_1k": m.CacheReadPer1k,
|
||||
"cache_creation_per_1k": m.CacheCreationPer1k,
|
||||
}
|
||||
for field, v := range rates {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
if *v < 0 || math.IsNaN(*v) || math.IsInf(*v, 0) {
|
||||
return status.Errorf(status.InvalidArgument, "models[%d] (%s): %s must be a finite, non-negative USD rate", i, m.Id, field)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
nethttp "net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
func f(v float64) *float64 { return &v }
|
||||
|
||||
// TestValidate_ModelRates guards the single ingress point for operator-entered
|
||||
// pricing. These rates flow verbatim into the proxy's cost_meter config at
|
||||
// synthesis time; the proxy treats a bad rate as a chain-build failure, so
|
||||
// rejecting here is what keeps an account's gateway from going down.
|
||||
func TestValidate_ModelRates(t *testing.T) {
|
||||
base := func(models ...api.AgentNetworkProviderModel) *api.AgentNetworkProviderRequest {
|
||||
key := "sk-test"
|
||||
return &api.AgentNetworkProviderRequest{
|
||||
ProviderId: "openai_api",
|
||||
Name: "OpenAI",
|
||||
UpstreamUrl: "https://api.openai.com",
|
||||
ApiKey: &key,
|
||||
Models: &models,
|
||||
}
|
||||
}
|
||||
|
||||
valid := api.AgentNetworkProviderModel{
|
||||
Id: "gpt-4o", InputPer1k: 0.0025, OutputPer1k: 0.01,
|
||||
CachedInputPer1k: f(0.00125),
|
||||
}
|
||||
require.NoError(t, validate(base(valid), true), "finite non-negative rates must pass")
|
||||
|
||||
zeroRates := api.AgentNetworkProviderModel{Id: "self-hosted-llama", InputPer1k: 0, OutputPer1k: 0}
|
||||
require.NoError(t, validate(base(zeroRates), true), "explicit zero prices are allowed (free / self-hosted models)")
|
||||
|
||||
cases := map[string]api.AgentNetworkProviderModel{
|
||||
"empty id": {Id: " ", InputPer1k: 0.001, OutputPer1k: 0.002},
|
||||
"negative input": {Id: "m", InputPer1k: -0.001, OutputPer1k: 0.002},
|
||||
"negative output": {Id: "m", InputPer1k: 0.001, OutputPer1k: -0.002},
|
||||
"NaN input": {Id: "m", InputPer1k: math.NaN(), OutputPer1k: 0.002},
|
||||
"Inf output": {Id: "m", InputPer1k: 0.001, OutputPer1k: math.Inf(1)},
|
||||
"negative cached": {Id: "m", InputPer1k: 0.001, OutputPer1k: 0.002, CachedInputPer1k: f(-1)},
|
||||
"NaN cache read": {Id: "m", InputPer1k: 0.001, OutputPer1k: 0.002, CacheReadPer1k: f(math.NaN())},
|
||||
"Inf cache creation": {Id: "m", InputPer1k: 0.001, OutputPer1k: 0.002, CacheCreationPer1k: f(math.Inf(-1))},
|
||||
}
|
||||
for name, m := range cases {
|
||||
assert.Error(t, validate(base(m), true), "case %q must be rejected", name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidate_ABlankApiKeyIsNotTheSameAsAnOmittedOne covers the one shape the
|
||||
// manager's own guard cannot see. Provider.FromAPIRequest assigns the key only
|
||||
// when it trims to something, so a request carrying " " arrives at
|
||||
// UpdateProvider indistinguishable from one that omitted it — the stored
|
||||
// credential is kept and the write answers 200, telling an operator who thinks
|
||||
// they just rotated a key that it worked.
|
||||
//
|
||||
// The request still knows the difference, so the refusal belongs here.
|
||||
func TestValidate_ABlankApiKeyIsNotTheSameAsAnOmittedOne(t *testing.T) {
|
||||
req := func(key *string) *api.AgentNetworkProviderRequest {
|
||||
return &api.AgentNetworkProviderRequest{
|
||||
ProviderId: "openai_api",
|
||||
Name: "OpenAI",
|
||||
UpstreamUrl: "https://api.openai.com",
|
||||
ApiKey: key,
|
||||
}
|
||||
}
|
||||
|
||||
blank := " "
|
||||
err := validate(req(&blank), false)
|
||||
require.Error(t, err, "a blank api_key on update must not be read as 'keep what is stored'")
|
||||
assert.Contains(t, err.Error(), "api_key")
|
||||
|
||||
require.NoError(t, validate(req(nil), false), "an omitted api_key is how an update keeps the stored credential")
|
||||
|
||||
// Create already refuses this, and keeps its own message: a caller who sent
|
||||
// no usable key is told the field is required rather than being told how to
|
||||
// preserve a credential that does not exist yet.
|
||||
err = validate(req(&blank), true)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "api_key is required")
|
||||
}
|
||||
|
||||
// TestProviderHandler_UpdateReplacesFullState pins the update contract shared
|
||||
// with the other PUT endpoints: the request replaces the provider's mutable
|
||||
// state, so optional fields absent from the JSON land as their zero values.
|
||||
// The two exceptions are server-side: the api_key (a secret — omitted means
|
||||
// "not rotated") and the session keypair, both preserved by the manager. The
|
||||
// identity headers stay on the wire as explicit empty strings so a cleared
|
||||
// value round-trips.
|
||||
func TestProviderHandler_UpdateReplacesFullState(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
// A private upstream: the save-time credential check leaves it unchecked
|
||||
// rather than spending "sk-test" against the real api.openai.com, which
|
||||
// the vendor refuses.
|
||||
create := `{
|
||||
"provider_id": "openai_api",
|
||||
"name": "openai",
|
||||
"upstream_url": "https://10.255.255.1",
|
||||
"api_key": "sk-test",
|
||||
"enabled": true,
|
||||
"metadata_disabled": true,
|
||||
"skip_tls_verification": true,
|
||||
"extra_values": {"x-portkey-config": "pc-prod-3f2a"},
|
||||
"identity_header_user_id": "x-bf-dim-netbird_user_id",
|
||||
"models": [{"id": "gpt-4o", "input_per_1k": 0.0025, "output_per_1k": 0.01}]
|
||||
}`
|
||||
rec := f.do(t, nethttp.MethodPost, "/agent-network/providers", create)
|
||||
require.Equal(t, nethttp.StatusOK, rec.Code, "create must succeed: %s", rec.Body.String())
|
||||
|
||||
var created api.AgentNetworkProvider
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created))
|
||||
|
||||
// Minimal update: only the required fields, no api_key. Everything
|
||||
// optional must land as its zero value.
|
||||
update := `{"provider_id": "openai_api", "name": "openai-renamed", "upstream_url": "https://10.255.255.1", "enabled": true}`
|
||||
rec = f.do(t, nethttp.MethodPut, "/agent-network/providers/"+created.Id, update)
|
||||
require.Equal(t, nethttp.StatusOK, rec.Code, "update without api_key must succeed (key is preserved): %s", rec.Body.String())
|
||||
|
||||
var updated api.AgentNetworkProvider
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &updated))
|
||||
assert.Equal(t, "openai-renamed", updated.Name, "sent field must apply")
|
||||
assert.True(t, updated.Enabled, "sent field must apply")
|
||||
assert.False(t, updated.MetadataDisabled, "omitted metadata_disabled must land as false — PUT replaces the full state")
|
||||
assert.False(t, updated.SkipTlsVerification, "omitted skip_tls_verification must land as false")
|
||||
assert.Nil(t, updated.ExtraValues, "omitted extra_values must be cleared")
|
||||
assert.Equal(t, "", updated.IdentityHeaderUserId, "omitted identity header must be cleared yet stay on the wire")
|
||||
assert.Empty(t, updated.Models, "omitted models must be cleared")
|
||||
assert.Contains(t, rec.Body.String(), `"identity_header_user_id":""`,
|
||||
"cleared identity header must round-trip as an explicit empty string")
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
"github.com/netbirdio/netbird/shared/management/http/util"
|
||||
)
|
||||
|
||||
// addSettingsEndpoints registers the Agent Network settings routes. POST
|
||||
// bootstraps the settings row, assigning the account's immutable endpoint;
|
||||
// GET reads it (defaults with an empty endpoint before bootstrap); PUT
|
||||
// carries every field, replacing the mutable collection toggles and rejecting
|
||||
// any change to the identity fields; DELETE removes the row — guarded so it
|
||||
// stays a bootstrap-repair operation — releasing the endpoint for a fresh
|
||||
// bootstrap.
|
||||
func (h *handler) addSettingsEndpoints(router *mux.Router) {
|
||||
router.HandleFunc("/agent-network/settings", h.getSettings).Methods("GET", "OPTIONS")
|
||||
router.HandleFunc("/agent-network/settings", h.createSettings).Methods("POST", "OPTIONS")
|
||||
router.HandleFunc("/agent-network/settings", h.updateSettings).Methods("PUT", "OPTIONS")
|
||||
router.HandleFunc("/agent-network/settings", h.deleteSettings).Methods("DELETE", "OPTIONS")
|
||||
}
|
||||
|
||||
// createSettings bootstraps the account's settings row. Exactly one of
|
||||
// proxy_address (labeled endpoint; the server allocates the label) and
|
||||
// endpoint (self-addressed, claimed verbatim) must be provided; optional
|
||||
// collection toggles ride along with defaults for omitted fields.
|
||||
func (h *handler) createSettings(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
var req api.AgentNetworkSettingsCreateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
|
||||
return
|
||||
}
|
||||
|
||||
settings := types.DefaultSettings(userAuth.AccountId)
|
||||
settings.FromAPICreateRequest(&req)
|
||||
|
||||
proxyAddress := ""
|
||||
if req.ProxyAddress != nil {
|
||||
proxyAddress = *req.ProxyAddress
|
||||
}
|
||||
endpoint := ""
|
||||
if req.Endpoint != nil {
|
||||
endpoint = *req.Endpoint
|
||||
}
|
||||
|
||||
created, err := h.manager.CreateSettings(r.Context(), userAuth.UserId, settings, proxyAddress, endpoint)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
util.WriteJSONObject(r.Context(), w, created.ToAPIResponse())
|
||||
}
|
||||
|
||||
// updateSettings replaces the mutable settings fields on the account's row.
|
||||
// A request carrying a cluster bootstraps the row when the account doesn't
|
||||
// have one yet.
|
||||
func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
var req api.AgentNetworkSettingsRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
|
||||
return
|
||||
}
|
||||
|
||||
settings := &types.Settings{AccountID: userAuth.AccountId}
|
||||
settings.FromAPIRequest(&req)
|
||||
|
||||
updated, err := h.manager.UpdateSettings(r.Context(), userAuth.UserId, settings)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
util.WriteJSONObject(r.Context(), w, updated.ToAPIResponse())
|
||||
}
|
||||
|
||||
// deleteSettings removes the account's settings row, releasing the endpoint.
|
||||
// The manager refuses (412) while providers exist or a proxy is actively
|
||||
// serving the endpoint; a later POST bootstraps fresh, allocating a new
|
||||
// endpoint.
|
||||
func (h *handler) deleteSettings(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.manager.DeleteSettings(r.Context(), userAuth.AccountId, userAuth.UserId); err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
|
||||
}
|
||||
|
||||
// getSettings returns the account's agent-network settings. Accounts that
|
||||
// haven't been bootstrapped yet read as the defaults with an empty cluster,
|
||||
// subdomain and endpoint; the manager synthesises that view.
|
||||
func (h *handler) getSettings(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := h.manager.GetSettings(r.Context(), userAuth.AccountId, userAuth.UserId)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
util.WriteJSONObject(r.Context(), w, settings.ToAPIResponse())
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
rpproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
// TestSettingsHandler_GetUnbootstrappedReturnsDefaults pins the settings-read
|
||||
// convention shared with the account and DNS settings endpoints: settings
|
||||
// always read as a JSON object. Before bootstrap that object carries the
|
||||
// defaults with an empty endpoint/proxy_address (the "not bootstrapped"
|
||||
// signal) and no timestamps — never a 404 and never the legacy null body.
|
||||
func TestSettingsHandler_GetUnbootstrappedReturnsDefaults(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
rec := f.do(t, http.MethodGet, "/agent-network/settings", "")
|
||||
require.Equal(t, http.StatusOK, rec.Code,
|
||||
"unbootstrapped account must read as 200 with defaults: got %d body=%s", rec.Code, rec.Body.String())
|
||||
require.NotEqual(t, "null", trimSpace(rec.Body.String()),
|
||||
"the legacy 200+null shape must not come back")
|
||||
|
||||
var got api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
assert.Empty(t, got.Endpoint, "endpoint must be empty until bootstrapped")
|
||||
assert.Empty(t, got.ProxyAddress, "proxy address must be empty until bootstrapped")
|
||||
assert.False(t, got.Dedicated, "an unbootstrapped account has no serving shape")
|
||||
assert.True(t, got.EnableLogCollection, "defaults must show log collection on, matching bootstrap")
|
||||
assert.False(t, got.EnablePromptCollection, "defaults must show prompt collection off")
|
||||
assert.False(t, got.RedactPii, "defaults must show redaction off")
|
||||
require.NotNil(t, got.AccessLogRetentionDays)
|
||||
assert.Equal(t, 30, *got.AccessLogRetentionDays, "defaults must show the bootstrap retention")
|
||||
assert.Nil(t, got.CreatedAt, "no timestamps before a row exists")
|
||||
assert.Nil(t, got.UpdatedAt, "no timestamps before a row exists")
|
||||
}
|
||||
|
||||
// TestSettingsHandler_PostBootstrapsLabeled covers the labeled bootstrap
|
||||
// shape: a POST carrying a proxy_address allocates a label beneath it, so the
|
||||
// endpoint hangs one label under the shared cluster's address and the pin is
|
||||
// not dedicated. Toggles riding along apply; omitted ones keep defaults.
|
||||
func TestSettingsHandler_PostBootstrapsLabeled(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
rec := f.do(t, http.MethodPost, "/agent-network/settings",
|
||||
`{"proxy_address": "eu.proxy.netbird.io", "enable_prompt_collection": true, "access_log_retention_days": 14}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
|
||||
|
||||
var got api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
assert.Equal(t, "eu.proxy.netbird.io", got.ProxyAddress, "proxy address must be pinned from the request")
|
||||
require.NotEmpty(t, got.Endpoint, "endpoint must be allocated at bootstrap")
|
||||
assert.True(t, strings.HasSuffix(got.Endpoint, ".eu.proxy.netbird.io"),
|
||||
"labeled endpoint must hang off the proxy address: %s", got.Endpoint)
|
||||
label := strings.TrimSuffix(got.Endpoint, ".eu.proxy.netbird.io")
|
||||
assert.NotContains(t, label, ".", "the allocated label must be a single DNS label: %s", label)
|
||||
assert.False(t, got.Dedicated, "a labeled pin is not dedicated")
|
||||
assert.True(t, got.EnableLogCollection, "omitted toggle must keep its default")
|
||||
assert.True(t, got.EnablePromptCollection, "toggle from the bootstrap request must apply")
|
||||
require.NotNil(t, got.AccessLogRetentionDays)
|
||||
assert.Equal(t, 14, *got.AccessLogRetentionDays, "retention from the bootstrap request must apply")
|
||||
assert.NotNil(t, got.CreatedAt, "a persisted row carries timestamps")
|
||||
|
||||
// The row is now readable via GET.
|
||||
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
|
||||
require.Equal(t, http.StatusOK, rec.Code, "GET after bootstrap must succeed")
|
||||
var read api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &read))
|
||||
assert.Equal(t, got.Endpoint, read.Endpoint, "GET must return the bootstrapped endpoint")
|
||||
}
|
||||
|
||||
// TestSettingsHandler_PostBootstrapsSelfAddressed covers the dedicated shape:
|
||||
// a POST carrying an endpoint claims the hostname verbatim, the proxy address
|
||||
// equals it, and the pin reads as dedicated. The claim is legitimate before
|
||||
// any proxy declares the address (address-first).
|
||||
func TestSettingsHandler_PostBootstrapsSelfAddressed(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
rec := f.do(t, http.MethodPost, "/agent-network/settings",
|
||||
`{"endpoint": "Brave-Otter.Gateway.Example.com"}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
|
||||
|
||||
var got api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
assert.Equal(t, "brave-otter.gateway.example.com", got.Endpoint,
|
||||
"endpoint must be claimed verbatim, lowercased")
|
||||
assert.Equal(t, got.Endpoint, got.ProxyAddress, "self-addressed: the proxy address is the endpoint")
|
||||
assert.True(t, got.Dedicated, "a self-addressed pin is dedicated")
|
||||
assert.True(t, got.EnableLogCollection, "omitted toggles must keep their defaults")
|
||||
}
|
||||
|
||||
// TestSettingsHandler_PostRequiresExactlyOneIdentityField pins the request
|
||||
// contract: proxy_address and endpoint are mutually exclusive and one is
|
||||
// required — both or neither is a validation error, not a guess.
|
||||
func TestSettingsHandler_PostRequiresExactlyOneIdentityField(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
rec := f.do(t, http.MethodPost, "/agent-network/settings", `{}`)
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code,
|
||||
"empty POST must be rejected: got %d body=%s", rec.Code, rec.Body.String())
|
||||
|
||||
rec = f.do(t, http.MethodPost, "/agent-network/settings",
|
||||
`{"proxy_address": "eu.proxy.netbird.io", "endpoint": "brave-otter.gateway.example.com"}`)
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code,
|
||||
"POST with both identity fields must be rejected: got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// TestSettingsHandler_PostRejectsMalformedHostnames pins per-write input
|
||||
// validation: shapes canonicalization cannot repair — trailing dots, embedded
|
||||
// whitespace, empty labels — are rejected with a validation error instead of
|
||||
// landing in an immutable column.
|
||||
func TestSettingsHandler_PostRejectsMalformedHostnames(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
for name, body := range map[string]string{
|
||||
"trailing dot": `{"endpoint": "gateway.example.com."}`,
|
||||
"leading dot": `{"endpoint": ".gateway.example.com"}`,
|
||||
"inner whitespace": `{"endpoint": "gate way.example.com"}`,
|
||||
"empty label": `{"proxy_address": "eu..proxy.netbird.io"}`,
|
||||
} {
|
||||
rec := f.do(t, http.MethodPost, "/agent-network/settings", body)
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code,
|
||||
"%s must be rejected: got %d body=%s", name, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsHandler_PostConflictsOnSecondBootstrap pins that bootstrap is a
|
||||
// one-time create: a second POST returns 409 and leaves the row untouched.
|
||||
func TestSettingsHandler_PostConflictsOnSecondBootstrap(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
rec := f.do(t, http.MethodPost, "/agent-network/settings", `{"proxy_address": "eu.proxy.netbird.io"}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "first bootstrap must succeed: %s", rec.Body.String())
|
||||
var first api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &first))
|
||||
|
||||
rec = f.do(t, http.MethodPost, "/agent-network/settings", `{"proxy_address": "us.proxy.netbird.io"}`)
|
||||
assert.Equal(t, http.StatusConflict, rec.Code,
|
||||
"second bootstrap must 409: got %d body=%s", rec.Code, rec.Body.String())
|
||||
|
||||
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var got api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
assert.Equal(t, first.Endpoint, got.Endpoint, "the original endpoint must survive the rejected bootstrap")
|
||||
assert.Equal(t, first.ProxyAddress, got.ProxyAddress, "the original proxy address must survive")
|
||||
}
|
||||
|
||||
// TestSettingsHandler_PutBeforeBootstrapIs404 pins that a PUT cannot conjure a
|
||||
// settings row out of nothing — bootstrap is the explicit POST — and the
|
||||
// error points the caller there.
|
||||
func TestSettingsHandler_PutBeforeBootstrapIs404(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
rec := f.do(t, http.MethodPut, "/agent-network/settings",
|
||||
`{"enable_log_collection": false, "enable_prompt_collection": false, "redact_pii": false}`)
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code,
|
||||
"PUT on an unbootstrapped account must 404: got %d body=%s", rec.Code, rec.Body.String())
|
||||
assert.Contains(t, rec.Body.String(), "/api/agent-network/settings",
|
||||
"the error must point the caller at the bootstrap POST: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// TestSettingsHandler_PutReplacesMutableFields pins the update contract shared
|
||||
// with the other PUT endpoints: the request carries every field, replacing the
|
||||
// mutable ones. The identity fields ride along as a required echo of the
|
||||
// assigned values — compared, never written — so the endpoint and proxy
|
||||
// address survive every accepted update.
|
||||
func TestSettingsHandler_PutReplacesMutableFields(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
rec := f.do(t, http.MethodPost, "/agent-network/settings",
|
||||
`{"proxy_address": "eu.proxy.netbird.io", "enable_prompt_collection": true, "redact_pii": true, "access_log_retention_days": 14}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
|
||||
|
||||
var before api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &before))
|
||||
|
||||
rec = f.do(t, http.MethodPut, "/agent-network/settings", fmt.Sprintf(
|
||||
`{"endpoint": %q, "proxy_address": %q, "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false, "access_log_retention_days": 7}`,
|
||||
before.Endpoint, before.ProxyAddress))
|
||||
require.Equal(t, http.StatusOK, rec.Code, "update PUT must succeed: %s", rec.Body.String())
|
||||
|
||||
var got api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
assert.True(t, got.EnableLogCollection, "sent toggle must apply")
|
||||
assert.False(t, got.EnablePromptCollection, "sent toggle must apply")
|
||||
assert.False(t, got.RedactPii, "sent toggle must apply")
|
||||
require.NotNil(t, got.AccessLogRetentionDays)
|
||||
assert.Equal(t, 7, *got.AccessLogRetentionDays, "sent retention must apply")
|
||||
assert.Equal(t, before.Endpoint, got.Endpoint, "endpoint must survive updates untouched")
|
||||
assert.Equal(t, before.ProxyAddress, got.ProxyAddress, "proxy address must survive updates untouched")
|
||||
}
|
||||
|
||||
// TestSettingsHandler_PutRejectsChangedIdentity pins the immutability contract:
|
||||
// the PUT carries the identity fields like every other field, but they are an
|
||||
// echo — a request carrying a different endpoint or proxy address is rejected
|
||||
// as a validation error and the row is left untouched. The comparison is
|
||||
// lenient about casing (the stored values are normalized lowercase), so a
|
||||
// client replaying a GET response with different casing is not rejected.
|
||||
func TestSettingsHandler_PutRejectsChangedIdentity(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
rec := f.do(t, http.MethodPost, "/agent-network/settings",
|
||||
`{"proxy_address": "eu.proxy.netbird.io", "enable_prompt_collection": true}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
|
||||
var before api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &before))
|
||||
|
||||
for name, body := range map[string]string{
|
||||
"changed endpoint": fmt.Sprintf(
|
||||
`{"endpoint": "other.gateway.example.com", "proxy_address": %q, "enable_log_collection": false, "enable_prompt_collection": false, "redact_pii": false, "access_log_retention_days": 30}`,
|
||||
before.ProxyAddress),
|
||||
"changed proxy_address": fmt.Sprintf(
|
||||
`{"endpoint": %q, "proxy_address": "us.proxy.netbird.io", "enable_log_collection": false, "enable_prompt_collection": false, "redact_pii": false, "access_log_retention_days": 30}`,
|
||||
before.Endpoint),
|
||||
"omitted identity": `{"enable_log_collection": false, "enable_prompt_collection": false, "redact_pii": false, "access_log_retention_days": 30}`,
|
||||
} {
|
||||
rec = f.do(t, http.MethodPut, "/agent-network/settings", body)
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, rec.Code,
|
||||
"%s must be rejected: got %d body=%s", name, rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// The rejected updates must not have applied anything — toggles included.
|
||||
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var got api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
assert.Equal(t, before.Endpoint, got.Endpoint, "rejected PUT must not change the endpoint")
|
||||
assert.Equal(t, before.ProxyAddress, got.ProxyAddress, "rejected PUT must not change the proxy address")
|
||||
assert.True(t, got.EnablePromptCollection, "rejected PUT must not apply its toggles")
|
||||
|
||||
// An uppercased echo of the assigned values still names the same host and
|
||||
// must be accepted.
|
||||
rec = f.do(t, http.MethodPut, "/agent-network/settings", fmt.Sprintf(
|
||||
`{"endpoint": %q, "proxy_address": %q, "enable_log_collection": true, "enable_prompt_collection": true, "redact_pii": false, "access_log_retention_days": 30}`,
|
||||
strings.ToUpper(before.Endpoint), strings.ToUpper(before.ProxyAddress)))
|
||||
assert.Equal(t, http.StatusOK, rec.Code,
|
||||
"an uppercased identity echo must be accepted: got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// TestSettingsHandler_PutOmittedRetentionLandsAsZero documents a residual the
|
||||
// required-ness of access_log_retention_days does not remove. Marking the field
|
||||
// required changes the generated client type from *int to int, so a generated
|
||||
// client cannot omit it — but nothing validates OpenAPI required-ness at
|
||||
// runtime, so a hand-rolled body without the field still decodes as 0, which
|
||||
// the API documents as "keep indefinitely".
|
||||
//
|
||||
// That is the same latitude the three booleans already have, so it is left
|
||||
// consistent rather than special-cased. This test exists to make the gap
|
||||
// explicit: if request validation is ever added, this expectation is what
|
||||
// changes.
|
||||
func TestSettingsHandler_PutOmittedRetentionLandsAsZero(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
rec := f.do(t, http.MethodPost, "/agent-network/settings",
|
||||
`{"proxy_address": "eu.proxy.netbird.io", "access_log_retention_days": 14}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
|
||||
var before api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &before))
|
||||
|
||||
rec = f.do(t, http.MethodPut, "/agent-network/settings", fmt.Sprintf(
|
||||
`{"endpoint": %q, "proxy_address": %q, "enable_log_collection": true, "enable_prompt_collection": false, "redact_pii": false}`,
|
||||
before.Endpoint, before.ProxyAddress))
|
||||
require.Equal(t, http.StatusOK, rec.Code, "update PUT must succeed: %s", rec.Body.String())
|
||||
|
||||
var got api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
require.NotNil(t, got.AccessLogRetentionDays)
|
||||
assert.Equal(t, 0, *got.AccessLogRetentionDays,
|
||||
"a non-conforming body that omits retention still replaces it with the zero value")
|
||||
}
|
||||
|
||||
// TestSettingsHandler_DeleteBeforeBootstrapIs404 pins that DELETE on an
|
||||
// account with no settings row is a 404, mirroring the PUT.
|
||||
func TestSettingsHandler_DeleteBeforeBootstrapIs404(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
rec := f.do(t, http.MethodDelete, "/agent-network/settings", "")
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code,
|
||||
"DELETE on an unbootstrapped account must 404: got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// TestSettingsHandler_DeleteBlockedByProviders pins the first delete guard:
|
||||
// while any provider exists for the account, the delete is refused with 412
|
||||
// and the row survives. Providers route through the endpoint — the guard
|
||||
// keeps DELETE a bootstrap-repair operation rather than a way to abandon a
|
||||
// configured gateway.
|
||||
func TestSettingsHandler_DeleteBlockedByProviders(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
rec := f.do(t, http.MethodPost, "/agent-network/settings", `{"proxy_address": "eu.proxy.netbird.io"}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
|
||||
var before api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &before))
|
||||
|
||||
f.seedProvider(t, "prov-guard")
|
||||
|
||||
rec = f.do(t, http.MethodDelete, "/agent-network/settings", "")
|
||||
assert.Equal(t, http.StatusPreconditionFailed, rec.Code,
|
||||
"delete with a provider present must be refused: got %d body=%s", rec.Code, rec.Body.String())
|
||||
|
||||
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var got api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
assert.Equal(t, before.Endpoint, got.Endpoint, "the refused delete must leave the row intact")
|
||||
}
|
||||
|
||||
// TestSettingsHandler_DeleteBlockedByActiveProxy pins the second delete
|
||||
// guard: while a proxy is actively serving the endpoint — an active proxy
|
||||
// row declaring the endpoint hostname as its cluster address, the dedicated
|
||||
// shape — the delete is refused with 412. A proxy that has disconnected no
|
||||
// longer blocks: the guard is about a live serving path, not history.
|
||||
//
|
||||
// The proxy declares its address with mixed casing on purpose: Connect
|
||||
// stores the declared address verbatim while the settings row is normalized
|
||||
// lowercase, and hostnames are case-insensitive, so the guard must match
|
||||
// across the casing difference rather than be sidestepped by it.
|
||||
func TestSettingsHandler_DeleteBlockedByActiveProxy(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
const endpoint = "gw.dedicated.example.com"
|
||||
rec := f.do(t, http.MethodPost, "/agent-network/settings", fmt.Sprintf(`{"endpoint": %q}`, endpoint))
|
||||
require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
|
||||
|
||||
now := time.Now()
|
||||
accountID := testAccountID
|
||||
proxyRow := &rpproxy.Proxy{
|
||||
ID: "proxy-guard",
|
||||
SessionID: "sess-1",
|
||||
ClusterAddress: "GW.Dedicated.Example.Com",
|
||||
AccountID: &accountID,
|
||||
LastSeen: now,
|
||||
ConnectedAt: &now,
|
||||
Status: rpproxy.StatusConnected,
|
||||
}
|
||||
require.NoError(t, f.store.SaveProxy(context.Background(), proxyRow))
|
||||
|
||||
rec = f.do(t, http.MethodDelete, "/agent-network/settings", "")
|
||||
assert.Equal(t, http.StatusPreconditionFailed, rec.Code,
|
||||
"delete with an active proxy at the endpoint must be refused: got %d body=%s", rec.Code, rec.Body.String())
|
||||
|
||||
// Once the proxy disconnects it no longer serves the endpoint, so the
|
||||
// delete goes through.
|
||||
require.NoError(t, f.store.DisconnectProxy(context.Background(), proxyRow.ID, proxyRow.SessionID))
|
||||
rec = f.do(t, http.MethodDelete, "/agent-network/settings", "")
|
||||
assert.Equal(t, http.StatusOK, rec.Code,
|
||||
"delete after the proxy disconnected must succeed: got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// TestSettingsHandler_DeleteReleasesEndpointForFreshBootstrap pins the
|
||||
// full-reset semantic that gives replace-on-change clients (e.g. Terraform's
|
||||
// RequiresReplace) a real path: with both guards clear the delete succeeds,
|
||||
// the account reads as the defaults again, and a fresh bootstrap draws a
|
||||
// fresh label. The released hostname is not reserved — a fresh draw may even
|
||||
// legitimately re-pick it — so the assertions check the new row's shape, not
|
||||
// that the label differs.
|
||||
func TestSettingsHandler_DeleteReleasesEndpointForFreshBootstrap(t *testing.T) {
|
||||
f := newAgentNetworkHandlerFixture(t)
|
||||
|
||||
rec := f.do(t, http.MethodPost, "/agent-network/settings",
|
||||
`{"proxy_address": "eu.proxy.netbird.io", "enable_prompt_collection": true}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "bootstrap POST must succeed: %s", rec.Body.String())
|
||||
|
||||
rec = f.do(t, http.MethodDelete, "/agent-network/settings", "")
|
||||
require.Equal(t, http.StatusOK, rec.Code,
|
||||
"delete with both guards clear must succeed: got %d body=%s", rec.Code, rec.Body.String())
|
||||
|
||||
rec = f.do(t, http.MethodGet, "/agent-network/settings", "")
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var after api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &after))
|
||||
assert.Empty(t, after.Endpoint, "a deleted account must read as unbootstrapped defaults")
|
||||
assert.False(t, after.EnablePromptCollection, "the deleted row's toggles must not linger")
|
||||
|
||||
rec = f.do(t, http.MethodPost, "/agent-network/settings", `{"proxy_address": "eu.proxy.netbird.io"}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, "re-bootstrap after delete must succeed: %s", rec.Body.String())
|
||||
var second api.AgentNetworkSettings
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &second))
|
||||
require.NotEmpty(t, second.Endpoint, "the fresh bootstrap must allocate an endpoint")
|
||||
assert.True(t, strings.HasSuffix(second.Endpoint, ".eu.proxy.netbird.io"),
|
||||
"the fresh endpoint must hang beneath the requested proxy address: %s", second.Endpoint)
|
||||
assert.False(t, second.EnablePromptCollection,
|
||||
"the fresh row must carry bootstrap defaults, not the deleted row's toggles")
|
||||
assert.NotNil(t, second.CreatedAt, "the fresh row is persisted and carries timestamps")
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package labelgen
|
||||
|
||||
// adjectives is the descriptor half of a generated label. It pairs with the
|
||||
// noun pool in words.go to form `<adjective>-<noun>` labels, and is kept
|
||||
// separate because words.go is almost entirely nouns — drawing both halves
|
||||
// from it produced unreadable pairs like "millet-hammock". Entries are
|
||||
// lowercase ASCII, 4-12 chars, free of hyphens and digits, screened for
|
||||
// offensive/brand/region-specific terms, and disjoint from the noun pool
|
||||
// (enforced by TestAdjectives_AreDisjointFromNouns).
|
||||
var adjectives = []string{
|
||||
"able", "active", "adept", "agile", "airy", "alert", "amiable", "ample",
|
||||
"ancient", "ardent", "artful", "astute", "balmy", "blithe", "bold", "bonny",
|
||||
"brave", "breezy", "brisk", "bubbly", "buoyant", "bushy", "candid", "canny",
|
||||
"cheery", "chilly", "chipper", "chunky", "civil", "classic", "clever", "comely",
|
||||
"compact", "cordial", "cosmic", "courtly", "crafty", "creamy", "crisp", "cuddly",
|
||||
"curious", "dainty", "dapper", "daring", "dashing", "deft", "dewy", "diligent",
|
||||
"downy", "dreamy", "dulcet", "durable", "dusky", "eager", "earnest", "earthy",
|
||||
"easy", "elated", "elegant", "epic", "fabled", "faithful", "fancy", "fearless",
|
||||
"feisty", "fervent", "fleet", "fluffy", "fond", "frisky", "frosty", "gallant",
|
||||
"genial", "genteel", "gentle", "giddy", "gilded", "glad", "glassy", "gleaming",
|
||||
"glossy", "graceful", "grand", "grainy", "hale", "hardy", "hearty", "hefty",
|
||||
"honest", "hopeful", "humble", "hushed", "immense", "jaunty", "jolly", "jovial",
|
||||
"joyful", "jubilant", "keen", "kindly", "kindred", "lanky", "leafy", "limber",
|
||||
"lively", "lofty", "loyal", "lucent", "lucid", "luminous", "lush", "maroon",
|
||||
"mellow", "merry", "mighty", "mindful", "mirthful", "misty", "modest", "muted",
|
||||
"nifty", "nimble", "noble", "patient", "peaceful", "pearly", "peppy", "perky",
|
||||
"petite", "placid", "playful", "pleasant", "plucky", "plush", "polite", "posh",
|
||||
"prancing", "pristine", "prompt", "proud", "prudent", "quaint", "quick", "quirky",
|
||||
"radiant", "ready", "regal", "restful", "robust", "rosy", "ruddy", "rugged",
|
||||
"sandy", "satin", "saucy", "savvy", "sedate", "serene", "shady", "shiny",
|
||||
"silken", "silky", "sincere", "sleek", "slender", "smart", "smooth", "snappy",
|
||||
"snug", "soaring", "sparkly", "spiffy", "spirited", "sprightly", "spry", "stalwart",
|
||||
"stately", "steady", "sterling", "stoic", "stormy", "stout", "sturdy", "sunlit",
|
||||
"supple", "svelte", "tawny", "tender", "tidy", "timeless", "trusty", "upbeat",
|
||||
"urbane", "valiant", "vast", "vernal", "vibrant", "vintage", "whimsy", "willing",
|
||||
"windy", "winsome", "wintry", "witty", "worthy", "zesty", "zippy",
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Package labelgen produces DNS-safe Agent Network subdomain labels.
|
||||
package labelgen
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/util"
|
||||
)
|
||||
|
||||
// pickAttempts caps the random retries before falling back to the
|
||||
// suffixed form. Eight is a soft compromise: with a near-empty taken
|
||||
// set the very first pick almost always succeeds; when the wordlist is
|
||||
// densely populated the fallback eventually fires anyway.
|
||||
const pickAttempts = 8
|
||||
|
||||
var (
|
||||
dedupOnce sync.Once
|
||||
uniqWords []string
|
||||
)
|
||||
|
||||
// uniqueWords returns the wordlist deduplicated and sorted for
|
||||
// deterministic exhaustion behaviour. Lazy-built once per process.
|
||||
func uniqueWords() []string {
|
||||
dedupOnce.Do(func() {
|
||||
seen := make(map[string]struct{}, len(words))
|
||||
uniqWords = make([]string, 0, len(words))
|
||||
for _, w := range words {
|
||||
if _, ok := seen[w]; ok {
|
||||
continue
|
||||
}
|
||||
seen[w] = struct{}{}
|
||||
uniqWords = append(uniqWords, w)
|
||||
}
|
||||
sort.Strings(uniqWords)
|
||||
})
|
||||
return uniqWords
|
||||
}
|
||||
|
||||
// PickUnique selects a label not already in `taken`. It tries up to
|
||||
// pickAttempts random picks; on exhaustion it scans the deduplicated
|
||||
// wordlist for any remaining free entry, and if none is left appends
|
||||
// `-<fallbackSuffix>` to a random word and returns.
|
||||
func PickUnique(taken map[string]struct{}, fallbackSuffix string) string {
|
||||
pool := uniqueWords()
|
||||
if len(pool) == 0 {
|
||||
return fallbackSuffix
|
||||
}
|
||||
|
||||
for i := 0; i < pickAttempts; i++ {
|
||||
w := pool[util.RandIntn(len(pool))]
|
||||
if _, ok := taken[w]; !ok {
|
||||
return w
|
||||
}
|
||||
}
|
||||
|
||||
for _, w := range pool {
|
||||
if _, ok := taken[w]; !ok {
|
||||
return w
|
||||
}
|
||||
}
|
||||
|
||||
w := pool[util.RandIntn(len(pool))]
|
||||
return fmt.Sprintf("%s-%s", w, fallbackSuffix)
|
||||
}
|
||||
|
||||
// PickTuple returns an adjective-noun label such as "brave-otter". It is still
|
||||
// a single DNS label.
|
||||
//
|
||||
// Unlike PickUnique it takes no `taken` set and has no fallback suffix. The
|
||||
// noun pool holds 857 entries, which is ample per cluster but a hard ceiling
|
||||
// once labels must be unique across one shared zone; pairing an adjective with
|
||||
// a noun spans len(adjectives) * 857 instead. Uniqueness is enforced by a
|
||||
// database constraint and retried by the caller, rather than guessed from a
|
||||
// pre-read set that a concurrent allocation can invalidate.
|
||||
func PickTuple() string {
|
||||
nouns := uniqueWords()
|
||||
if len(nouns) == 0 || len(adjectives) == 0 {
|
||||
return ""
|
||||
}
|
||||
return adjectives[util.RandIntn(len(adjectives))] + "-" + nouns[util.RandIntn(len(nouns))]
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package labelgen
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestPickUnique_ReturnsWordFromPool confirms a pick against an empty
|
||||
// taken set is always drawn verbatim from the wordlist.
|
||||
func TestPickUnique_ReturnsWordFromPool(t *testing.T) {
|
||||
got := PickUnique(map[string]struct{}{}, "abcd")
|
||||
|
||||
assert.True(t, slices.Contains(uniqueWords(), got), "Pick %q must be drawn from the wordlist", got)
|
||||
}
|
||||
|
||||
// TestPickUnique_AvoidsTakenWordsWhenMostAreReserved seeds taken with
|
||||
// every word in the pool except a handful and confirms PickUnique
|
||||
// finds one of the remaining free entries instead of returning the
|
||||
// fallback form.
|
||||
func TestPickUnique_AvoidsTakenWordsWhenMostAreReserved(t *testing.T) {
|
||||
pool := uniqueWords()
|
||||
require.NotEmpty(t, pool, "wordlist must be populated for the test to mean anything")
|
||||
|
||||
free := map[string]struct{}{
|
||||
pool[0]: {},
|
||||
pool[len(pool)/2]: {},
|
||||
pool[len(pool)-1]: {},
|
||||
}
|
||||
|
||||
taken := make(map[string]struct{}, len(pool))
|
||||
for _, w := range pool {
|
||||
if _, ok := free[w]; ok {
|
||||
continue
|
||||
}
|
||||
taken[w] = struct{}{}
|
||||
}
|
||||
|
||||
got := PickUnique(taken, "abcd")
|
||||
|
||||
_, isFree := free[got]
|
||||
assert.True(t, isFree, "PickUnique must return one of the free words; got %q", got)
|
||||
assert.NotContains(t, got, "-", "Free pick must not be the suffix fallback form")
|
||||
}
|
||||
|
||||
// TestPickUnique_FallsBackWhenAllReserved exhausts the pool and
|
||||
// confirms PickUnique appends the supplied suffix instead of
|
||||
// returning a duplicate.
|
||||
func TestPickUnique_FallsBackWhenAllReserved(t *testing.T) {
|
||||
pool := uniqueWords()
|
||||
|
||||
taken := make(map[string]struct{}, len(pool))
|
||||
for _, w := range pool {
|
||||
taken[w] = struct{}{}
|
||||
}
|
||||
|
||||
got := PickUnique(taken, "abcd")
|
||||
|
||||
assert.True(t, strings.HasSuffix(got, "-abcd"), "Exhausted pool must produce <word>-<suffix>; got %q", got)
|
||||
|
||||
prefix := strings.TrimSuffix(got, "-abcd")
|
||||
found := false
|
||||
for _, w := range pool {
|
||||
if w == prefix {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Fallback prefix must be drawn from the wordlist; got %q", prefix)
|
||||
}
|
||||
|
||||
// TestUniqueWords_DropsDuplicates guards against authoring slips in
|
||||
// words.go: every entry must be unique and DNS-safe.
|
||||
func TestUniqueWords_DropsDuplicates(t *testing.T) {
|
||||
pool := uniqueWords()
|
||||
seen := make(map[string]struct{}, len(pool))
|
||||
for _, w := range pool {
|
||||
_, dup := seen[w]
|
||||
assert.False(t, dup, "Duplicate entry %q in deduplicated pool", w)
|
||||
seen[w] = struct{}{}
|
||||
assert.GreaterOrEqual(t, len(w), 4, "Word %q is shorter than 4 chars", w)
|
||||
assert.LessOrEqual(t, len(w), 12, "Word %q is longer than 12 chars", w)
|
||||
for _, r := range w {
|
||||
ok := r >= 'a' && r <= 'z'
|
||||
assert.True(t, ok, "Word %q contains non-lowercase-ASCII rune %q", w, r)
|
||||
}
|
||||
}
|
||||
assert.GreaterOrEqual(t, len(pool), 500, "Pool must contain at least 500 unique words")
|
||||
}
|
||||
|
||||
// TestPickTuple_ShapeAndPoolMembership locks the wire-visible shape: an
|
||||
// adjective and a noun, each from its own pool, joined by a single hyphen so
|
||||
// the result stays one DNS label.
|
||||
func TestPickTuple_ShapeAndPoolMembership(t *testing.T) {
|
||||
nouns := uniqueWords()
|
||||
inNouns := make(map[string]struct{}, len(nouns))
|
||||
for _, w := range nouns {
|
||||
inNouns[w] = struct{}{}
|
||||
}
|
||||
inAdjectives := make(map[string]struct{}, len(adjectives))
|
||||
for _, a := range adjectives {
|
||||
inAdjectives[a] = struct{}{}
|
||||
}
|
||||
|
||||
for i := 0; i < 200; i++ {
|
||||
got := PickTuple()
|
||||
|
||||
parts := strings.Split(got, "-")
|
||||
require.Len(t, parts, 2, "PickTuple must produce exactly two hyphen-joined words; got %q", got)
|
||||
|
||||
_, adjOK := inAdjectives[parts[0]]
|
||||
assert.True(t, adjOK, "First half must be an adjective; %q not in adjectives (from %q)", parts[0], got)
|
||||
_, nounOK := inNouns[parts[1]]
|
||||
assert.True(t, nounOK, "Second half must be a noun; %q not in words (from %q)", parts[1], got)
|
||||
|
||||
assert.LessOrEqual(t, len(got), 63, "Label must fit a DNS label; got %q (%d chars)", got, len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdjectives_AreDisjointFromNouns keeps the namespace a clean product and
|
||||
// prevents nonsense like "azure-azure": a handful of the noun pool's entries
|
||||
// are adjectival, and any overlap would let the same word land on both sides.
|
||||
func TestAdjectives_AreDisjointFromNouns(t *testing.T) {
|
||||
nouns := make(map[string]struct{}, len(uniqueWords()))
|
||||
for _, w := range uniqueWords() {
|
||||
nouns[w] = struct{}{}
|
||||
}
|
||||
for _, a := range adjectives {
|
||||
_, clash := nouns[a]
|
||||
assert.False(t, clash, "Adjective %q also appears in the noun pool; remove it from one list", a)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdjectives_AreDNSSafeAndDeduplicated mirrors the curation contract stated
|
||||
// in words.go: lowercase ASCII, 4-12 chars, no digits or hyphens, no repeats.
|
||||
func TestAdjectives_AreDNSSafeAndDeduplicated(t *testing.T) {
|
||||
seen := make(map[string]struct{}, len(adjectives))
|
||||
for _, a := range adjectives {
|
||||
_, dup := seen[a]
|
||||
assert.False(t, dup, "Duplicate adjective %q", a)
|
||||
seen[a] = struct{}{}
|
||||
|
||||
assert.Regexp(t, `^[a-z]{4,12}$`, a, "Adjective %q must be 4-12 lowercase ASCII letters", a)
|
||||
}
|
||||
assert.Greater(t, len(adjectives), 150, "Adjective pool too small to give a useful namespace")
|
||||
}
|
||||
|
||||
// TestPickTuple_SpansALargeNamespace guards the reason we moved to tuples: a
|
||||
// single-word pool caps the GLOBAL namespace at 857. Drawing many tuples must
|
||||
// yield overwhelmingly distinct values.
|
||||
func TestPickTuple_SpansALargeNamespace(t *testing.T) {
|
||||
seen := make(map[string]struct{}, 2000)
|
||||
for i := 0; i < 2000; i++ {
|
||||
seen[PickTuple()] = struct{}{}
|
||||
}
|
||||
assert.Greater(t, len(seen), 1900,
|
||||
"2000 draws should be nearly all distinct across a ~200k namespace; got %d unique", len(seen))
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// Package labelgen produces DNS-safe Agent Network subdomain labels.
|
||||
//
|
||||
// The wordlist below is a curated subset drawn from public-domain
|
||||
// nature / common-noun pools (e.g. EFF's diceware lists). Every entry
|
||||
// is lowercase ASCII, 4–12 chars, no hyphens, no digits, and was
|
||||
// hand-checked to avoid offensive, brand, or region-specific terms.
|
||||
package labelgen
|
||||
|
||||
// words is the pool PickUnique selects from. The slice is intentionally
|
||||
// not sorted — random picks distribute across the list naturally.
|
||||
var words = []string{
|
||||
"acorn", "adobe", "agate", "alder", "almond", "alpine", "amber", "amethyst",
|
||||
"anchor", "antler", "apple", "apricot", "arcade", "arctic", "arrow", "ashen",
|
||||
"aspen", "atlas", "atom", "aurora", "autumn", "azure",
|
||||
"badger", "bamboo", "banana", "banjo", "barley", "barn", "basalt", "basil",
|
||||
"basin", "bayou", "beach", "beacon", "beaver", "beech", "beetle", "berry",
|
||||
"birch", "bison", "blossom", "blue", "bobcat", "bonsai", "boulder", "branch",
|
||||
"brass", "breeze", "bridge", "bright", "brook", "broom", "brown", "buffalo",
|
||||
"bumble", "burrow", "butter", "button",
|
||||
"cabin", "cactus", "calm", "camel", "campfire", "canary", "candle", "canoe",
|
||||
"canyon", "cardinal", "carrot", "cascade", "castle", "cedar", "celery", "cello",
|
||||
"cement", "cherry", "chestnut", "chime", "cinnamon", "cinder", "citron", "clay",
|
||||
"clear", "cliff", "clock", "cloud", "clover", "coast", "cobalt", "cobble",
|
||||
"cocoa", "coffee", "comet", "compass", "copper", "coral", "corner", "cosmos",
|
||||
"cotton", "cougar", "country", "coyote", "cove", "crane", "crater", "creek",
|
||||
"crescent", "crimson", "crocus", "crystal", "cypress",
|
||||
"daffodil", "dahlia", "daisy", "dawn", "deer", "delta", "denim", "desert",
|
||||
"dewdrop", "diamond", "dolphin", "doodle", "dove", "dragon", "drift", "drop",
|
||||
"dune", "dusk", "dusty",
|
||||
"eagle", "earth", "echo", "elder", "elkhorn", "ember", "emerald", "emperor",
|
||||
"evergreen", "evening",
|
||||
"falcon", "fawn", "feather", "fern", "fiddle", "field", "fiesta", "finch",
|
||||
"firepit", "firefly", "fjord", "flame", "flax", "fleece", "flint", "floral",
|
||||
"flower", "flute", "foal", "foggy", "forest", "fountain", "foxglove", "fresh",
|
||||
"frost", "fuchsia", "fudge",
|
||||
"gable", "galaxy", "garden", "garnet", "gazelle", "geode", "geyser", "ginger",
|
||||
"glacier", "glade", "glass", "glow", "gold", "goose", "gorge", "gourd",
|
||||
"granite", "grape", "grass", "gravel", "grayling", "greenery", "grizzly", "grove",
|
||||
"gull", "gumdrop", "gust",
|
||||
"hammock", "harbor", "harvest", "hawk", "hazel", "heather", "hedge", "heron",
|
||||
"hibiscus", "hickory", "hideaway", "highland", "hill", "hive", "hollow", "honey",
|
||||
"hopper", "horizon", "hummingbird", "husky",
|
||||
"iceberg", "indigo", "iris", "island", "ivory", "ivybush",
|
||||
"jade", "jasmine", "jasper", "jaybird", "jelly", "jewel", "jonquil", "journey",
|
||||
"juniper", "jupiter", "jute",
|
||||
"kale", "kangaroo", "kayak", "kelp", "kestrel", "kettle", "khaki", "kindling",
|
||||
"kingfisher", "kiwi", "knapweed", "koala",
|
||||
"lagoon", "lake", "lantern", "larch", "lark", "laurel", "lava", "lavender",
|
||||
"leaf", "lemon", "lichen", "light", "lilac", "lily", "lime", "limestone",
|
||||
"linden", "linen", "lion", "lobster", "locust", "loon", "lotus", "lumber",
|
||||
"lunar", "lupine", "lynx",
|
||||
"madrone", "magenta", "magnolia", "mahogany", "mallow", "mango", "manor", "maple",
|
||||
"marble", "marigold", "marina", "marlin", "marsh", "mauve", "meadow", "melody",
|
||||
"melon", "merlin", "metal", "midnight", "milk", "millet", "mineral", "mint",
|
||||
"mirror", "mist", "mitten", "molasses", "moon", "moose", "morning", "moss",
|
||||
"mountain", "mulberry", "muscat", "mustard",
|
||||
"narwhal", "navy", "nectar", "needle", "nest", "nettle", "newt", "nightfall",
|
||||
"noon", "nook", "north", "nova", "nutmeg",
|
||||
"oaken", "oasis", "oatmeal", "ocean", "ochre", "octagon", "olive", "onyx",
|
||||
"opal", "orange", "orbit", "orchard", "orchid", "oregano", "orion", "osprey",
|
||||
"otter", "outpost", "owlet", "oyster",
|
||||
"painter", "palace", "palm", "pansy", "panther", "papaya", "paprika", "parsley",
|
||||
"partridge", "passage", "pastel", "patio", "peach", "peacock", "pear", "pearl",
|
||||
"pebble", "pecan", "pelican", "penguin", "peony", "pepper", "perch", "peridot",
|
||||
"pewter", "phoenix", "pier", "pillar", "pine", "pineapple", "pinto", "piper",
|
||||
"pistachio", "plain", "planet", "plateau", "platinum", "plum", "plume", "polar",
|
||||
"pollen", "pond", "poplar", "poppy", "porcelain", "portal", "portrait", "potato",
|
||||
"prairie", "primrose", "prism", "puffin", "pumpkin",
|
||||
"quail", "quartz", "quaver", "quill", "quince", "quinoa",
|
||||
"rabbit", "raccoon", "radish", "rain", "rainbow", "raindrop", "rapids", "raspberry",
|
||||
"raven", "ravine", "redwood", "reed", "reef", "ridge", "river", "robin",
|
||||
"rocket", "rubyred", "rose", "rosemary", "rosewood", "ruffle", "rugby", "russet",
|
||||
"rustic", "ryefield",
|
||||
"saffron", "sage", "salmon", "sand", "sandstone", "sapphire", "savanna", "scarlet",
|
||||
"scout", "seal", "season", "seaweed", "sequoia", "shadow", "shamrock", "shell",
|
||||
"sherbet", "shore", "silver", "siskin", "skybloom", "skyline", "sleet", "smoke",
|
||||
"snail", "snapdragon", "snow", "snowflake", "snowy", "solar", "song", "sonic",
|
||||
"sorrel", "south", "sparkle", "sparrow", "spice", "spider", "spinach", "spire",
|
||||
"spring", "sprout", "spruce", "squirrel", "starfish", "starlight", "stoat", "stone",
|
||||
"stork", "storm", "stream", "studio", "summer", "sunbeam", "sundew", "sunny",
|
||||
"sunrise", "sunset", "swallow", "swan", "sweet", "sycamore",
|
||||
"tangelo", "tangerine", "tansy", "taupe", "teak", "teal", "thicket", "thistle",
|
||||
"thrush", "thunder", "tide", "tiger", "tinder", "topaz", "torch", "tortoise",
|
||||
"tower", "trail", "tranquil", "tundra", "tulip", "turquoise", "turtle", "twig",
|
||||
"twilight",
|
||||
"umber", "uplands",
|
||||
"valley", "vanilla", "velvet", "venus", "verdant", "verdigris", "vermilion", "violet",
|
||||
"vista", "vivid", "volcano", "vortex",
|
||||
"walnut", "warbler", "watercress", "waterfall", "wave", "waxwing", "weasel", "westwind",
|
||||
"whale", "whisker", "whisper", "wicker", "wildwood", "willow", "winter", "wisp",
|
||||
"wisteria", "wolf", "wombat", "woodland", "woolly", "wren", "wreath",
|
||||
"yarrow", "yellow", "yewtree", "yodel",
|
||||
"zebra", "zenith", "zephyr", "zinnia",
|
||||
"alabaster", "alfalfa", "almanac", "anise", "antelope", "arbor", "arena", "armadillo",
|
||||
"avocet", "azalea", "balsam", "bayou", "beacon", "blizzard", "bluebell", "bluebird",
|
||||
"bluejay", "bobolink", "borage", "boreal", "buckeye", "buckthorn", "buttercup",
|
||||
"cabana", "calico", "canopy", "caraway", "cardamom", "cattail", "celadon", "centaur",
|
||||
"chambray", "chamois", "champlain", "chestnuts", "chickadee", "chinook", "chipmunk", "cinnabar",
|
||||
"cirrus", "citrine", "clematis", "copperhead",
|
||||
"crocodile", "currant", "cuttlebone", "daffy", "dapple", "delphinium", "dervish", "diamondback",
|
||||
"dogwood", "dolphins", "dragonfly", "driftwood", "dusk", "dustpan", "ebony", "edelweiss",
|
||||
"emperor", "endive", "estuary", "everglade", "fairway", "feldspar", "fennel", "fieldstone",
|
||||
"firebrand", "firefly", "fireweed", "firework", "flagstone", "fossil", "frostbite", "galleon",
|
||||
"gardener", "geranium", "gingko", "ginseng", "goldfish", "goldfinch", "goldenrod", "graphite",
|
||||
"greenfinch", "guppy", "haiku", "halibut", "hammerhead", "harbinger", "harvest", "hatchling",
|
||||
"havana", "hawthorn", "hazelnut", "heartwood", "henna", "heron", "highrise", "homestead",
|
||||
"honeycomb", "honeydew", "horseshoe", "hyacinth", "iceland", "icicle", "indigobird", "ironwood",
|
||||
"jacaranda", "jamboree", "javelina", "jellyfish", "junebug", "kaleido", "kayaker", "kerchief",
|
||||
"keystone", "kingdom", "labrador", "lacewing", "ladybug", "lakeside", "lamplight", "leopard",
|
||||
"lighthouse", "lilypad", "lullaby", "magnet", "mahonia", "mandolin", "manzanita", "maraschino",
|
||||
"mariner", "marsupial", "mastodon", "matterhorn", "mayflower", "mayfly", "meadowlark", "merlot",
|
||||
"meteor", "midshipman", "millpond", "mimosa", "minnow", "mockingbird", "molten", "monarch",
|
||||
"monsoon", "moondust", "moonlight", "moorland", "morning", "mossland", "mountain", "mulch",
|
||||
"narcissus", "nautilus", "nettlebush", "northstar", "nuthatch", "obsidian", "okra", "olivine",
|
||||
"opalescent", "orchidea", "orchard", "ornament", "outrigger", "oxalis", "paddler", "paintbrush",
|
||||
"papyrus", "paradise", "pasture", "patchwork", "pathway", "peridot", "periwinkle", "petalbloom",
|
||||
"petrel", "petunia", "phlox", "pikeperch", "pinecone", "pioneer", "pipevine", "platypus",
|
||||
"pomelo", "pondweed", "porpoise", "powder", "promise", "puddle", "pumice", "puzzle",
|
||||
"quetzal", "quicksilver", "raccoon", "ragwort", "rainforest", "ramble", "rapid", "rascal",
|
||||
"raspberry", "redbud", "redfern", "redpoll", "reedling", "ringtail", "riverbed", "riverbird",
|
||||
"riverstone", "rockcress", "roebuck", "rosebay", "rosehip", "rosemary", "rowan", "rumble",
|
||||
"runaway", "rustler", "sagebrush", "sailcloth", "salamander", "salsify", "samphire", "sandbar",
|
||||
"sanddollar", "sandpiper", "santolina", "sapodilla", "sassafras", "scallion", "schooner", "seafoam",
|
||||
"seafrost", "seagrass", "seahorse", "seaport", "seashell", "seaspray", "shamble", "shimmer",
|
||||
"shoreline", "silkmoth", "silverfox", "skylark", "snapdragon", "snowberry", "snowdrop", "snowfall",
|
||||
"snowmelt", "softwood", "songbird", "sorghum", "southwind", "speedwell", "spinnaker", "spruce",
|
||||
"starlight", "starling", "stormcloud", "summit", "sundance", "sundew", "sundial", "sunflower",
|
||||
"surface", "swallowtail", "sweetcorn", "sycamore", "tabletop", "tamarack", "tamarind", "tangerine",
|
||||
"tarragon", "telescope", "thicket", "thrasher", "thunder", "thyme", "tideline", "timberland",
|
||||
"tinderbox", "topiary", "torchwood", "totem", "tradewind", "treasure", "tremolo", "trinket",
|
||||
"trumpetvine", "tugboat", "tundra", "turnstone", "underbrush", "vagabond", "valerian", "vanilla",
|
||||
"velveteen", "vermilion", "vinca", "vineyard", "violet", "voyager", "wagonwheel", "walnutwood",
|
||||
"watermark", "watershed", "waterway", "wavefront", "westerly", "whaleback", "whetstone", "wicker",
|
||||
"wildbloom", "wildflower", "wilderness", "windsong", "windward", "winterberry", "woodbine", "woodfern",
|
||||
"woodland", "woodthrush", "woolgrass", "yellowfin", "zenithal", "zucchini",
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,521 @@
|
||||
// Package modeldiscovery asks a vendor which models an operator's own
|
||||
// credential can reach, so the provider form can offer a live list instead of
|
||||
// only the catalog's hand-curated one.
|
||||
//
|
||||
// The catalog cannot know two things that matter. It goes stale — its entries
|
||||
// carry comments tracking which models a vendor retired on which date — and it
|
||||
// cannot see an account: which OpenAI models an org is entitled to, which
|
||||
// Bedrock inference profiles a given account and region hold, which Vertex
|
||||
// models a project has enabled. Those are exactly the facts an operator needs
|
||||
// when filling in a provider record, and only the vendor has them.
|
||||
//
|
||||
// The vendor is authoritative for the model ID. The catalog remains
|
||||
// authoritative for pricing, and a discovered model the catalog cannot price
|
||||
// is reported as such rather than silently registered at a rate of zero.
|
||||
package modeldiscovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2/google"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing"
|
||||
)
|
||||
|
||||
const (
|
||||
// fetchTimeout bounds one vendor call end to end. A listing is a single
|
||||
// small GET; anything slower is a vendor problem and the operator is
|
||||
// waiting on a form.
|
||||
fetchTimeout = 8 * time.Second
|
||||
// maxListingBytes bounds the response we will buffer. The largest real
|
||||
// listing observed is Bedrock's foundation-model catalogue at ~70KB, so
|
||||
// this is a wide margin over anything legitimate.
|
||||
maxListingBytes = 2 << 20
|
||||
// gcpScope matches the scope llm_router mints Vertex tokens under, so a
|
||||
// credential that works for discovery works for inference too.
|
||||
gcpScope = "https://www.googleapis.com/auth/cloud-platform"
|
||||
// vertexKeyfilePrefix marks an api_key that is a base64 service-account
|
||||
// JSON key rather than a bearer token.
|
||||
vertexKeyfilePrefix = "keyfile::"
|
||||
)
|
||||
|
||||
// ErrNoDiscovery is returned for a catalog entry that declares no listing
|
||||
// endpoint. The caller should fall back to the catalog list plus free-text
|
||||
// entry rather than treating this as a failure.
|
||||
var ErrNoDiscovery = errors.New("provider has no model-discovery endpoint")
|
||||
|
||||
// ErrInvalidRequest marks a discovery failure caused by the caller's own input
|
||||
// rather than by the vendor or by this server. Every one of these is reachable
|
||||
// from a well-formed request carrying a bad field value, so the handler owes
|
||||
// the caller a 400 — a 500 would both misinform them and bury real server
|
||||
// faults in the error rate.
|
||||
var ErrInvalidRequest = errors.New("invalid discovery request")
|
||||
|
||||
// Model is one discovered model.
|
||||
type Model struct {
|
||||
// ID is the identifier to register on the provider record, in the form the
|
||||
// vendor issues it. For Bedrock that is the region-prefixed inference
|
||||
// profile id, which is the only form AWS accepts at invoke time.
|
||||
ID string
|
||||
// Label is the vendor's display name where it supplies one.
|
||||
Label string
|
||||
// PricingKnown reports whether the shipped pricing table can price this
|
||||
// model. False means the operator must set rates, or the request would
|
||||
// meter at zero.
|
||||
PricingKnown bool
|
||||
// The rates below are the defaults for this model, taken from the same
|
||||
// table the proxy bills with, so the form prefills exactly what a request
|
||||
// would cost. All zero when PricingKnown is false — an unpriced model is
|
||||
// offered at zero and flagged, rather than withheld: the vendor says the
|
||||
// credential can reach it, and refusing to show it would hide a model the
|
||||
// operator genuinely has.
|
||||
InputPer1k float64
|
||||
OutputPer1k float64
|
||||
CachedInputPer1k float64
|
||||
CacheReadPer1k float64
|
||||
CacheCreationPer1k float64
|
||||
}
|
||||
|
||||
// Request identifies which vendor to ask and with what credential.
|
||||
type Request struct {
|
||||
// CatalogID selects the catalog entry, which supplies the endpoint, the
|
||||
// auth header and the response shape. The caller never supplies those.
|
||||
CatalogID string
|
||||
// UpstreamURL is the provider record's configured upstream. It is used
|
||||
// only when the catalog entry declares no discovery host of its own.
|
||||
UpstreamURL string
|
||||
// Region substitutes the catalog host's <region> placeholder.
|
||||
Region string
|
||||
// APIKey is the operator's credential, exactly as stored on the record.
|
||||
APIKey string
|
||||
}
|
||||
|
||||
// Client fetches model listings. The zero value is usable; Resolver and
|
||||
// HTTPClient exist so tests can drive it against a local server.
|
||||
type Client struct {
|
||||
HTTPClient *http.Client
|
||||
// Resolver looks up the host for the SSRF check. Nil uses the default.
|
||||
Resolver *net.Resolver
|
||||
// AllowPrivateHosts disables the private-address guard. Only tests set it:
|
||||
// their server is on loopback, which is precisely what the guard blocks.
|
||||
AllowPrivateHosts bool
|
||||
}
|
||||
|
||||
// Fetch returns the models the credential can reach.
|
||||
func (c *Client) Fetch(ctx context.Context, req Request) ([]Model, error) {
|
||||
entry, ok := catalog.Lookup(req.CatalogID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: unknown catalog provider %q", ErrInvalidRequest, req.CatalogID)
|
||||
}
|
||||
if entry.Discovery == nil {
|
||||
return nil, ErrNoDiscovery
|
||||
}
|
||||
|
||||
// One deadline over the whole operation. Both host lookups and the request
|
||||
// itself run under it, so a vendor cannot be slow twice, and a caller that
|
||||
// gives up is not left waiting on a resolver.
|
||||
ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
|
||||
defer cancel()
|
||||
|
||||
// An entry with a listing host of its own answers from somewhere other
|
||||
// than the upstream on the record — Bedrock lists from the control plane
|
||||
// and infers on the runtime host. Reaching the listing therefore proves
|
||||
// nothing about the host requests will actually go to, so that one is
|
||||
// checked separately or not at all.
|
||||
if entry.Discovery.Host != "" {
|
||||
if err := c.checkUpstreamHost(ctx, entry, req.UpstreamURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
endpoint, err := c.discoveryURL(ctx, entry, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build discovery request: %w", err)
|
||||
}
|
||||
if err := applyAuth(httpReq, entry, req.APIKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for name, value := range entry.Discovery.Headers {
|
||||
httpReq.Header.Set(name, value)
|
||||
}
|
||||
httpReq.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient().Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, &UnreachableError{Provider: entry.Name, Err: err}
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxListingBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s listing: %w", entry.Name, err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// Surface the vendor's own status. An operator whose key lacks a scope
|
||||
// needs to see 403 rather than a generic failure.
|
||||
return nil, &VendorStatusError{Provider: entry.Name, Status: resp.StatusCode}
|
||||
}
|
||||
|
||||
ids, err := parseListing(entry.Discovery.Shape, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decorate(entry, ids), nil
|
||||
}
|
||||
|
||||
// discoveryURL builds the listing URL and refuses one that does not point at a
|
||||
// public host.
|
||||
//
|
||||
// The path, query and (for Bedrock) the host all come from the catalog rather
|
||||
// than from the caller, so the only operator-controlled part is the host of an
|
||||
// entry whose listing lives on its own upstream. That still has to be checked:
|
||||
// management holds credentials for every provider, and an upstream pointed at
|
||||
// an internal address would turn this endpoint into a probe of the management
|
||||
// server's own network.
|
||||
func (c *Client) discoveryURL(ctx context.Context, entry catalog.Provider, req Request) (string, error) {
|
||||
host := entry.Discovery.Host
|
||||
if host == "" {
|
||||
parsed, err := url.Parse(strings.TrimSpace(req.UpstreamURL))
|
||||
if err != nil || parsed.Host == "" {
|
||||
// The URL is left out of the message on purpose: it reaches the
|
||||
// operator through an endpoint that does not lowercase it, but the
|
||||
// rest of this feature's copy never echoes what they typed, and one
|
||||
// path that does is the one that ends up quoted in a bug report.
|
||||
return "", fmt.Errorf("%w: the provider upstream is not a usable URL", ErrInvalidRequest)
|
||||
}
|
||||
host = parsed.Host
|
||||
}
|
||||
if strings.Contains(host, catalog.RegionPlaceholder) {
|
||||
region := strings.TrimSpace(req.Region)
|
||||
if region == "" {
|
||||
// A provider record carries no region field: the region lives
|
||||
// inside the upstream host the operator already configured, so
|
||||
// read it back out rather than asking them for it twice.
|
||||
region = RegionFromUpstream(entry, req.UpstreamURL)
|
||||
}
|
||||
if region == "" {
|
||||
return "", fmt.Errorf("%w: %w: %s discovery needs a region, and none could be read from the provider upstream",
|
||||
ErrInvalidRequest, ErrNoDiscoveryHost, entry.Name)
|
||||
}
|
||||
host = strings.ReplaceAll(host, catalog.RegionPlaceholder, region)
|
||||
}
|
||||
|
||||
target := &url.URL{Scheme: "https", Host: host, Path: entry.Discovery.Path, RawQuery: entry.Discovery.Query}
|
||||
if err := c.classifyHost(ctx, entry, target.Hostname()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return target.String(), nil
|
||||
}
|
||||
|
||||
// checkUpstreamHost verifies the host the operator configured, for entries
|
||||
// whose listing lives elsewhere and so cannot vouch for it.
|
||||
//
|
||||
// A name that does not resolve is the record being wrong. One that resolves
|
||||
// privately is not: an upstream behind a proxy is a supported configuration,
|
||||
// and ErrPrivateHost carries that difference on to the caller, which treats it
|
||||
// as unverifiable rather than as a failure.
|
||||
func (c *Client) checkUpstreamHost(ctx context.Context, entry catalog.Provider, upstreamURL string) error {
|
||||
parsed, err := url.Parse(strings.TrimSpace(upstreamURL))
|
||||
if err != nil || parsed.Hostname() == "" {
|
||||
return fmt.Errorf("%w: the provider upstream is not a usable URL", ErrInvalidRequest)
|
||||
}
|
||||
return c.classifyHost(ctx, entry, parsed.Hostname())
|
||||
}
|
||||
|
||||
// classifyHost renders a failed host check as the two outcomes the caller
|
||||
// distinguishes. A host that refuses to resolve is the commonest way for an
|
||||
// upstream to be wrong and has to arrive as unreachable rather than as an
|
||||
// unclassified fault. ErrPrivateHost means something else entirely — not a bad
|
||||
// host, one we decline to dial.
|
||||
func (c *Client) classifyHost(ctx context.Context, entry catalog.Provider, host string) error {
|
||||
err := c.checkPublicHost(ctx, host)
|
||||
if err == nil || errors.Is(err, ErrPrivateHost) {
|
||||
return err
|
||||
}
|
||||
return &UnreachableError{Provider: entry.Name, Err: err}
|
||||
}
|
||||
|
||||
// RegionFromUpstream recovers the region an operator embedded in the provider
|
||||
// upstream, by matching it against the catalog's own host template. Bedrock's
|
||||
// template is "bedrock-runtime.<region>.amazonaws.com" and Vertex's is
|
||||
// "<region>-aiplatform.googleapis.com", so the region is whatever sits between
|
||||
// the fixed halves. Returns empty when the upstream does not match the
|
||||
// template, which is the case for a custom or proxied endpoint.
|
||||
func RegionFromUpstream(entry catalog.Provider, upstreamURL string) string {
|
||||
prefix, suffix, found := strings.Cut(entry.DefaultHost, catalog.RegionPlaceholder)
|
||||
if !found {
|
||||
return ""
|
||||
}
|
||||
parsed, err := url.Parse(strings.TrimSpace(upstreamURL))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
host := parsed.Hostname()
|
||||
if host == "" {
|
||||
// A bare host with no scheme parses as a path, not a host.
|
||||
host = strings.TrimSpace(upstreamURL)
|
||||
}
|
||||
// The two halves must not overlap. "bedrock-runtime.amazonaws.com" carries
|
||||
// both of Bedrock's — it is the regionless endpoint — and satisfies both
|
||||
// checks above while leaving nothing between them, so slicing it would
|
||||
// panic on an inverted range rather than report "no region here".
|
||||
if !strings.HasPrefix(host, prefix) || !strings.HasSuffix(host, suffix) ||
|
||||
len(host) < len(prefix)+len(suffix) {
|
||||
return ""
|
||||
}
|
||||
region := host[len(prefix) : len(host)-len(suffix)]
|
||||
if region == "" || strings.Contains(region, ".") {
|
||||
return ""
|
||||
}
|
||||
return region
|
||||
}
|
||||
|
||||
// checkPublicHost refuses hosts that resolve to an address the management
|
||||
// server should never be asked to reach on an operator's behalf.
|
||||
func (c *Client) checkPublicHost(ctx context.Context, host string) error {
|
||||
if c.AllowPrivateHosts {
|
||||
return nil
|
||||
}
|
||||
if host == "" {
|
||||
return errors.New("discovery host is empty")
|
||||
}
|
||||
resolver := c.Resolver
|
||||
if resolver == nil {
|
||||
resolver = net.DefaultResolver
|
||||
}
|
||||
addrs, err := resolver.LookupNetIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve discovery host %q: %w", host, err)
|
||||
}
|
||||
// Every address must be public: a name that resolves to one public and one
|
||||
// loopback address is still a way to reach loopback.
|
||||
for _, addr := range addrs {
|
||||
if !isPublic(addr) {
|
||||
return fmt.Errorf("%w: %w: discovery host %q resolves to a non-public address", ErrInvalidRequest, ErrPrivateHost, host)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isPublic reports whether an address is one we are willing to dial.
|
||||
func isPublic(addr netip.Addr) bool {
|
||||
addr = addr.Unmap()
|
||||
switch {
|
||||
case !addr.IsValid(),
|
||||
addr.IsLoopback(),
|
||||
addr.IsPrivate(),
|
||||
addr.IsLinkLocalUnicast(),
|
||||
addr.IsLinkLocalMulticast(),
|
||||
addr.IsInterfaceLocalMulticast(),
|
||||
addr.IsMulticast(),
|
||||
addr.IsUnspecified():
|
||||
return false
|
||||
}
|
||||
// 100.64.0.0/10 (carrier NAT) is where NetBird's own overlay addresses
|
||||
// live, so it is emphatically not somewhere to send a provider credential.
|
||||
if addr.Is4() {
|
||||
b := addr.As4()
|
||||
if b[0] == 100 && b[1] >= 64 && b[1] <= 127 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// applyAuth sets the credential header the catalog entry declares. A Vertex
|
||||
// service-account key is exchanged for an OAuth token first, the same way the
|
||||
// proxy does at request time.
|
||||
func applyAuth(req *http.Request, entry catalog.Provider, apiKey string) error {
|
||||
key := strings.TrimSpace(apiKey)
|
||||
if key == "" {
|
||||
return fmt.Errorf("%w: %s discovery needs an API key", ErrInvalidRequest, entry.Name)
|
||||
}
|
||||
if rest, ok := strings.CutPrefix(key, vertexKeyfilePrefix); ok {
|
||||
token, err := mintGCPToken(req.Context(), rest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key = token
|
||||
}
|
||||
name := entry.AuthHeaderName
|
||||
if name == "" {
|
||||
name = "Authorization"
|
||||
}
|
||||
template := entry.AuthHeaderTemplate
|
||||
if template == "" {
|
||||
template = "${API_KEY}"
|
||||
}
|
||||
req.Header.Set(name, strings.ReplaceAll(template, "${API_KEY}", key))
|
||||
return nil
|
||||
}
|
||||
|
||||
// mintGCPToken exchanges a base64 service-account key for an access token.
|
||||
func mintGCPToken(ctx context.Context, saKeyB64 string) (string, error) {
|
||||
jsonKey, err := base64.StdEncoding.DecodeString(strings.TrimSpace(saKeyB64))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode service-account key: %w", err)
|
||||
}
|
||||
conf, err := google.JWTConfigFromJSON(jsonKey, gcpScope)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse service-account key: %w", err)
|
||||
}
|
||||
tok, err := conf.TokenSource(ctx).Token()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("mint gcp token: %w", err)
|
||||
}
|
||||
return tok.AccessToken, nil
|
||||
}
|
||||
|
||||
// decorate turns raw vendor ids into the models the caller renders, attaching
|
||||
// the rates the request would actually be billed at.
|
||||
//
|
||||
// Rates come from the live default pricing table rather than the compiled-in
|
||||
// catalog, because that is the table the synthesiser ships to the proxy: an
|
||||
// operator running a defaults_llm_pricing.yaml would otherwise be shown one
|
||||
// price in the form and charged another. It is also the same lookup the catalog
|
||||
// endpoint prefills from, so a model reached by either route prices identically.
|
||||
func decorate(entry catalog.Provider, ids []listedModel) []Model {
|
||||
out := make([]Model, 0, len(ids))
|
||||
seen := make(map[string]struct{}, len(ids))
|
||||
for _, listed := range ids {
|
||||
if listed.id == "" {
|
||||
continue
|
||||
}
|
||||
if entry.Discovery.ExactModelsOnly && strings.Contains(listed.id, "*") {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[listed.id]; dup {
|
||||
continue
|
||||
}
|
||||
seen[listed.id] = struct{}{}
|
||||
|
||||
// The table keys pricing by the normalised id while the vendor issues
|
||||
// the wire form, so normalise before looking it up — otherwise every
|
||||
// Bedrock profile would report unpriced.
|
||||
model := Model{ID: listed.id, Label: listed.label}
|
||||
if rate, known := pricing.LookupDefault(entry.PricingSurfaces, normalizeForPricing(entry.ID, listed.id)); known {
|
||||
model.PricingKnown = true
|
||||
model.InputPer1k = rate.InputPer1k
|
||||
model.OutputPer1k = rate.OutputPer1k
|
||||
model.CachedInputPer1k = rate.CachedInputPer1k
|
||||
model.CacheReadPer1k = rate.CacheReadPer1k
|
||||
model.CacheCreationPer1k = rate.CacheCreationPer1k
|
||||
}
|
||||
out = append(out, model)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// refuseRedirect is the redirect policy every discovery request runs under. A
|
||||
// redirect is a way to move the request to a host checkPublicHost never saw,
|
||||
// so none are followed.
|
||||
func refuseRedirect(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
|
||||
func (c *Client) httpClient() *http.Client {
|
||||
if c.HTTPClient != nil {
|
||||
if c.HTTPClient.CheckRedirect != nil {
|
||||
return c.HTTPClient
|
||||
}
|
||||
// An injected client that states no policy still gets ours: the
|
||||
// no-redirect guarantee should not depend on the caller remembering it.
|
||||
//
|
||||
// Copied rather than assigned into: one Client is shared by every
|
||||
// request for the process's lifetime, so writing to its fields here
|
||||
// would race across request goroutines. The copy shares the Transport,
|
||||
// which is safe for concurrent use by design.
|
||||
clone := *c.HTTPClient
|
||||
clone.CheckRedirect = refuseRedirect
|
||||
return &clone
|
||||
}
|
||||
transport := guardedTransport
|
||||
if c.AllowPrivateHosts {
|
||||
transport = http.DefaultTransport
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: fetchTimeout,
|
||||
Transport: transport,
|
||||
CheckRedirect: refuseRedirect,
|
||||
}
|
||||
}
|
||||
|
||||
// guardedTransport dials only addresses isPublic accepts.
|
||||
//
|
||||
// checkPublicHost resolves the host itself, and the transport then resolves it
|
||||
// again when it dials — two lookups of a name whose owner chooses the answers.
|
||||
// A record that returns a public address to the first and 127.0.0.1 to the
|
||||
// second passes the guard and reaches loopback anyway, which is the whole of
|
||||
// DNS rebinding. Re-checking at the socket closes that window: whatever the
|
||||
// second lookup returned is what Control is handed, and an address the guard
|
||||
// refuses never gets connected.
|
||||
//
|
||||
// Shared package-wide rather than built per Fetch so connections and their
|
||||
// pool survive between calls; the guard holds no state.
|
||||
var guardedTransport = newGuardedTransport()
|
||||
|
||||
func newGuardedTransport() http.RoundTripper {
|
||||
base, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
// Something replaced the default transport. Fall back to it rather
|
||||
// than dropping its behaviour, and rely on checkPublicHost alone.
|
||||
return http.DefaultTransport
|
||||
}
|
||||
// Cloned so proxy settings, TLS defaults and timeouts come from the
|
||||
// standard transport rather than being restated here.
|
||||
transport := base.Clone()
|
||||
dialer := &net.Dialer{
|
||||
Timeout: fetchTimeout,
|
||||
KeepAlive: 30 * time.Second,
|
||||
Control: func(_, address string, _ syscall.RawConn) error {
|
||||
return guardDialAddress(address)
|
||||
},
|
||||
}
|
||||
transport.DialContext = dialer.DialContext
|
||||
return transport
|
||||
}
|
||||
|
||||
// guardDialAddress refuses a resolved socket address the discovery client has
|
||||
// no business connecting to. Control hands it over post-resolution and
|
||||
// pre-connect, once per address the dialer tries, so a name with several A
|
||||
// records is checked at each one.
|
||||
func guardDialAddress(address string) error {
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("discovery dial address %q is unreadable", address)
|
||||
}
|
||||
addr, err := netip.ParseAddr(host)
|
||||
if err != nil {
|
||||
// Control is documented to receive a resolved address; anything else
|
||||
// is a state we cannot vet, so it does not get dialled.
|
||||
return fmt.Errorf("discovery dial address %q is not an IP", host)
|
||||
}
|
||||
if !isPublic(addr) {
|
||||
// Deliberately not ErrPrivateHost, which means "this upstream is on a
|
||||
// private network, so we cannot check it" and lets a save through
|
||||
// unchecked. checkPublicHost has already cleared the target by the
|
||||
// time anything is dialled, so an address refused here is not the
|
||||
// operator's upstream: it is a rebinding attempt, or an HTTP proxy in
|
||||
// the path. Neither may quietly skip the check — one is hostile, and
|
||||
// the other would silently disable this on every provider.
|
||||
return fmt.Errorf("discovery refused to dial non-public address %s", addr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
package modeldiscovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing"
|
||||
)
|
||||
|
||||
// stubTransport answers every request with one canned response and records the
|
||||
// request it was given, so a test can assert on the URL and headers the client
|
||||
// built without a network round trip.
|
||||
type stubTransport struct {
|
||||
status int
|
||||
body string
|
||||
got *http.Request
|
||||
}
|
||||
|
||||
func (s *stubTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
s.got = req
|
||||
status := s.status
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Body: io.NopCloser(strings.NewReader(s.body)),
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// newStubClient returns a client that never leaves the process. The host guard
|
||||
// is disabled because it would otherwise resolve the vendor's real name, which
|
||||
// would make these tests depend on DNS.
|
||||
func newStubClient(status int, body string) (*Client, *stubTransport) {
|
||||
tr := &stubTransport{status: status, body: body}
|
||||
return &Client{
|
||||
HTTPClient: &http.Client{Transport: tr},
|
||||
AllowPrivateHosts: true,
|
||||
}, tr
|
||||
}
|
||||
|
||||
// The payloads below are trimmed from what the vendors actually returned in
|
||||
// the discovery e2e, rather than invented, so a parser that only works against
|
||||
// an idealised shape fails here.
|
||||
|
||||
const openAIListing = `{"object":"list","data":[
|
||||
{"id":"gpt-4o-mini","object":"model","created":1721172741,"owned_by":"system"},
|
||||
{"id":"gpt-4o","object":"model","created":1715367049,"owned_by":"system"}
|
||||
]}`
|
||||
|
||||
const agentgatewayListing = `{"object":"list","data":[
|
||||
{"id":"gpt-4o-mini","object":"model","created":1785166485,"owned_by":"openai"},
|
||||
{"id":"claude-haiku-4-5","object":"model","created":1785166485,"owned_by":"anthropic"},
|
||||
{"id":"openai/*","object":"model","created":1785166485,"owned_by":"openai"},
|
||||
{"id":"*-latest","object":"model","created":1785166485,"owned_by":"openai"}
|
||||
]}`
|
||||
|
||||
const anthropicListing = `{"data":[
|
||||
{"type":"model","id":"claude-haiku-4-5-20251001","display_name":"Claude Haiku 4.5"},
|
||||
{"type":"model","id":"claude-sonnet-4-6","display_name":"Claude Sonnet 4.6"}
|
||||
],"has_more":false}`
|
||||
|
||||
const bedrockListing = `{"inferenceProfileSummaries":[
|
||||
{"inferenceProfileId":"eu.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
"inferenceProfileName":"EU Anthropic Claude Haiku 4.5","status":"ACTIVE","type":"SYSTEM_DEFINED"},
|
||||
{"inferenceProfileId":"global.cohere.embed-v4:0",
|
||||
"inferenceProfileName":"Global Cohere Embed v4","status":"ACTIVE","type":"SYSTEM_DEFINED"},
|
||||
{"inferenceProfileId":"eu.meta.llama3-2-1b-instruct-v1:0",
|
||||
"inferenceProfileName":"EU Meta Llama 3.2 1B","status":"INACTIVE","type":"SYSTEM_DEFINED"}
|
||||
]}`
|
||||
|
||||
const vertexListing = `{"publisherModels":[
|
||||
{"name":"publishers/anthropic/models/claude-3-opus","versionId":"20240229","launchStage":"GA"},
|
||||
{"name":"publishers/anthropic/models/claude-sonnet-4-5","versionId":"20250929","launchStage":"GA"}
|
||||
]}`
|
||||
|
||||
func TestFetchOpenAIListing(t *testing.T) {
|
||||
cl, tr := newStubClient(http.StatusOK, openAIListing)
|
||||
|
||||
models, err := cl.Fetch(context.Background(), Request{
|
||||
CatalogID: "openai_api",
|
||||
UpstreamURL: "https://api.openai.com",
|
||||
APIKey: "sk-test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "https://api.openai.com/v1/models", tr.got.URL.String())
|
||||
assert.Equal(t, "Bearer sk-test", tr.got.Header.Get("Authorization"),
|
||||
"the credential must be injected through the catalog's auth template")
|
||||
assert.Equal(t, []string{"gpt-4o-mini", "gpt-4o"}, ids(models))
|
||||
for _, m := range models {
|
||||
assert.True(t, m.PricingKnown, "both models are in the shipped catalog: %s", m.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAgentgatewayListing(t *testing.T) {
|
||||
cl, tr := newStubClient(http.StatusOK, agentgatewayListing)
|
||||
|
||||
models, err := cl.Fetch(context.Background(), Request{
|
||||
CatalogID: "agentgateway",
|
||||
UpstreamURL: "https://gateway.example.com",
|
||||
APIKey: "virtual-key",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "https://gateway.example.com/v1/models", tr.got.URL.String())
|
||||
assert.Equal(t, "Bearer virtual-key", tr.got.Header.Get("Authorization"),
|
||||
"agentgateway model discovery must use the configured virtual key")
|
||||
assert.Equal(t, []string{"gpt-4o-mini", "claude-haiku-4-5"}, ids(models),
|
||||
"model patterns must not be offered as exact NetBird authorization rows")
|
||||
for _, m := range models {
|
||||
assert.True(t, m.PricingKnown, "known upstream model must use NetBird catalog pricing: %s", m.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAnthropicSendsTheVersionHeader(t *testing.T) {
|
||||
cl, tr := newStubClient(http.StatusOK, anthropicListing)
|
||||
|
||||
models, err := cl.Fetch(context.Background(), Request{
|
||||
CatalogID: "anthropic_api",
|
||||
UpstreamURL: "https://api.anthropic.com",
|
||||
APIKey: "sk-ant-test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Anthropic rejects a request without the version header, so a listing
|
||||
// that reached us at all proves it was sent — but assert it, because the
|
||||
// failure mode otherwise only shows up against the live API.
|
||||
assert.Equal(t, "2023-06-01", tr.got.Header.Get("anthropic-version"))
|
||||
assert.Equal(t, "sk-ant-test", tr.got.Header.Get("x-api-key"),
|
||||
"Anthropic takes a bare key under its own header, not a Bearer token")
|
||||
assert.Equal(t, "limit=1000", tr.got.URL.RawQuery)
|
||||
|
||||
assert.Equal(t, []string{"claude-haiku-4-5-20251001", "claude-sonnet-4-6"}, ids(models))
|
||||
assert.Equal(t, "Claude Haiku 4.5", models[0].Label)
|
||||
}
|
||||
|
||||
func TestFetchBedrockUsesTheControlPlaneAndKeepsWireIDs(t *testing.T) {
|
||||
cl, tr := newStubClient(http.StatusOK, bedrockListing)
|
||||
|
||||
models, err := cl.Fetch(context.Background(), Request{
|
||||
CatalogID: "bedrock_api",
|
||||
// The record's upstream is the RUNTIME host, which does not serve
|
||||
// listings. The catalog's own discovery host must win over it.
|
||||
UpstreamURL: "https://bedrock-runtime.eu-central-1.amazonaws.com",
|
||||
Region: "eu-central-1",
|
||||
APIKey: "aws-bearer",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "https://bedrock.eu-central-1.amazonaws.com/inference-profiles",
|
||||
tr.got.URL.String(), "listings come from the control plane, not the runtime host")
|
||||
|
||||
// Region-prefixed ids verbatim: the prefix is what makes them invocable
|
||||
// and it cannot be reconstructed — global.* alongside eu.* is exactly the
|
||||
// case that defeats deriving it from the configured region.
|
||||
assert.Equal(t, []string{
|
||||
"eu.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
"global.cohere.embed-v4:0",
|
||||
}, ids(models), "an INACTIVE profile must not be offered")
|
||||
|
||||
assert.True(t, models[0].PricingKnown,
|
||||
"the catalog prices anthropic.claude-haiku-4-5, which this id normalises to")
|
||||
assert.False(t, models[1].PricingKnown,
|
||||
"cohere embed is not in the shipped Bedrock catalog, so the operator must price it")
|
||||
|
||||
// The rates travel with the model, so the form can prefill an editable row
|
||||
// rather than making the operator look every price up by hand.
|
||||
assert.Positive(t, models[0].InputPer1k, "a priced model must carry its input rate")
|
||||
assert.Positive(t, models[0].OutputPer1k, "a priced model must carry its output rate")
|
||||
// An unpriced model is offered at zero and flagged, not withheld: the
|
||||
// vendor says the credential can reach it.
|
||||
assert.Zero(t, models[1].InputPer1k)
|
||||
assert.Zero(t, models[1].OutputPer1k)
|
||||
}
|
||||
|
||||
// TestDiscoveredRatesMatchTheCatalogEndpoint pins the two prefill paths to one
|
||||
// table. The provider form fills a model row either from the catalog response
|
||||
// or from a discovery response, and an operator who switches between them must
|
||||
// not see the price change — both must equal what the proxy will bill.
|
||||
func TestDiscoveredRatesMatchTheCatalogEndpoint(t *testing.T) {
|
||||
cl, _ := newStubClient(http.StatusOK, openAIListing)
|
||||
|
||||
models, err := cl.Fetch(context.Background(), Request{
|
||||
CatalogID: "openai_api",
|
||||
UpstreamURL: "https://api.openai.com",
|
||||
APIKey: "sk-test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, models)
|
||||
|
||||
entry, ok := catalog.Lookup("openai_api")
|
||||
require.True(t, ok)
|
||||
|
||||
for _, m := range models {
|
||||
want, known := pricing.LookupDefault(entry.PricingSurfaces, m.ID)
|
||||
require.True(t, known, "%s should be priced by the default table", m.ID)
|
||||
assert.Equal(t, want.InputPer1k, m.InputPer1k, "input rate for %s", m.ID)
|
||||
assert.Equal(t, want.OutputPer1k, m.OutputPer1k, "output rate for %s", m.ID)
|
||||
assert.Equal(t, want.CachedInputPer1k, m.CachedInputPer1k, "cached-input rate for %s", m.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchVertexJoinsNameAndVersion(t *testing.T) {
|
||||
cl, _ := newStubClient(http.StatusOK, vertexListing)
|
||||
|
||||
models, err := cl.Fetch(context.Background(), Request{
|
||||
CatalogID: "vertex_ai_api",
|
||||
UpstreamURL: "https://us-east5-aiplatform.googleapis.com",
|
||||
Region: "us-east5",
|
||||
APIKey: "ya29.test-token",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Vertex addresses a model as "<id>@<version>" on rawPredict, and splits
|
||||
// those across two fields in the listing.
|
||||
assert.Equal(t, []string{"claude-3-opus@20240229", "claude-sonnet-4-5@20250929"}, ids(models))
|
||||
assert.Equal(t, "claude-3-opus", models[0].Label)
|
||||
}
|
||||
|
||||
func TestFetchSurfacesTheVendorStatus(t *testing.T) {
|
||||
cl, _ := newStubClient(http.StatusForbidden, `{"error":{"message":"no access"}}`)
|
||||
|
||||
_, err := cl.Fetch(context.Background(), Request{
|
||||
CatalogID: "openai_api",
|
||||
UpstreamURL: "https://api.openai.com",
|
||||
APIKey: "sk-test",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "403",
|
||||
"an operator whose key lacks access needs to see which status the vendor returned")
|
||||
}
|
||||
|
||||
func TestFetchRejectsAProviderWithoutDiscovery(t *testing.T) {
|
||||
cl, _ := newStubClient(http.StatusOK, openAIListing)
|
||||
|
||||
_, err := cl.Fetch(context.Background(), Request{
|
||||
CatalogID: "litellm_proxy",
|
||||
UpstreamURL: "https://gateway.example.com",
|
||||
APIKey: "sk-test",
|
||||
})
|
||||
assert.ErrorIs(t, err, ErrNoDiscovery,
|
||||
"a gateway with no listing endpoint must be distinguishable from a failure, so the caller can fall back")
|
||||
}
|
||||
|
||||
func TestFetchRequiresACredential(t *testing.T) {
|
||||
cl, _ := newStubClient(http.StatusOK, openAIListing)
|
||||
|
||||
_, err := cl.Fetch(context.Background(), Request{
|
||||
CatalogID: "openai_api",
|
||||
UpstreamURL: "https://api.openai.com",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "API key")
|
||||
}
|
||||
|
||||
func TestDiscoveryURLNeedsARegionWhenTheHostTemplatesOne(t *testing.T) {
|
||||
cl, _ := newStubClient(http.StatusOK, bedrockListing)
|
||||
|
||||
// An upstream that matches no catalog template — a proxy in front of
|
||||
// Bedrock, say — leaves nothing to read the region from. Refusing beats
|
||||
// guessing: an unsubstituted placeholder would dial a host that does not
|
||||
// exist, and a guessed region would dial the wrong account's endpoint.
|
||||
_, err := cl.Fetch(context.Background(), Request{
|
||||
CatalogID: "bedrock_api",
|
||||
UpstreamURL: "https://bedrock.internal-proxy.example.com",
|
||||
APIKey: "aws-bearer",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "region")
|
||||
}
|
||||
|
||||
// TestHostGuardRejectsNonPublicAddresses is the SSRF guard. Management holds a
|
||||
// credential for every provider, so an upstream pointed at an internal address
|
||||
// would turn discovery into a way to probe — and hand a token to — the
|
||||
// management server's own network.
|
||||
func TestHostGuardRejectsNonPublicAddresses(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
addr string
|
||||
want bool
|
||||
}{
|
||||
{"loopback v4", "127.0.0.1", false},
|
||||
{"loopback v6", "::1", false},
|
||||
{"private 10/8", "10.0.0.5", false},
|
||||
{"private 172.16/12", "172.16.4.1", false},
|
||||
{"private 192.168/16", "192.168.1.1", false},
|
||||
{"link-local", "169.254.169.254", false}, // cloud metadata
|
||||
{"unspecified", "0.0.0.0", false},
|
||||
{"multicast", "224.0.0.1", false},
|
||||
{"netbird overlay 100.64/10", "100.90.1.2", false},
|
||||
{"v4-mapped loopback", "::ffff:127.0.0.1", false},
|
||||
{"public v4", "1.1.1.1", true},
|
||||
{"public v6", "2606:4700:4700::1111", true},
|
||||
{"just outside CGNAT", "100.128.0.1", true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
addr, err := netip.ParseAddr(tc.addr)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.want, isPublic(addr))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostGuardResolvesAndRejectsLocalhost(t *testing.T) {
|
||||
cl := &Client{}
|
||||
err := cl.checkPublicHost(context.Background(), "localhost")
|
||||
require.Error(t, err, "a name resolving to loopback must be refused, not just a literal address")
|
||||
assert.Contains(t, err.Error(), "non-public")
|
||||
}
|
||||
|
||||
// TestRedirectsAreNotFollowed covers a gap the other tests leave open: they all
|
||||
// inject an HTTPClient, which bypasses httpClient() and therefore the redirect
|
||||
// policy entirely. The policy is a security control — a 302 moves the request
|
||||
// to a host checkPublicHost never resolved — so it needs a test that goes
|
||||
// through the constructor the manager actually uses.
|
||||
func TestRedirectsAreNotFollowed(t *testing.T) {
|
||||
var hits int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
http.Redirect(w, r, "http://169.254.169.254/latest/meta-data/", http.StatusFound)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
for name, cl := range map[string]*Client{
|
||||
// The production shape: no injected client at all.
|
||||
"default client": {AllowPrivateHosts: true},
|
||||
// An injected client that states no policy must inherit ours rather
|
||||
// than silently chasing the redirect.
|
||||
"injected client with no policy": {
|
||||
AllowPrivateHosts: true,
|
||||
HTTPClient: &http.Client{},
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
hits = 0
|
||||
req, err := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := cl.httpClient().Do(req)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = resp.Body.Close() })
|
||||
|
||||
assert.Equal(t, http.StatusFound, resp.StatusCode,
|
||||
"the redirect must be surfaced, not followed to an unchecked host")
|
||||
assert.Equal(t, 1, hits, "exactly one request must leave the client")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInjectedClientKeepsItsOwnRedirectPolicy pins that the default above is a
|
||||
// default, not an override, and that supplying it does not mutate the caller's
|
||||
// client — one Client is shared across every request, so a write here would
|
||||
// race.
|
||||
func TestInjectedClientKeepsItsOwnRedirectPolicy(t *testing.T) {
|
||||
own := func(*http.Request, []*http.Request) error { return nil }
|
||||
injected := &http.Client{CheckRedirect: own}
|
||||
cl := &Client{HTTPClient: injected}
|
||||
|
||||
assert.Same(t, injected, cl.httpClient(),
|
||||
"a client that states a policy must be handed back untouched")
|
||||
|
||||
bare := &http.Client{}
|
||||
cl = &Client{HTTPClient: bare}
|
||||
require.NotSame(t, bare, cl.httpClient(), "the policy must be applied to a copy")
|
||||
assert.Nil(t, bare.CheckRedirect, "the caller's client must not be written to")
|
||||
}
|
||||
|
||||
// TestDialGuardRejectsRebindingToANonPublicAddress covers the window between
|
||||
// the two DNS lookups. checkPublicHost resolves the host, then the transport
|
||||
// resolves it again to dial; a name whose owner answers the first with a public
|
||||
// address and the second with 127.0.0.1 would otherwise pass the guard and
|
||||
// still reach loopback. The dial-time check sees whatever the second lookup
|
||||
// actually returned.
|
||||
func TestDialGuardRejectsRebindingToANonPublicAddress(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
address string
|
||||
wantErr string
|
||||
}{
|
||||
{"loopback", "127.0.0.1:443", "non-public"},
|
||||
{"cloud metadata", "169.254.169.254:80", "non-public"},
|
||||
{"rfc1918", "10.1.2.3:443", "non-public"},
|
||||
{"netbird overlay", "100.90.1.2:443", "non-public"},
|
||||
{"loopback v6", "[::1]:443", "non-public"},
|
||||
{"unresolved name", "evil.example.com:443", "not an IP"},
|
||||
{"no port", "1.1.1.1", "unreadable"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := guardDialAddress(tc.address)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tc.wantErr)
|
||||
})
|
||||
}
|
||||
|
||||
assert.NoError(t, guardDialAddress("1.1.1.1:443"), "a public address must still be dialled")
|
||||
assert.NoError(t, guardDialAddress("[2606:4700:4700::1111]:443"))
|
||||
}
|
||||
|
||||
// TestDialGuardIsInstalledOnTheDefaultClient pins the wiring rather than the
|
||||
// guard: a correct guard nothing calls protects nothing.
|
||||
func TestDialGuardIsInstalledOnTheDefaultClient(t *testing.T) {
|
||||
cl := &Client{}
|
||||
transport, ok := cl.httpClient().Transport.(*http.Transport)
|
||||
require.True(t, ok, "the default discovery client must carry the guarded transport")
|
||||
require.NotNil(t, transport.DialContext, "the guarded transport must dial through the guard")
|
||||
|
||||
_, err := transport.DialContext(context.Background(), "tcp", "127.0.0.1:9")
|
||||
require.Error(t, err, "the guard must refuse loopback even when the caller dials it directly")
|
||||
assert.Contains(t, err.Error(), "non-public")
|
||||
|
||||
// Tests point the client at a loopback server on purpose, so the opt-out
|
||||
// has to reach the dialer too.
|
||||
relaxed := &Client{AllowPrivateHosts: true}
|
||||
assert.Equal(t, http.DefaultTransport, relaxed.httpClient().Transport)
|
||||
}
|
||||
|
||||
// TestCallerInputFailuresAreMarkedInvalid keeps the handler's 400 mapping
|
||||
// honest: it branches on this sentinel, so an unmarked caller-input failure
|
||||
// silently becomes a 500.
|
||||
func TestCallerInputFailuresAreMarkedInvalid(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
req Request
|
||||
}{
|
||||
{"unknown provider", Request{CatalogID: "not_a_provider", APIKey: "k"}},
|
||||
{"unusable upstream", Request{CatalogID: "openai_api", UpstreamURL: "://", APIKey: "k"}},
|
||||
{"missing api key", Request{CatalogID: "openai_api", UpstreamURL: "https://api.openai.com"}},
|
||||
{"no region to read", Request{
|
||||
CatalogID: "bedrock_api",
|
||||
UpstreamURL: "https://bedrock-runtime.amazonaws.com",
|
||||
APIKey: "aws-bearer",
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cl, _ := newStubClient(http.StatusOK, openAIListing)
|
||||
_, err := cl.Fetch(context.Background(), tc.req)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrInvalidRequest)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEveryDiscoveryEntryHasAParser keeps the catalog and the parser table from
|
||||
// drifting: adding a Discovery block with a shape nothing parses would fail
|
||||
// only at runtime, in front of an operator.
|
||||
func TestEveryDiscoveryEntryHasAParser(t *testing.T) {
|
||||
for _, entry := range catalog.All() {
|
||||
if entry.Discovery == nil {
|
||||
continue
|
||||
}
|
||||
t.Run(entry.ID, func(t *testing.T) {
|
||||
assert.NotEmpty(t, entry.Discovery.Path, "a discovery entry needs a path")
|
||||
_, err := parseListing(entry.Discovery.Shape, []byte(`{}`))
|
||||
assert.NoError(t, err, "shape %q has no parser", entry.Discovery.Shape)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func ids(models []Model) []string {
|
||||
out := make([]string, 0, len(models))
|
||||
for _, m := range models {
|
||||
out = append(out, m.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestRegionIsReadBackFromTheUpstream covers the reason the API takes no
|
||||
// region field: a provider record has none, and the operator already encoded
|
||||
// it in the upstream host when they configured inference.
|
||||
func TestRegionIsReadBackFromTheUpstream(t *testing.T) {
|
||||
cl, tr := newStubClient(http.StatusOK, bedrockListing)
|
||||
|
||||
_, err := cl.Fetch(context.Background(), Request{
|
||||
CatalogID: "bedrock_api",
|
||||
UpstreamURL: "https://bedrock-runtime.us-west-2.amazonaws.com",
|
||||
APIKey: "aws-bearer",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "bedrock.us-west-2.amazonaws.com", tr.got.URL.Host)
|
||||
}
|
||||
|
||||
func TestRegionFromUpstream(t *testing.T) {
|
||||
bedrock, ok := catalog.Lookup("bedrock_api")
|
||||
require.True(t, ok)
|
||||
vertex, ok := catalog.Lookup("vertex_ai_api")
|
||||
require.True(t, ok)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
entry catalog.Provider
|
||||
upstream string
|
||||
want string
|
||||
}{
|
||||
{"bedrock runtime host", bedrock, "https://bedrock-runtime.eu-central-1.amazonaws.com", "eu-central-1"},
|
||||
{"bedrock without scheme", bedrock, "bedrock-runtime.ap-south-1.amazonaws.com", "ap-south-1"},
|
||||
{"vertex regional host", vertex, "https://us-east5-aiplatform.googleapis.com", "us-east5"},
|
||||
// A proxied or self-hosted upstream matches no template, and guessing
|
||||
// a region from it would build a URL pointing somewhere arbitrary.
|
||||
{"unrelated upstream", bedrock, "https://llm.internal.example.com", ""},
|
||||
{"vertex global host has no region segment", vertex, "https://aiplatform.googleapis.com", ""},
|
||||
// Bedrock's regionless endpoint carries both halves of the template at
|
||||
// once, with nothing between them. It has to read as "no region here"
|
||||
// rather than as an inverted slice range.
|
||||
{"bedrock regionless endpoint", bedrock, "https://bedrock-runtime.amazonaws.com", ""},
|
||||
{"bedrock regionless without scheme", bedrock, "bedrock-runtime.amazonaws.com", ""},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, RegionFromUpstream(tc.entry, tc.upstream))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// bedrockGeoListing carries profiles from geographies the original prefix list
|
||||
// did not name. Every one reduces to a catalog key, so every one must arrive
|
||||
// priced — an unstripped geography is what made a real account's listing come
|
||||
// back almost entirely at zero.
|
||||
const bedrockGeoListing = `{"inferenceProfileSummaries":[
|
||||
{"inferenceProfileId":"jp.anthropic.claude-sonnet-5-20260514-v1:0",
|
||||
"inferenceProfileName":"JP Anthropic Claude Sonnet 5","status":"ACTIVE","type":"SYSTEM_DEFINED"},
|
||||
{"inferenceProfileId":"au.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
"inferenceProfileName":"AU Anthropic Claude Haiku 4.5","status":"ACTIVE","type":"SYSTEM_DEFINED"},
|
||||
{"inferenceProfileId":"us-gov.anthropic.claude-sonnet-5-20260514-v1:0",
|
||||
"inferenceProfileName":"GovCloud Anthropic Claude Sonnet 5","status":"ACTIVE","type":"SYSTEM_DEFINED"}
|
||||
]}`
|
||||
|
||||
func TestBedrockProfilesFromAnyGeographyArrivePriced(t *testing.T) {
|
||||
cl, _ := newStubClient(http.StatusOK, bedrockGeoListing)
|
||||
|
||||
models, err := cl.Fetch(context.Background(), Request{
|
||||
CatalogID: "bedrock_api",
|
||||
UpstreamURL: "https://bedrock-runtime.eu-central-1.amazonaws.com",
|
||||
APIKey: "aws-token",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, models, 3)
|
||||
|
||||
for _, m := range models {
|
||||
assert.True(t, m.PricingKnown, "%s must resolve to a catalog rate", m.ID)
|
||||
assert.Greater(t, m.InputPer1k, 0.0, "input rate for %s", m.ID)
|
||||
assert.Greater(t, m.OutputPer1k, 0.0, "output rate for %s", m.ID)
|
||||
assert.Greater(t, m.CacheReadPer1k, 0.0, "cache-read rate for %s", m.ID)
|
||||
}
|
||||
|
||||
// The wire id is preserved whatever the pricing key reduced to: it is the
|
||||
// only form that works at invoke time.
|
||||
assert.Equal(t, "jp.anthropic.claude-sonnet-5-20260514-v1:0", models[0].ID)
|
||||
}
|
||||
|
||||
// TestFetch_AHostThatWillNotResolveIsUnreachable closes a gap the live suite
|
||||
// found. The SSRF guard resolves the host before any request is built, so a
|
||||
// name that does not resolve fails there rather than at the transport — and
|
||||
// that error used to reach the caller unclassified. A wrong hostname is the
|
||||
// commonest way for an upstream to be wrong, so it has to arrive as
|
||||
// "unreachable" and not as an unrecognised fault.
|
||||
func TestFetch_AHostThatWillNotResolveIsUnreachable(t *testing.T) {
|
||||
// A resolver whose dial always fails, so the lookup errors without the
|
||||
// test depending on real DNS.
|
||||
refusing := &net.Resolver{
|
||||
PreferGo: true,
|
||||
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return nil, errors.New("resolver unavailable")
|
||||
},
|
||||
}
|
||||
client := &Client{Resolver: refusing}
|
||||
|
||||
_, err := client.Fetch(context.Background(), Request{
|
||||
CatalogID: "openai_api",
|
||||
UpstreamURL: "https://not-a-real-vendor-host.example.invalid",
|
||||
APIKey: "sk-test",
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
var unreachable *UnreachableError
|
||||
require.ErrorAs(t, err, &unreachable, "a host that will not resolve must classify as unreachable")
|
||||
require.NotErrorIs(t, err, ErrPrivateHost, "it is not a host we declined to dial")
|
||||
}
|
||||
|
||||
// TestFetch_AProxyInThePathDoesNotSilentlyDisableTheCheck pins a fail-open the
|
||||
// dial-time guard can produce. checkPublicHost clears the target before
|
||||
// anything is dialled, so a private address refused at the socket is never the
|
||||
// operator's upstream — it is a rebinding attempt, or an HTTP proxy the
|
||||
// management server egresses through. Reporting either as ErrPrivateHost would
|
||||
// read as "this provider cannot be checked" and let every save through
|
||||
// unchecked, which is how a proxied deployment would install this feature and
|
||||
// have it quietly do nothing.
|
||||
func TestFetch_AProxyInThePathDoesNotSilentlyDisableTheCheck(t *testing.T) {
|
||||
// A transport that refuses at the socket exactly as the guard does, with a
|
||||
// loopback address standing in for the proxy the dial went to.
|
||||
// AllowPrivateHosts short-circuits the resolve-stage check only; the
|
||||
// injected transport below is still what the request goes through. Without
|
||||
// it this test resolves api.openai.com for real, and on a runner with no
|
||||
// egress that lookup fails as an UnreachableError too — so it would pass
|
||||
// while never reaching the socket guard it is named for.
|
||||
client := &Client{AllowPrivateHosts: true, HTTPClient: &http.Client{
|
||||
Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return nil, guardDialAddress("127.0.0.1:38599")
|
||||
}),
|
||||
CheckRedirect: refuseRedirect,
|
||||
}}
|
||||
|
||||
_, err := client.Fetch(context.Background(), Request{
|
||||
CatalogID: "openai_api",
|
||||
UpstreamURL: "https://api.openai.com",
|
||||
APIKey: "sk-test",
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
require.NotErrorIs(t, err, ErrPrivateHost,
|
||||
"a refusal at the socket must not read as an upstream we cannot check")
|
||||
var unreachable *UnreachableError
|
||||
require.ErrorAs(t, err, &unreachable, "it is the vendor we failed to reach")
|
||||
}
|
||||
|
||||
// roundTripFunc adapts a function to http.RoundTripper.
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||
|
||||
// TestFetch_TheUpstreamIsCheckedWhenTheListingCannotVouchForIt covers the hole
|
||||
// a separate listing host leaves. Bedrock lists from the control plane, so a
|
||||
// record whose runtime upstream does not exist reaches a perfectly good
|
||||
// listing and saves — the requests it then serves go nowhere.
|
||||
//
|
||||
// Both halves matter. A runtime host that cannot be resolved is the record
|
||||
// being wrong, and blocks. A proxied one resolves and only leaves the region
|
||||
// underivable, which stays the unverifiable outcome it already was.
|
||||
func TestFetch_TheUpstreamIsCheckedWhenTheListingCannotVouchForIt(t *testing.T) {
|
||||
refusing := &net.Resolver{
|
||||
PreferGo: true,
|
||||
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return nil, errors.New("resolver unavailable")
|
||||
},
|
||||
}
|
||||
client := &Client{Resolver: refusing}
|
||||
|
||||
_, err := client.Fetch(context.Background(), Request{
|
||||
CatalogID: "bedrock_api",
|
||||
// Matches no catalog template, so nothing here reaches the control
|
||||
// plane the listing comes from: without its own check this upstream
|
||||
// was never contacted at all.
|
||||
UpstreamURL: "https://bedrock.typo.example.invalid",
|
||||
APIKey: "aws-bearer",
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
var unreachable *UnreachableError
|
||||
require.ErrorAs(t, err, &unreachable, "a runtime host that will not resolve must block the save")
|
||||
}
|
||||
|
||||
// TestFetch_AListingHostOfItsOwnDoesNotReachThroughTheUpstream keeps the check
|
||||
// above from reading the operator's upstream as the place to list from.
|
||||
func TestFetch_AListingHostOfItsOwnDoesNotReachThroughTheUpstream(t *testing.T) {
|
||||
cl, tr := newStubClient(http.StatusOK, bedrockListing)
|
||||
|
||||
_, err := cl.Fetch(context.Background(), Request{
|
||||
CatalogID: "bedrock_api",
|
||||
UpstreamURL: "https://bedrock-runtime.eu-central-1.amazonaws.com",
|
||||
APIKey: "aws-bearer",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "bedrock.eu-central-1.amazonaws.com", tr.got.URL.Host,
|
||||
"checking the runtime host must not turn it into the listing host")
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package modeldiscovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// Fetch serves two callers with different needs: the model picker, which only
|
||||
// needs to know it failed, and the provider credential check, which has to
|
||||
// tell an operator whether the URL or the key is at fault. Each failure
|
||||
// carries a type so the second does not have to branch on a message.
|
||||
|
||||
// VendorStatusError reports a listing answered with something other than 200.
|
||||
// Only the vendor's own code separates a refused credential (401, 403) from a
|
||||
// URL that does not serve this API (404, 405) from an unwell vendor (5xx).
|
||||
type VendorStatusError struct {
|
||||
Provider string
|
||||
Status int
|
||||
}
|
||||
|
||||
func (e *VendorStatusError) Error() string {
|
||||
return fmt.Sprintf("%s returned %d for its model listing", e.Provider, e.Status)
|
||||
}
|
||||
|
||||
// UnreachableError reports that the request never reached the vendor: the
|
||||
// name did not resolve, the connection was refused, TLS failed, or it timed
|
||||
// out. Nothing was authenticated, so only the URL is implicated.
|
||||
type UnreachableError struct {
|
||||
Provider string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *UnreachableError) Error() string {
|
||||
return fmt.Sprintf("reach %s: %v", e.Provider, e.Err)
|
||||
}
|
||||
|
||||
func (e *UnreachableError) Unwrap() error { return e.Err }
|
||||
|
||||
// Reason names the transport failure in words an operator can act on: a wrong
|
||||
// port and a wrong hostname fail differently and are worth telling apart.
|
||||
// Empty means unrecognised, and the caller should say only that the host could
|
||||
// not be reached rather than paste a Go error into the UI.
|
||||
func (e *UnreachableError) Reason() string {
|
||||
err := e.Err
|
||||
|
||||
var dns *net.DNSError
|
||||
if errors.As(err, &dns) {
|
||||
if dns.IsNotFound {
|
||||
return "no such host"
|
||||
}
|
||||
// Named apart from the dial timeout below. A resolver that never
|
||||
// answered and an upstream that never answered send an operator to
|
||||
// different places, and the generic "connection timed out" would
|
||||
// describe a connection that was never attempted.
|
||||
if dns.IsTimeout {
|
||||
return "dns lookup timed out"
|
||||
}
|
||||
return "dns lookup failed"
|
||||
}
|
||||
|
||||
// Timeouts are checked before the syscall cases: a dial that times out is
|
||||
// reported as a net.OpError wrapping a timeout, and the operator needs to
|
||||
// hear "timed out" rather than the syscall underneath it.
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, os.ErrDeadlineExceeded) {
|
||||
return "connection timed out"
|
||||
}
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||
return "connection timed out"
|
||||
}
|
||||
|
||||
if errors.Is(err, syscall.ECONNREFUSED) {
|
||||
return "connection refused"
|
||||
}
|
||||
if errors.Is(err, syscall.EHOSTUNREACH) || errors.Is(err, syscall.ENETUNREACH) {
|
||||
return "host unreachable"
|
||||
}
|
||||
|
||||
var certErr *tls.CertificateVerificationError
|
||||
if errors.As(err, &certErr) {
|
||||
return "tls certificate not trusted"
|
||||
}
|
||||
var recordErr tls.RecordHeaderError
|
||||
if errors.As(err, &recordErr) {
|
||||
return "not a tls endpoint"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// ErrUnparseableListing marks a 200 whose body is not a listing in the shape
|
||||
// the catalog declared. Distinct from a status refusal: the host answered and
|
||||
// authenticated fine, it is just not the API — a login page, say.
|
||||
var ErrUnparseableListing = errors.New("response is not a model listing")
|
||||
|
||||
// ErrNoDiscoveryHost marks a provider whose listing host cannot be derived
|
||||
// from the record: Bedrock's control-plane host comes from the region in the
|
||||
// upstream, so a proxied endpoint leaves nowhere to send it, and inventing one
|
||||
// would spend the credential somewhere never configured.
|
||||
//
|
||||
// Wraps ErrInvalidRequest so the discovery endpoint still answers 400, while a
|
||||
// credential check can read it as "cannot be checked" rather than "broken".
|
||||
var ErrNoDiscoveryHost = errors.New("provider has no derivable discovery host")
|
||||
|
||||
// ErrPrivateHost marks an upstream resolving somewhere management will not
|
||||
// dial. A self-hosted endpoint on a private network is a legitimate provider
|
||||
// the proxy reaches through the tunnel, so this means the check cannot run,
|
||||
// not that the record is wrong.
|
||||
var ErrPrivateHost = errors.New("discovery host is not publicly routable")
|
||||
@@ -0,0 +1,134 @@
|
||||
package modeldiscovery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
|
||||
sharedllm "github.com/netbirdio/netbird/shared/llm"
|
||||
)
|
||||
|
||||
// listedModel is one entry lifted out of a vendor listing before the catalog
|
||||
// is consulted about it.
|
||||
type listedModel struct {
|
||||
id string
|
||||
label string
|
||||
}
|
||||
|
||||
// parseListing extracts model ids from a vendor listing. Each vendor invented
|
||||
// its own envelope, and the shape is declared by the catalog rather than
|
||||
// sniffed, so a vendor that changes shape fails loudly instead of silently
|
||||
// returning nothing.
|
||||
func parseListing(shape catalog.ListingShape, body []byte) ([]listedModel, error) {
|
||||
switch shape {
|
||||
case catalog.ShapeOpenAIData:
|
||||
return parseOpenAIData(body)
|
||||
case catalog.ShapeBedrockInferenceProfiles:
|
||||
return parseBedrockInferenceProfiles(body)
|
||||
case catalog.ShapeVertexPublisherModels:
|
||||
return parseVertexPublisherModels(body)
|
||||
default:
|
||||
return nil, fmt.Errorf("no parser for listing shape %q", shape)
|
||||
}
|
||||
}
|
||||
|
||||
// parseOpenAIData reads {"data":[{"id":…}]}, which OpenAI defined and
|
||||
// Anthropic adopted. Anthropic additionally supplies display_name.
|
||||
func parseOpenAIData(body []byte) ([]listedModel, error) {
|
||||
var doc struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &doc); err != nil {
|
||||
return nil, fmt.Errorf("%w: decode model listing: %w", ErrUnparseableListing, err)
|
||||
}
|
||||
out := make([]listedModel, 0, len(doc.Data))
|
||||
for _, entry := range doc.Data {
|
||||
out = append(out, listedModel{id: entry.ID, label: entry.DisplayName})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// parseBedrockInferenceProfiles reads
|
||||
// {"inferenceProfileSummaries":[{"inferenceProfileId":…}]}.
|
||||
//
|
||||
// The profile id is taken verbatim because its region prefix (eu., us.,
|
||||
// global.) is what makes it invocable, and it is not derivable from the
|
||||
// configured region — an account in one region legitimately holds global.*
|
||||
// profiles alongside its regional ones.
|
||||
//
|
||||
// Only ACTIVE profiles are offered: AWS reports others, and registering one
|
||||
// would produce a model that routes inside NetBird and fails at AWS.
|
||||
func parseBedrockInferenceProfiles(body []byte) ([]listedModel, error) {
|
||||
var doc struct {
|
||||
Summaries []struct {
|
||||
ID string `json:"inferenceProfileId"`
|
||||
Name string `json:"inferenceProfileName"`
|
||||
Status string `json:"status"`
|
||||
} `json:"inferenceProfileSummaries"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &doc); err != nil {
|
||||
return nil, fmt.Errorf("%w: decode inference-profile listing: %w", ErrUnparseableListing, err)
|
||||
}
|
||||
out := make([]listedModel, 0, len(doc.Summaries))
|
||||
for _, entry := range doc.Summaries {
|
||||
if entry.Status != "" && !strings.EqualFold(entry.Status, "ACTIVE") {
|
||||
continue
|
||||
}
|
||||
out = append(out, listedModel{id: entry.ID, label: entry.Name})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// parseVertexPublisherModels reads {"publisherModels":[{"name":…}]}, where
|
||||
// name is a resource path ("publishers/anthropic/models/claude-3-opus") and
|
||||
// the version lives in a separate field.
|
||||
//
|
||||
// Vertex addresses a model as "<id>@<version>" on the rawPredict path, so the
|
||||
// two are joined here: reporting the bare name would hand the operator an id
|
||||
// that looks usable and is not.
|
||||
func parseVertexPublisherModels(body []byte) ([]listedModel, error) {
|
||||
var doc struct {
|
||||
Models []struct {
|
||||
Name string `json:"name"`
|
||||
VersionID string `json:"versionId"`
|
||||
} `json:"publisherModels"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &doc); err != nil {
|
||||
return nil, fmt.Errorf("%w: decode publisher-model listing: %w", ErrUnparseableListing, err)
|
||||
}
|
||||
out := make([]listedModel, 0, len(doc.Models))
|
||||
for _, entry := range doc.Models {
|
||||
id := entry.Name
|
||||
if slash := strings.LastIndex(id, "/"); slash >= 0 {
|
||||
id = id[slash+1:]
|
||||
}
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
label := id
|
||||
if entry.VersionID != "" {
|
||||
id += "@" + entry.VersionID
|
||||
}
|
||||
out = append(out, listedModel{id: id, label: label})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// normalizeForPricing maps a vendor's wire id onto the key the catalog prices
|
||||
// it under. It mirrors the synthesiser's normalizePricingModelID: the two must
|
||||
// agree, or a model reported here as priced would meter at the default rate
|
||||
// instead of the operator's.
|
||||
func normalizeForPricing(catalogProviderID, modelID string) string {
|
||||
switch {
|
||||
case catalog.IsBedrockPathStyle(catalogProviderID):
|
||||
return sharedllm.NormalizeBedrockModel(modelID)
|
||||
case catalog.IsVertexPathStyle(catalogProviderID):
|
||||
return sharedllm.NormalizeVertexModel(modelID)
|
||||
default:
|
||||
return modelID
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,836 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
// validateUsageDeltas rejects negative or non-finite usage counters before they
|
||||
// reach the consumption store, so a bad delta can't decrement or poison totals.
|
||||
// The store batch method enforces the same invariant; this is the manager-level
|
||||
// guard so direct callers fail fast with a clear error.
|
||||
func validateUsageDeltas(tokensIn, tokensOut int64, costUSD float64) error {
|
||||
if tokensIn < 0 || tokensOut < 0 || costUSD < 0 || math.IsNaN(costUSD) || math.IsInf(costUSD, 0) {
|
||||
return status.Errorf(status.InvalidArgument, "usage deltas must be non-negative and finite")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deny codes the proxy surfaces back to the caller when every
|
||||
// applicable policy is exhausted. The proxy converts these into
|
||||
// upstream-shaped error responses.
|
||||
const (
|
||||
//nolint:gosec // policy deny code label, not a credential
|
||||
denyCodeTokenCapExceeded = "llm_policy.token_cap_exceeded"
|
||||
//nolint:gosec // policy deny code label, not a credential
|
||||
denyCodeBudgetCapExceeded = "llm_policy.budget_cap_exceeded"
|
||||
//nolint:gosec // account deny code label, not a credential
|
||||
denyCodeAccountTokenCapExceeded = "llm_account.token_cap_exceeded"
|
||||
//nolint:gosec // account deny code label, not a credential
|
||||
denyCodeAccountBudgetCapExceeded = "llm_account.budget_cap_exceeded"
|
||||
// denyCodeModelBlocked is returned when policies govern the request's
|
||||
// (provider, caller-groups) but none permits the model. Matches the proxy
|
||||
// guardrail's code so both layers surface the same label.
|
||||
denyCodeModelBlocked = "llm_policy.model_blocked"
|
||||
)
|
||||
|
||||
// consumptionCache holds the consumption counters prefetched for one
|
||||
// policy-selection request, keyed by ConsumptionKey. A miss returns a zero
|
||||
// counter — the same contract the store's single-row getter uses for absent
|
||||
// rows — so the eval logic is identical whether a counter exists yet or not.
|
||||
type consumptionCache map[types.ConsumptionKey]*types.Consumption
|
||||
|
||||
func (c consumptionCache) get(accountID string, kind types.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time) *types.Consumption {
|
||||
key := types.ConsumptionKey{Kind: kind, DimID: dimID, WindowSeconds: windowSeconds, WindowStartUTC: windowStart.UTC()}
|
||||
if row, ok := c[key]; ok && row != nil {
|
||||
return row
|
||||
}
|
||||
return &types.Consumption{
|
||||
AccountID: accountID,
|
||||
DimensionKind: kind,
|
||||
DimensionID: dimID,
|
||||
WindowSeconds: windowSeconds,
|
||||
WindowStartUTC: windowStart.UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
// addLimitKeys records the user/group consumption keys a single enabled (token
|
||||
// or budget) limit window reads for the given attribution group, into a dedup
|
||||
// set. attrGroup may be empty (no group dimension applies).
|
||||
func addLimitKeys(set map[types.ConsumptionKey]struct{}, userID, attrGroup string, windowSeconds int64, now time.Time) {
|
||||
if windowSeconds <= 0 {
|
||||
return
|
||||
}
|
||||
ws := types.WindowStart(now, windowSeconds)
|
||||
if userID != "" {
|
||||
set[types.ConsumptionKey{Kind: types.DimensionUser, DimID: userID, WindowSeconds: windowSeconds, WindowStartUTC: ws}] = struct{}{}
|
||||
}
|
||||
if attrGroup != "" {
|
||||
set[types.ConsumptionKey{Kind: types.DimensionGroup, DimID: attrGroup, WindowSeconds: windowSeconds, WindowStartUTC: ws}] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// prefetchConsumption loads, in one store round-trip, every consumption counter
|
||||
// that the account-budget ceiling and the candidate policies will read while
|
||||
// scoring this request. This replaces the per-cap point reads the selector
|
||||
// previously issued one at a time (the N+1 on the hot path).
|
||||
func (m *managerImpl) prefetchConsumption(ctx context.Context, in PolicySelectionInput, rules []*types.AccountBudgetRule, candidates []*types.Policy, now time.Time) (consumptionCache, error) {
|
||||
set := make(map[types.ConsumptionKey]struct{})
|
||||
for _, p := range candidates {
|
||||
attr := lowestIntersect(p.SourceGroups, in.GroupIDs)
|
||||
if p.Limits.TokenLimit.Enabled {
|
||||
addLimitKeys(set, in.UserID, attr, p.Limits.TokenLimit.WindowSeconds, now)
|
||||
}
|
||||
if p.Limits.BudgetLimit.Enabled {
|
||||
addLimitKeys(set, in.UserID, attr, p.Limits.BudgetLimit.WindowSeconds, now)
|
||||
}
|
||||
}
|
||||
for _, r := range rules {
|
||||
if r == nil || !r.Enabled || !budgetRuleApplies(r, in) {
|
||||
continue
|
||||
}
|
||||
attr := lowestIntersect(r.TargetGroups, in.GroupIDs)
|
||||
if r.Limits.TokenLimit.Enabled {
|
||||
addLimitKeys(set, in.UserID, attr, r.Limits.TokenLimit.WindowSeconds, now)
|
||||
}
|
||||
if r.Limits.BudgetLimit.Enabled {
|
||||
addLimitKeys(set, in.UserID, attr, r.Limits.BudgetLimit.WindowSeconds, now)
|
||||
}
|
||||
}
|
||||
if len(set) == 0 {
|
||||
return consumptionCache{}, nil
|
||||
}
|
||||
keys := make([]types.ConsumptionKey, 0, len(set))
|
||||
for k := range set {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
rows, err := m.store.GetAgentNetworkConsumptionBatch(ctx, store.LockingStrengthNone, in.AccountID, keys)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("batch read consumption: %w", err)
|
||||
}
|
||||
return consumptionCache(rows), nil
|
||||
}
|
||||
|
||||
// SelectPolicyForRequest picks the policy that "pays" for the
|
||||
// incoming request. The chosen policy is the one with the largest
|
||||
// pool that still has headroom — drain the bigger bucket first,
|
||||
// fall through to the next-biggest only when the current one's
|
||||
// group cap or shared per-user cap is exhausted. This matches
|
||||
// operator intuition for layered tiers ("privileged group has the
|
||||
// 10k budget, regular group has 1k as the safety net") and avoids
|
||||
// the load-balancer flapping that fraction-based scoring produces
|
||||
// once any cap has been touched.
|
||||
//
|
||||
// Ordering across non-exhausted candidates:
|
||||
// 1. Policies with NO enabled caps (catch-all-allow) win over any
|
||||
// capped policy — operators who configure unlimited access
|
||||
// expect requests to attribute there until they explicitly add
|
||||
// caps.
|
||||
// 2. Larger group token cap wins.
|
||||
// 3. Larger group budget USD cap wins.
|
||||
// 4. Larger user token cap wins.
|
||||
// 5. Larger user budget USD cap wins.
|
||||
// 6. Older created_at wins (deterministic final tiebreak so
|
||||
// multi-node selection converges).
|
||||
//
|
||||
// Returns Allow=true with empty SelectedPolicyID when no policy in
|
||||
// the account targets the (provider, caller-groups) combination —
|
||||
// llm_router is the gate that owns "no policy authorises this
|
||||
// request" semantics; this function trusts that authorisation has
|
||||
// already happened upstream and only does the limit-aware
|
||||
// attribution.
|
||||
func (m *managerImpl) SelectPolicyForRequest(ctx context.Context, in PolicySelectionInput) (*PolicySelectionResult, error) {
|
||||
if in.AccountID == "" {
|
||||
return nil, status.Errorf(status.InvalidArgument, "account_id is required")
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
rules, err := m.store.GetAccountAgentNetworkBudgetRules(ctx, store.LockingStrengthNone, in.AccountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list account budget rules: %w", err)
|
||||
}
|
||||
policies, err := m.store.GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, in.AccountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list account policies: %w", err)
|
||||
}
|
||||
candidates := filterApplicablePolicies(policies, in)
|
||||
|
||||
candidates, denied, err := m.applyModelGate(ctx, in, candidates)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if denied != nil {
|
||||
return denied, nil
|
||||
}
|
||||
|
||||
// Prefetch every consumption counter the ceiling + candidate policies will
|
||||
// read, in a single store round-trip, then score against the cache.
|
||||
cache, err := m.prefetchConsumption(ctx, in, rules, candidates, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Account-level budget rules are an always-on ceiling, evaluated
|
||||
// independently of policy selection (they bind even for catch-all-allow
|
||||
// policies or requests that match no policy). All applicable rules must
|
||||
// pass — this is where min-wins lives.
|
||||
if deny, code, reason := checkAccountBudget(in, rules, cache, now); deny {
|
||||
return &PolicySelectionResult{Allow: false, DenyCode: code, DenyReason: reason}, nil
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
return &PolicySelectionResult{Allow: true}, nil
|
||||
}
|
||||
scored, lastDenyCode, lastDenyReason := scoreCandidates(in, candidates, cache, now)
|
||||
if len(scored) == 0 {
|
||||
return &PolicySelectionResult{
|
||||
Allow: false,
|
||||
DenyCode: lastDenyCode,
|
||||
DenyReason: lastDenyReason,
|
||||
}, nil
|
||||
}
|
||||
|
||||
sort.SliceStable(scored, func(i, j int) bool {
|
||||
// Catch-all-allow (no caps configured) wins outright over
|
||||
// any capped policy.
|
||||
iNoCap := isUncapped(scored[i].policy)
|
||||
jNoCap := isUncapped(scored[j].policy)
|
||||
if iNoCap != jNoCap {
|
||||
return iNoCap
|
||||
}
|
||||
// Bigger pool drains first. Group caps dominate (shared
|
||||
// across the group) before individual caps.
|
||||
if a, b := groupCapTokens(scored[i].policy), groupCapTokens(scored[j].policy); a != b {
|
||||
return a > b
|
||||
}
|
||||
if a, b := groupCapBudgetUsd(scored[i].policy), groupCapBudgetUsd(scored[j].policy); a != b {
|
||||
return a > b
|
||||
}
|
||||
if a, b := userCapTokens(scored[i].policy), userCapTokens(scored[j].policy); a != b {
|
||||
return a > b
|
||||
}
|
||||
if a, b := userCapBudgetUsd(scored[i].policy), userCapBudgetUsd(scored[j].policy); a != b {
|
||||
return a > b
|
||||
}
|
||||
return scored[i].policy.CreatedAt.Before(scored[j].policy.CreatedAt)
|
||||
})
|
||||
|
||||
winner := scored[0]
|
||||
return &PolicySelectionResult{
|
||||
Allow: true,
|
||||
SelectedPolicyID: winner.policy.ID,
|
||||
AttributionGroupID: winner.attributionGroup,
|
||||
WindowSeconds: winner.windowSeconds,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// filterApplicablePolicies returns the enabled policies that target
|
||||
// the requested provider and have at least one of the caller's groups
|
||||
// in their source_groups. Caller's group set is matched
|
||||
// case-sensitively against policy.SourceGroups.
|
||||
func filterApplicablePolicies(policies []*types.Policy, in PolicySelectionInput) []*types.Policy {
|
||||
if len(policies) == 0 {
|
||||
return nil
|
||||
}
|
||||
groupSet := make(map[string]struct{}, len(in.GroupIDs))
|
||||
for _, g := range in.GroupIDs {
|
||||
if g != "" {
|
||||
groupSet[g] = struct{}{}
|
||||
}
|
||||
}
|
||||
out := make([]*types.Policy, 0, len(policies))
|
||||
for _, p := range policies {
|
||||
if p == nil || !p.Enabled {
|
||||
continue
|
||||
}
|
||||
if !sliceContains(p.DestinationProviderIDs, in.ProviderID) {
|
||||
continue
|
||||
}
|
||||
if !anyGroupMatches(p.SourceGroups, groupSet) {
|
||||
continue
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// anyPolicyHasGuardrails reports whether any policy references at least one
|
||||
// guardrail, so the selector can skip loading guardrails when none do.
|
||||
func anyPolicyHasGuardrails(policies []*types.Policy) bool {
|
||||
for _, p := range policies {
|
||||
if p != nil && len(p.GuardrailIDs) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// applyModelGate is the model-allowlist gate scoped to the matched policies:
|
||||
// it keeps the candidates whose guardrails permit the model (none enabled =
|
||||
// unrestricted) and returns a deny result when policies apply but none
|
||||
// permits it. The guardrail load is skipped when no candidate references a
|
||||
// guardrail, and the provider's catalog id — which picks the model-id
|
||||
// normalizer — is resolved only when a candidate actually restricts models:
|
||||
// with no enabled allowlist every candidate is unrestricted, and a
|
||||
// provider-store failure must not fail a request the gate would have waved
|
||||
// through.
|
||||
func (m *managerImpl) applyModelGate(ctx context.Context, in PolicySelectionInput, candidates []*types.Policy) ([]*types.Policy, *PolicySelectionResult, error) {
|
||||
if len(candidates) == 0 || !anyPolicyHasGuardrails(candidates) {
|
||||
return candidates, nil, nil
|
||||
}
|
||||
guardrailsByID, err := m.loadGuardrailsByID(ctx, in.AccountID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !anyEnabledModelAllowlist(candidates, guardrailsByID) {
|
||||
return candidates, nil, nil
|
||||
}
|
||||
catalogID, err := m.providerCatalogID(ctx, in.AccountID, in.ProviderID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
permitted := filterModelPermittedPolicies(candidates, guardrailsByID, in.Model, catalogID)
|
||||
if len(permitted) == 0 {
|
||||
return nil, &PolicySelectionResult{
|
||||
Allow: false,
|
||||
DenyCode: denyCodeModelBlocked,
|
||||
DenyReason: modelBlockedReason(in.Model),
|
||||
}, nil
|
||||
}
|
||||
return permitted, nil, nil
|
||||
}
|
||||
|
||||
// anyEnabledModelAllowlist reports whether any policy references a guardrail
|
||||
// whose model allowlist is enabled — the only case the model gate restricts
|
||||
// anything. Disabled allowlists, stale guardrail references, and guardrails
|
||||
// carrying only other checks all leave every candidate unrestricted.
|
||||
func anyEnabledModelAllowlist(policies []*types.Policy, byID map[string]*types.Guardrail) bool {
|
||||
for _, p := range policies {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
for _, gID := range p.GuardrailIDs {
|
||||
if g, ok := byID[gID]; ok && g != nil && g.Checks.ModelAllowlist.Enabled {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// loadGuardrailsByID loads the account's guardrails indexed by ID. Used by the
|
||||
// model-allowlist gate to resolve each candidate policy's attached guardrails.
|
||||
func (m *managerImpl) loadGuardrailsByID(ctx context.Context, accountID string) (map[string]*types.Guardrail, error) {
|
||||
guardrails, err := m.store.GetAccountAgentNetworkGuardrails(ctx, store.LockingStrengthNone, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list account guardrails: %w", err)
|
||||
}
|
||||
byID := make(map[string]*types.Guardrail, len(guardrails))
|
||||
for _, g := range guardrails {
|
||||
if g != nil {
|
||||
byID[g.ID] = g
|
||||
}
|
||||
}
|
||||
return byID, nil
|
||||
}
|
||||
|
||||
// providerCatalogID resolves a provider record id to its catalog provider
|
||||
// id, the key the model-id normalizers are picked by. A missing provider
|
||||
// resolves to the empty catalog id — the compare then runs verbatim-only,
|
||||
// which can never widen an allowlist — while a store failure propagates
|
||||
// rather than degrading a security decision.
|
||||
func (m *managerImpl) providerCatalogID(ctx context.Context, accountID, providerID string) (string, error) {
|
||||
if providerID == "" {
|
||||
return "", nil
|
||||
}
|
||||
provider, err := m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, accountID, providerID)
|
||||
switch {
|
||||
case err == nil:
|
||||
return provider.ProviderID, nil
|
||||
case isNotFound(err):
|
||||
return "", nil
|
||||
default:
|
||||
return "", fmt.Errorf("get provider: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// filterModelPermittedPolicies returns the subset of policies whose guardrails
|
||||
// permit the model on the provider with the given catalog id. Order is
|
||||
// preserved so downstream scoring is unaffected.
|
||||
func filterModelPermittedPolicies(policies []*types.Policy, byID map[string]*types.Guardrail, model, catalogProviderID string) []*types.Policy {
|
||||
out := make([]*types.Policy, 0, len(policies))
|
||||
for _, p := range policies {
|
||||
if policyPermitsModel(p, byID, model, catalogProviderID) {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// policyPermitsModel reports whether a policy permits the model. No
|
||||
// allowlist-enabled guardrail = unrestricted (permits any, incl. empty);
|
||||
// otherwise the model must be in the union of its allowlists, so an
|
||||
// empty/undetermined model fails closed. An entry matches on its own
|
||||
// normalised form or, for a path-style provider, its canonical form: the
|
||||
// parser emits the canonical id for path-routed requests, while an
|
||||
// allowlist may hold the raw declared id the dashboard's picker copies
|
||||
// from the provider. The catalog id picks the normalizer, so a plain
|
||||
// provider's entries always compare verbatim.
|
||||
func policyPermitsModel(p *types.Policy, byID map[string]*types.Guardrail, model, catalogProviderID string) bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
wanted := normaliseModelID(model)
|
||||
restricted := false
|
||||
for _, gID := range p.GuardrailIDs {
|
||||
g, ok := byID[gID]
|
||||
if !ok || g == nil || !g.Checks.ModelAllowlist.Enabled {
|
||||
continue
|
||||
}
|
||||
restricted = true
|
||||
if wanted == "" {
|
||||
continue
|
||||
}
|
||||
for _, allowed := range g.Checks.ModelAllowlist.Models {
|
||||
if normaliseModelID(allowed) == wanted || canonicalModelKey(catalogProviderID, allowed) == wanted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return !restricted
|
||||
}
|
||||
|
||||
// normaliseModelID lowercases and trims a model identifier so the allowlist
|
||||
// compare is case-insensitive and trim-tolerant. Mirrors the proxy guardrail's
|
||||
// normaliseModel so both layers agree on what "same model" means.
|
||||
func normaliseModelID(model string) string {
|
||||
return strings.ToLower(strings.TrimSpace(model))
|
||||
}
|
||||
|
||||
// modelBlockedReason builds the human-readable deny reason for a model-allowlist
|
||||
// rejection. The model is quoted when known; an undetermined model is reported
|
||||
// as such so the access log distinguishes "wrong model" from "no model".
|
||||
func modelBlockedReason(model string) string {
|
||||
if normaliseModelID(model) == "" {
|
||||
return "request model could not be determined for the policy allowlist"
|
||||
}
|
||||
return fmt.Sprintf("model %q is not permitted by any applicable policy allowlist", model)
|
||||
}
|
||||
|
||||
// candidate is the per-policy intermediate the selector ranks. A
|
||||
// policy that's been exhausted on any enabled cap never makes it
|
||||
// into this slice; the selector's deny envelope carries the latest
|
||||
// exhaustion's reason out separately.
|
||||
type candidate struct {
|
||||
policy *types.Policy
|
||||
attributionGroup string
|
||||
windowSeconds int64
|
||||
}
|
||||
|
||||
// scoreCandidates evaluates every applicable policy against the
|
||||
// caller's current consumption. Exhausted policies are filtered out
|
||||
// of the returned slice; the most recent exhaustion's deny code +
|
||||
// human reason is returned alongside so the caller can surface it
|
||||
// when no candidate survives.
|
||||
func scoreCandidates(
|
||||
in PolicySelectionInput,
|
||||
candidates []*types.Policy,
|
||||
cache consumptionCache,
|
||||
now time.Time,
|
||||
) ([]candidate, string, string) {
|
||||
out := make([]candidate, 0, len(candidates))
|
||||
var lastDenyCode, lastDenyReason string
|
||||
|
||||
for _, p := range candidates {
|
||||
c, exhausted, denyCode, denyReason := scoreOne(in, p, cache, now)
|
||||
if exhausted {
|
||||
lastDenyCode = denyCode
|
||||
lastDenyReason = denyReason
|
||||
continue
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, lastDenyCode, lastDenyReason
|
||||
}
|
||||
|
||||
// scoreOne checks a single policy for cap exhaustion. Returns the
|
||||
// candidate envelope when the policy still has headroom on every
|
||||
// enabled cap; reports exhausted=true with a deny code naming the
|
||||
// offending cap kind otherwise.
|
||||
func scoreOne(
|
||||
in PolicySelectionInput,
|
||||
p *types.Policy,
|
||||
cache consumptionCache,
|
||||
now time.Time,
|
||||
) (candidate, bool, string, string) {
|
||||
attrGroup := lowestIntersect(p.SourceGroups, in.GroupIDs)
|
||||
c := candidate{
|
||||
policy: p,
|
||||
attributionGroup: attrGroup,
|
||||
windowSeconds: effectiveWindowSeconds(p),
|
||||
}
|
||||
|
||||
if p.Limits.TokenLimit.Enabled && p.Limits.TokenLimit.WindowSeconds > 0 {
|
||||
if exhausted, reason := evalTokenCap(cache, in.AccountID, in.UserID, attrGroup, p.Limits.TokenLimit, now, "policy "+p.ID); exhausted {
|
||||
return candidate{}, true, denyCodeTokenCapExceeded, reason
|
||||
}
|
||||
}
|
||||
|
||||
if p.Limits.BudgetLimit.Enabled && p.Limits.BudgetLimit.WindowSeconds > 0 {
|
||||
if exhausted, reason := evalBudgetCap(cache, in.AccountID, in.UserID, attrGroup, p.Limits.BudgetLimit, now, "policy "+p.ID); exhausted {
|
||||
return candidate{}, true, denyCodeBudgetCapExceeded, reason
|
||||
}
|
||||
}
|
||||
|
||||
return c, false, "", ""
|
||||
}
|
||||
|
||||
// evalTokenCap reports whether the token limit is already exhausted for the
|
||||
// caller in its own window. attrGroup may be empty (no group dimension applies).
|
||||
// label identifies the cap source ("policy <id>" or "account rule <id>") for the
|
||||
// deny reason. It is the shared primitive behind both policy and account-rule
|
||||
// enforcement.
|
||||
func evalTokenCap(
|
||||
cache consumptionCache,
|
||||
accountID, userID, attrGroup string,
|
||||
tl types.PolicyTokenLimit,
|
||||
now time.Time,
|
||||
label string,
|
||||
) (bool, string) {
|
||||
windowStart := types.WindowStart(now, tl.WindowSeconds)
|
||||
|
||||
if tl.UserCap > 0 && userID != "" {
|
||||
row := cache.get(accountID, types.DimensionUser, userID, tl.WindowSeconds, windowStart)
|
||||
used := row.TokensInput + row.TokensOutput
|
||||
if used >= tl.UserCap {
|
||||
return true, fmt.Sprintf("user token cap exhausted on %s (used %d of %d)", label, used, tl.UserCap)
|
||||
}
|
||||
}
|
||||
|
||||
if tl.GroupCap > 0 && attrGroup != "" {
|
||||
row := cache.get(accountID, types.DimensionGroup, attrGroup, tl.WindowSeconds, windowStart)
|
||||
used := row.TokensInput + row.TokensOutput
|
||||
if used >= tl.GroupCap {
|
||||
return true, fmt.Sprintf("group token cap exhausted on %s (used %d of %d)", label, used, tl.GroupCap)
|
||||
}
|
||||
}
|
||||
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// evalBudgetCap is the budget (USD) counterpart of evalTokenCap.
|
||||
func evalBudgetCap(
|
||||
cache consumptionCache,
|
||||
accountID, userID, attrGroup string,
|
||||
bl types.PolicyBudgetLimit,
|
||||
now time.Time,
|
||||
label string,
|
||||
) (bool, string) {
|
||||
windowStart := types.WindowStart(now, bl.WindowSeconds)
|
||||
|
||||
if bl.UserCapUsd > 0 && userID != "" {
|
||||
row := cache.get(accountID, types.DimensionUser, userID, bl.WindowSeconds, windowStart)
|
||||
if row.CostUSD >= bl.UserCapUsd {
|
||||
return true, fmt.Sprintf("user budget cap exhausted on %s (used $%.4f of $%.4f)", label, row.CostUSD, bl.UserCapUsd)
|
||||
}
|
||||
}
|
||||
|
||||
if bl.GroupCapUsd > 0 && attrGroup != "" {
|
||||
row := cache.get(accountID, types.DimensionGroup, attrGroup, bl.WindowSeconds, windowStart)
|
||||
if row.CostUSD >= bl.GroupCapUsd {
|
||||
return true, fmt.Sprintf("group budget cap exhausted on %s (used $%.4f of $%.4f)", label, row.CostUSD, bl.GroupCapUsd)
|
||||
}
|
||||
}
|
||||
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// checkAccountBudget evaluates every applicable account-level budget rule as an
|
||||
// all-must-pass ceiling. A rule applies when the caller is in its TargetUsers,
|
||||
// one of its TargetGroups, or it has no targets at all (account-wide). Returns
|
||||
// deny=true with an llm_account.* code on the first exhausted rule. Group caps
|
||||
// attribute to the lowest intersecting group (the same model policies use), so
|
||||
// multi-group behavior is unchanged.
|
||||
func checkAccountBudget(in PolicySelectionInput, rules []*types.AccountBudgetRule, cache consumptionCache, now time.Time) (bool, string, string) {
|
||||
for _, r := range rules {
|
||||
if r == nil || !r.Enabled || !budgetRuleApplies(r, in) {
|
||||
continue
|
||||
}
|
||||
attrGroup := lowestIntersect(r.TargetGroups, in.GroupIDs)
|
||||
label := "account rule " + r.ID
|
||||
|
||||
if r.Limits.TokenLimit.Enabled && r.Limits.TokenLimit.WindowSeconds > 0 {
|
||||
if exhausted, reason := evalTokenCap(cache, in.AccountID, in.UserID, attrGroup, r.Limits.TokenLimit, now, label); exhausted {
|
||||
return true, denyCodeAccountTokenCapExceeded, reason
|
||||
}
|
||||
}
|
||||
|
||||
if r.Limits.BudgetLimit.Enabled && r.Limits.BudgetLimit.WindowSeconds > 0 {
|
||||
if exhausted, reason := evalBudgetCap(cache, in.AccountID, in.UserID, attrGroup, r.Limits.BudgetLimit, now, label); exhausted {
|
||||
return true, denyCodeAccountBudgetCapExceeded, reason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false, "", ""
|
||||
}
|
||||
|
||||
// budgetRuleApplies reports whether an account budget rule binds the caller:
|
||||
// a direct user match, a group intersection, or an untargeted (account-wide)
|
||||
// rule.
|
||||
func budgetRuleApplies(r *types.AccountBudgetRule, in PolicySelectionInput) bool {
|
||||
if len(r.TargetUsers) == 0 && len(r.TargetGroups) == 0 {
|
||||
return true
|
||||
}
|
||||
if in.UserID != "" && sliceContains(r.TargetUsers, in.UserID) {
|
||||
return true
|
||||
}
|
||||
groupSet := make(map[string]struct{}, len(in.GroupIDs))
|
||||
for _, g := range in.GroupIDs {
|
||||
if g != "" {
|
||||
groupSet[g] = struct{}{}
|
||||
}
|
||||
}
|
||||
return anyGroupMatches(r.TargetGroups, groupSet)
|
||||
}
|
||||
|
||||
// RecordAccountBudgetUsage fans the served request's usage out to every
|
||||
// applicable account budget rule's own (dimension, window) counter. The user
|
||||
// dimension is always booked when a rule has a user-applicable cap; the group
|
||||
// dimension books against the rule's lowest intersecting group. This runs
|
||||
// alongside the policy-window record so account ceilings accumulate in their own
|
||||
// windows (commonly monthly) independently of the per-policy window.
|
||||
func (m *managerImpl) RecordAccountBudgetUsage(ctx context.Context, accountID, userID string, groupIDs []string, tokensIn, tokensOut int64, costUSD float64) error {
|
||||
if accountID == "" {
|
||||
return status.Errorf(status.InvalidArgument, "account_id is required")
|
||||
}
|
||||
if err := validateUsageDeltas(tokensIn, tokensOut, costUSD); err != nil {
|
||||
return err
|
||||
}
|
||||
rules, err := m.store.GetAccountAgentNetworkBudgetRules(ctx, store.LockingStrengthNone, accountID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list account budget rules: %w", err)
|
||||
}
|
||||
set := make(map[types.ConsumptionKey]struct{})
|
||||
addAccountBudgetKeys(set, PolicySelectionInput{AccountID: accountID, UserID: userID, GroupIDs: groupIDs}, rules, time.Now().UTC())
|
||||
if len(set) == 0 {
|
||||
return nil
|
||||
}
|
||||
return m.store.IncrementAgentNetworkConsumptionBatch(ctx, accountID, keysSlice(set), tokensIn, tokensOut, costUSD)
|
||||
}
|
||||
|
||||
// RecordUsageInput carries everything RecordUsage books for one served request.
|
||||
type RecordUsageInput struct {
|
||||
AccountID string
|
||||
UserID string
|
||||
AttributionGroupID string // selected policy's attribution group (policy window)
|
||||
GroupIDs []string
|
||||
WindowSeconds int64 // selected policy's window; 0 means no policy cap
|
||||
TokensIn int64
|
||||
TokensOut int64
|
||||
CostUSD float64
|
||||
}
|
||||
|
||||
// RecordUsage books a served request's usage against every counter it touches —
|
||||
// the selected policy's per-(user, group) window plus every applicable account
|
||||
// budget rule's own window — deduplicated and written in a single transaction.
|
||||
// Two counters that collapse to the same (dimension, window) tuple are booked
|
||||
// once, so a single request can never double-count against one cap.
|
||||
func (m *managerImpl) RecordUsage(ctx context.Context, in RecordUsageInput) error {
|
||||
if in.AccountID == "" {
|
||||
return status.Errorf(status.InvalidArgument, "account_id is required")
|
||||
}
|
||||
if err := validateUsageDeltas(in.TokensIn, in.TokensOut, in.CostUSD); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
set := make(map[types.ConsumptionKey]struct{})
|
||||
|
||||
// Policy-window dimensions are booked only when a policy cap bound this
|
||||
// request (window > 0). A zero window means catch-all-allow / no policy cap;
|
||||
// the account fan-out below still books against the budget rules' windows.
|
||||
if in.WindowSeconds > 0 {
|
||||
addLimitKeys(set, in.UserID, in.AttributionGroupID, in.WindowSeconds, now)
|
||||
}
|
||||
|
||||
rules, err := m.store.GetAccountAgentNetworkBudgetRules(ctx, store.LockingStrengthNone, in.AccountID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list account budget rules: %w", err)
|
||||
}
|
||||
addAccountBudgetKeys(set, PolicySelectionInput{AccountID: in.AccountID, UserID: in.UserID, GroupIDs: in.GroupIDs}, rules, now)
|
||||
|
||||
if len(set) == 0 {
|
||||
return nil
|
||||
}
|
||||
return m.store.IncrementAgentNetworkConsumptionBatch(ctx, in.AccountID, keysSlice(set), in.TokensIn, in.TokensOut, in.CostUSD)
|
||||
}
|
||||
|
||||
// addAccountBudgetKeys adds the (dimension, window) keys a served request books
|
||||
// against every applicable account budget rule into the dedup set.
|
||||
func addAccountBudgetKeys(set map[types.ConsumptionKey]struct{}, in PolicySelectionInput, rules []*types.AccountBudgetRule, now time.Time) {
|
||||
for _, r := range rules {
|
||||
if r == nil || !r.Enabled || !budgetRuleApplies(r, in) {
|
||||
continue
|
||||
}
|
||||
attrGroup := lowestIntersect(r.TargetGroups, in.GroupIDs)
|
||||
for _, window := range ruleWindows(r) {
|
||||
addLimitKeys(set, in.UserID, attrGroup, window, now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// keysSlice flattens a ConsumptionKey set into a slice.
|
||||
func keysSlice(set map[types.ConsumptionKey]struct{}) []types.ConsumptionKey {
|
||||
keys := make([]types.ConsumptionKey, 0, len(set))
|
||||
for k := range set {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// ruleWindows returns the distinct enabled window lengths a budget rule books
|
||||
// against (token window and/or budget window, deduplicated).
|
||||
func ruleWindows(r *types.AccountBudgetRule) []int64 {
|
||||
var windows []int64
|
||||
if r.Limits.TokenLimit.Enabled && r.Limits.TokenLimit.WindowSeconds > 0 {
|
||||
windows = append(windows, r.Limits.TokenLimit.WindowSeconds)
|
||||
}
|
||||
if r.Limits.BudgetLimit.Enabled && r.Limits.BudgetLimit.WindowSeconds > 0 {
|
||||
bw := r.Limits.BudgetLimit.WindowSeconds
|
||||
if len(windows) == 0 || windows[0] != bw {
|
||||
windows = append(windows, bw)
|
||||
}
|
||||
}
|
||||
return windows
|
||||
}
|
||||
|
||||
// effectiveWindowSeconds returns the window length the proxy should
|
||||
// hand back to RecordLLMUsage. When both halves are enabled with
|
||||
// different windows, token_limit wins (the more common config); when
|
||||
// only one is enabled that one wins; when neither is enabled the
|
||||
// returned value is 0 — RecordLLMUsage treats 0 as "no limit
|
||||
// tracking" and skips the increment, which is the right pass-through
|
||||
// for catch-all-allow policies with no caps configured.
|
||||
func effectiveWindowSeconds(p *types.Policy) int64 {
|
||||
if p.Limits.TokenLimit.Enabled && p.Limits.TokenLimit.WindowSeconds > 0 {
|
||||
return p.Limits.TokenLimit.WindowSeconds
|
||||
}
|
||||
if p.Limits.BudgetLimit.Enabled && p.Limits.BudgetLimit.WindowSeconds > 0 {
|
||||
return p.Limits.BudgetLimit.WindowSeconds
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// lowestIntersect returns the lowest-by-string-sort element of
|
||||
// callerGroups ∩ sourceGroups. Empty when the intersection is empty.
|
||||
// Lowest is deterministic so multi-node selection converges.
|
||||
func lowestIntersect(sourceGroups, callerGroups []string) string {
|
||||
if len(sourceGroups) == 0 || len(callerGroups) == 0 {
|
||||
return ""
|
||||
}
|
||||
srcSet := make(map[string]struct{}, len(sourceGroups))
|
||||
for _, g := range sourceGroups {
|
||||
srcSet[g] = struct{}{}
|
||||
}
|
||||
var best string
|
||||
for _, g := range callerGroups {
|
||||
if _, ok := srcSet[g]; !ok {
|
||||
continue
|
||||
}
|
||||
if best == "" || g < best {
|
||||
best = g
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func anyGroupMatches(sourceGroups []string, callerSet map[string]struct{}) bool {
|
||||
for _, g := range sourceGroups {
|
||||
if _, ok := callerSet[g]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isUncapped reports whether a policy has any enabled cap with a
|
||||
// positive limit value. Mirrors the eval functions' guards: a policy
|
||||
// with token_limit.enabled=true but every cap value at 0 still
|
||||
// counts as uncapped because the eval would query nothing and bind
|
||||
// nothing.
|
||||
func isUncapped(p *types.Policy) bool {
|
||||
tl := p.Limits.TokenLimit
|
||||
if tl.Enabled && tl.WindowSeconds > 0 && (tl.GroupCap > 0 || tl.UserCap > 0) {
|
||||
return false
|
||||
}
|
||||
bl := p.Limits.BudgetLimit
|
||||
if bl.Enabled && bl.WindowSeconds > 0 && (bl.GroupCapUsd > 0 || bl.UserCapUsd > 0) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// groupCapTokens returns the policy's group-token cap when the token
|
||||
// limit is enabled, zero otherwise. Drives the primary "bigger pool
|
||||
// first" sort.
|
||||
func groupCapTokens(p *types.Policy) int64 {
|
||||
if p.Limits.TokenLimit.Enabled {
|
||||
return p.Limits.TokenLimit.GroupCap
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// groupCapBudgetUsd returns the policy's group-budget cap in USD
|
||||
// when the budget limit is enabled, zero otherwise. Secondary sort
|
||||
// key after token group cap so budget-only policies still order
|
||||
// predictably.
|
||||
func groupCapBudgetUsd(p *types.Policy) float64 {
|
||||
if p.Limits.BudgetLimit.Enabled {
|
||||
return p.Limits.BudgetLimit.GroupCapUsd
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// userCapTokens returns the policy's per-user token cap when the
|
||||
// token limit is enabled, zero otherwise. Tertiary sort key, used
|
||||
// when group caps tie or are absent.
|
||||
func userCapTokens(p *types.Policy) int64 {
|
||||
if p.Limits.TokenLimit.Enabled {
|
||||
return p.Limits.TokenLimit.UserCap
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// userCapBudgetUsd returns the policy's per-user budget cap in USD
|
||||
// when the budget limit is enabled, zero otherwise. Quaternary sort
|
||||
// key for budget-only policies whose group caps tie or are absent.
|
||||
func userCapBudgetUsd(p *types.Policy) float64 {
|
||||
if p.Limits.BudgetLimit.Enabled {
|
||||
return p.Limits.BudgetLimit.UserCapUsd
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func sliceContains(haystack []string, needle string) bool {
|
||||
for _, v := range haystack {
|
||||
if v == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// mockManager fallback so tests that don't care about selection still
|
||||
// compile.
|
||||
func (*mockManager) SelectPolicyForRequest(_ context.Context, _ PolicySelectionInput) (*PolicySelectionResult, error) {
|
||||
return &PolicySelectionResult{Allow: true}, nil
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
)
|
||||
|
||||
// GC-2 no-mock enforcement tests for the account-budget ceiling. They drive the
|
||||
// real store + real consumption accounting through SelectPolicyForRequest and
|
||||
// RecordAccountBudgetUsage, asserting min-wins (account binds independently of
|
||||
// policy), targeting (groups + direct users), and the record fan-out.
|
||||
|
||||
func accountWideUserTokenRule(id string, userCap, window int64) *types.AccountBudgetRule {
|
||||
r := types.NewAccountBudgetRule(realSelectAccount)
|
||||
r.ID = id
|
||||
r.Limits.TokenLimit = types.PolicyTokenLimit{Enabled: true, UserCap: userCap, WindowSeconds: window}
|
||||
return r
|
||||
}
|
||||
|
||||
// TestSelectPolicy_RealStore_AccountCeilingBindsEvenWithUncappedPolicy proves
|
||||
// min-wins: the account user ceiling denies once exhausted even though a
|
||||
// catch-all-allow (uncapped) policy would otherwise pass the request. The
|
||||
// account gate runs independently of and ahead of policy selection.
|
||||
func TestSelectPolicy_RealStore_AccountCeilingBindsEvenWithUncappedPolicy(t *testing.T) {
|
||||
mgr, s := newRealSelectorMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// An uncapped (catch-all-allow) policy: enabled token limit, zero caps.
|
||||
uncapped := capPolicy("pol-open", realSelectAccount, []string{"grp-eng"}, "prov-1", 0, 86_400)
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, uncapped))
|
||||
|
||||
// Account-wide user ceiling of 100 tokens in an hourly window.
|
||||
require.NoError(t, s.SaveAgentNetworkBudgetRule(ctx, accountWideUserTokenRule("ainbud-1", 100, 3_600)))
|
||||
|
||||
in := PolicySelectionInput{AccountID: realSelectAccount, UserID: "user-1", GroupIDs: []string{"grp-eng"}, ProviderID: "prov-1"}
|
||||
|
||||
// Fresh: account ceiling has headroom, uncapped policy wins.
|
||||
res, err := mgr.SelectPolicyForRequest(ctx, in)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Allow, "fresh account ceiling must allow")
|
||||
|
||||
// Drain the account user ceiling via the fan-out path.
|
||||
require.NoError(t, mgr.RecordAccountBudgetUsage(ctx, realSelectAccount, "user-1", []string{"grp-eng"}, 100, 0, 0))
|
||||
|
||||
res, err = mgr.SelectPolicyForRequest(ctx, in)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, res.Allow, "account ceiling must deny even though the policy is uncapped (min-wins)")
|
||||
assert.Equal(t, denyCodeAccountTokenCapExceeded, res.DenyCode, "deny must carry the llm_account.* code")
|
||||
}
|
||||
|
||||
// TestSelectPolicy_RealStore_AccountGroupCeiling proves a group-targeted rule
|
||||
// binds the caller's group dimension.
|
||||
func TestSelectPolicy_RealStore_AccountGroupCeiling(t *testing.T) {
|
||||
mgr, s := newRealSelectorMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
rule := types.NewAccountBudgetRule(realSelectAccount)
|
||||
rule.ID = "ainbud-grp"
|
||||
rule.TargetGroups = []string{"grp-eng"}
|
||||
rule.Limits.BudgetLimit = types.PolicyBudgetLimit{Enabled: true, GroupCapUsd: 5.0, WindowSeconds: 2_592_000}
|
||||
require.NoError(t, s.SaveAgentNetworkBudgetRule(ctx, rule))
|
||||
|
||||
in := PolicySelectionInput{AccountID: realSelectAccount, UserID: "user-1", GroupIDs: []string{"grp-eng"}, ProviderID: "prov-1"}
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(ctx, in)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Allow, "fresh group ceiling must allow")
|
||||
|
||||
require.NoError(t, mgr.RecordAccountBudgetUsage(ctx, realSelectAccount, "user-1", []string{"grp-eng"}, 0, 0, 5.0))
|
||||
|
||||
res, err = mgr.SelectPolicyForRequest(ctx, in)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, res.Allow, "group budget ceiling must deny once spent")
|
||||
assert.Equal(t, denyCodeAccountBudgetCapExceeded, res.DenyCode, "account budget deny code")
|
||||
}
|
||||
|
||||
// TestSelectPolicy_RealStore_AccountTargetUsersBindsOnlyThatUser proves a
|
||||
// TargetUsers rule tightens only the named user, leaving others unbound.
|
||||
func TestSelectPolicy_RealStore_AccountTargetUsersBindsOnlyThatUser(t *testing.T) {
|
||||
mgr, s := newRealSelectorMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
rule := types.NewAccountBudgetRule(realSelectAccount)
|
||||
rule.ID = "ainbud-alice"
|
||||
rule.TargetUsers = []string{"alice"}
|
||||
rule.Limits.TokenLimit = types.PolicyTokenLimit{Enabled: true, UserCap: 100, WindowSeconds: 3_600}
|
||||
require.NoError(t, s.SaveAgentNetworkBudgetRule(ctx, rule))
|
||||
|
||||
// Record alice's usage to the rule window.
|
||||
require.NoError(t, mgr.RecordAccountBudgetUsage(ctx, realSelectAccount, "alice", nil, 100, 0, 0))
|
||||
|
||||
aliceIn := PolicySelectionInput{AccountID: realSelectAccount, UserID: "alice", ProviderID: "prov-1"}
|
||||
res, err := mgr.SelectPolicyForRequest(ctx, aliceIn)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, res.Allow, "alice is bound by the TargetUsers rule and is exhausted")
|
||||
|
||||
bobIn := PolicySelectionInput{AccountID: realSelectAccount, UserID: "bob", ProviderID: "prov-1"}
|
||||
res, err = mgr.SelectPolicyForRequest(ctx, bobIn)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Allow, "bob is not in TargetUsers, so the rule must not bind him")
|
||||
}
|
||||
|
||||
// TestSelectPolicy_RealStore_AccountRuleRecordsToOwnWindow proves the record
|
||||
// fan-out books usage in the rule's own window (distinct from any policy
|
||||
// window), so the account ceiling accumulates independently.
|
||||
func TestSelectPolicy_RealStore_AccountRuleRecordsToOwnWindow(t *testing.T) {
|
||||
mgr, s := newRealSelectorMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkBudgetRule(ctx, accountWideUserTokenRule("ainbud-w", 100, 3_600)))
|
||||
|
||||
require.NoError(t, mgr.RecordAccountBudgetUsage(ctx, realSelectAccount, "user-1", nil, 60, 0, 0))
|
||||
|
||||
// Same user, a policy-style daily window must NOT see the account-window
|
||||
// usage — windows are independent counters.
|
||||
dailyRow, err := s.GetAgentNetworkConsumption(ctx, store.LockingStrengthNone, realSelectAccount, types.DimensionUser, "user-1", 86_400, types.WindowStart(time.Now().UTC(), 86_400))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), dailyRow.TokensInput+dailyRow.TokensOutput, "daily window must be untouched by the hourly account-rule record")
|
||||
|
||||
// A second record pushes the hourly account window to its cap → deny.
|
||||
require.NoError(t, mgr.RecordAccountBudgetUsage(ctx, realSelectAccount, "user-1", nil, 40, 0, 0))
|
||||
res, err := mgr.SelectPolicyForRequest(ctx, PolicySelectionInput{AccountID: realSelectAccount, UserID: "user-1", ProviderID: "prov-1"})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, res.Allow, "100 tokens recorded in the rule's hourly window must exhaust the 100-token ceiling")
|
||||
assert.Equal(t, denyCodeAccountTokenCapExceeded, res.DenyCode, "account token deny code")
|
||||
}
|
||||
|
||||
// TestRecordUsage_RealStore_BooksPolicyAndAccountWindows proves the batched
|
||||
// post-flight write books the selected policy's window AND every applicable
|
||||
// account rule's (independent) window in a single call — the #6 batched-write
|
||||
// path the proxy's RecordLLMUsage RPC now uses.
|
||||
func TestRecordUsage_RealStore_BooksPolicyAndAccountWindows(t *testing.T) {
|
||||
mgr, s := newRealSelectorMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Policy: 100-token group cap on a daily window. Account rule: 100-token
|
||||
// user ceiling on an hourly window — an independent counter.
|
||||
policy := capPolicy("pol-1", realSelectAccount, []string{"grp-eng"}, "prov-1", 100, 86_400)
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy))
|
||||
require.NoError(t, s.SaveAgentNetworkBudgetRule(ctx, accountWideUserTokenRule("ainbud-1", 100, 3_600)))
|
||||
|
||||
in := PolicySelectionInput{AccountID: realSelectAccount, UserID: "user-1", GroupIDs: []string{"grp-eng"}, ProviderID: "prov-1"}
|
||||
res, err := mgr.SelectPolicyForRequest(ctx, in)
|
||||
require.NoError(t, err)
|
||||
require.True(t, res.Allow)
|
||||
require.Equal(t, "pol-1", res.SelectedPolicyID)
|
||||
|
||||
// One batched record books the policy window (group + user @86400) and the
|
||||
// account rule window (user @3600) atomically.
|
||||
require.NoError(t, mgr.RecordUsage(ctx, RecordUsageInput{
|
||||
AccountID: realSelectAccount,
|
||||
UserID: "user-1",
|
||||
AttributionGroupID: res.AttributionGroupID,
|
||||
GroupIDs: []string{"grp-eng"},
|
||||
WindowSeconds: res.WindowSeconds,
|
||||
TokensIn: 100,
|
||||
}))
|
||||
|
||||
// The next selection denies — the account hourly ceiling binds first.
|
||||
res, err = mgr.SelectPolicyForRequest(ctx, in)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, res.Allow, "usage booked by RecordUsage must enforce on the next request")
|
||||
|
||||
// Prove BOTH windows were booked in the one call via a direct batch read.
|
||||
now := time.Now().UTC()
|
||||
userKey := types.ConsumptionKey{Kind: types.DimensionUser, DimID: "user-1", WindowSeconds: 3_600, WindowStartUTC: types.WindowStart(now, 3_600)}
|
||||
groupKey := types.ConsumptionKey{Kind: types.DimensionGroup, DimID: "grp-eng", WindowSeconds: 86_400, WindowStartUTC: types.WindowStart(now, 86_400)}
|
||||
rows, err := s.GetAgentNetworkConsumptionBatch(ctx, store.LockingStrengthNone, realSelectAccount, []types.ConsumptionKey{userKey, groupKey})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, rows, userKey, "account rule user/hourly window booked")
|
||||
require.Contains(t, rows, groupKey, "policy group/daily window booked")
|
||||
assert.Equal(t, int64(100), rows[userKey].TokensInput, "account hourly user counter")
|
||||
assert.Equal(t, int64(100), rows[groupKey].TokensInput, "policy daily group counter")
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
// guardedPolicy builds an enabled, uncapped policy that authorises sourceGroups
|
||||
// to reach providerID under the given guardrails. Uncapped keeps the selector's
|
||||
// headroom scoring trivial so these tests isolate the model-allowlist gate.
|
||||
func guardedPolicy(id, account string, sourceGroups []string, providerID string, guardrailIDs ...string) *types.Policy {
|
||||
return &types.Policy{
|
||||
ID: id,
|
||||
AccountID: account,
|
||||
Enabled: true,
|
||||
SourceGroups: sourceGroups,
|
||||
DestinationProviderIDs: []string{providerID},
|
||||
GuardrailIDs: guardrailIDs,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
// allowlistGuardrail builds a guardrail whose model allowlist is enabled and
|
||||
// carries the given models.
|
||||
func allowlistGuardrail(id, account string, models ...string) *types.Guardrail {
|
||||
return &types.Guardrail{
|
||||
ID: id,
|
||||
AccountID: account,
|
||||
Checks: types.GuardrailChecks{
|
||||
ModelAllowlist: types.GuardrailModelAllowlist{Enabled: true, Models: models},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func expectPolicies(mockStore *store.MockStore, account string, policies ...*types.Policy) {
|
||||
mockStore.EXPECT().
|
||||
GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), account).
|
||||
Return(policies, nil)
|
||||
}
|
||||
|
||||
func expectGuardrails(mockStore *store.MockStore, account string, guardrails ...*types.Guardrail) {
|
||||
mockStore.EXPECT().
|
||||
GetAccountAgentNetworkGuardrails(gomock.Any(), gomock.Any(), account).
|
||||
Return(guardrails, nil)
|
||||
}
|
||||
|
||||
// expectProviderCatalog resolves the destination provider to the given
|
||||
// catalog provider id, which picks the model-id normalizer the allowlist
|
||||
// gate compares through. AnyTimes: the lookup runs only when the guardrail
|
||||
// gate is reached.
|
||||
func expectProviderCatalog(mockStore *store.MockStore, account, providerID, catalog string) {
|
||||
mockStore.EXPECT().
|
||||
GetAgentNetworkProviderByID(gomock.Any(), gomock.Any(), account, providerID).
|
||||
Return(&types.Provider{ID: providerID, AccountID: account, ProviderID: catalog}, nil).
|
||||
AnyTimes()
|
||||
}
|
||||
|
||||
// TestSelectPolicy_ModelBlockedByAllowlist proves the authoritative allowlist
|
||||
// decision: a policy authorises the (provider, group) but restricts the model,
|
||||
// and the requested model isn't on the list, so the request is denied.
|
||||
func TestSelectPolicy_ModelBlockedByAllowlist(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr, mockStore := newSelectorMgr(t, ctrl)
|
||||
|
||||
policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-1")
|
||||
expectPolicies(mockStore, "acc-1", policy)
|
||||
expectGuardrails(mockStore, "acc-1", allowlistGuardrail("g-1", "acc-1", "gpt-4o"))
|
||||
expectProviderCatalog(mockStore, "acc-1", "prov-1", "openai_api")
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
||||
AccountID: "acc-1",
|
||||
UserID: "user-1",
|
||||
GroupIDs: []string{"grp-eng"},
|
||||
ProviderID: "prov-1",
|
||||
Model: "claude-opus-4",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, res.Allow, "a model outside the only applicable policy's allowlist must be denied")
|
||||
assert.Equal(t, denyCodeModelBlocked, res.DenyCode, "deny code must be model_blocked")
|
||||
assert.NotEmpty(t, res.DenyReason, "deny reason must be populated")
|
||||
}
|
||||
|
||||
// TestSelectPolicy_ModelAllowedByAllowlist is the allow counterpart: the model
|
||||
// is on the applicable policy's allowlist, so selection proceeds normally.
|
||||
func TestSelectPolicy_ModelAllowedByAllowlist(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr, mockStore := newSelectorMgr(t, ctrl)
|
||||
|
||||
policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-1")
|
||||
expectPolicies(mockStore, "acc-1", policy)
|
||||
expectGuardrails(mockStore, "acc-1", allowlistGuardrail("g-1", "acc-1", "gpt-4o", "claude-opus-4"))
|
||||
expectProviderCatalog(mockStore, "acc-1", "prov-1", "openai_api")
|
||||
expectConsumptionBatch(mockStore, nil)
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
||||
AccountID: "acc-1",
|
||||
UserID: "user-1",
|
||||
GroupIDs: []string{"grp-eng"},
|
||||
ProviderID: "prov-1",
|
||||
Model: "claude-opus-4",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Allow, "a model on the applicable policy's allowlist must be allowed")
|
||||
assert.Equal(t, "pol-A", res.SelectedPolicyID)
|
||||
}
|
||||
|
||||
// TestSelectPolicy_CaseInsensitiveModelMatch proves the compare tolerates case
|
||||
// and surrounding whitespace, matching the proxy guardrail's normalisation.
|
||||
func TestSelectPolicy_CaseInsensitiveModelMatch(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr, mockStore := newSelectorMgr(t, ctrl)
|
||||
|
||||
policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-1")
|
||||
expectPolicies(mockStore, "acc-1", policy)
|
||||
expectGuardrails(mockStore, "acc-1", allowlistGuardrail("g-1", "acc-1", " GPT-4o "))
|
||||
expectProviderCatalog(mockStore, "acc-1", "prov-1", "openai_api")
|
||||
expectConsumptionBatch(mockStore, nil)
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
||||
AccountID: "acc-1",
|
||||
GroupIDs: []string{"grp-eng"},
|
||||
ProviderID: "prov-1",
|
||||
Model: "gpt-4o",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Allow, "case/whitespace variants must match the allowlist entry")
|
||||
}
|
||||
|
||||
// TestSelectPolicy_UnguardedPolicyIsUnrestricted is the false-deny fix: when two
|
||||
// policies authorise the same (provider, group) and one has no guardrail, that
|
||||
// policy makes the request unrestricted — not caught by the other's allowlist.
|
||||
func TestSelectPolicy_UnguardedPolicyIsUnrestricted(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr, mockStore := newSelectorMgr(t, ctrl)
|
||||
|
||||
restricted := guardedPolicy("pol-restricted", "acc-1", []string{"grp-eng"}, "prov-1", "g-1")
|
||||
open := guardedPolicy("pol-open", "acc-1", []string{"grp-eng"}, "prov-1") // no guardrail
|
||||
expectPolicies(mockStore, "acc-1", restricted, open)
|
||||
expectGuardrails(mockStore, "acc-1", allowlistGuardrail("g-1", "acc-1", "gpt-4o"))
|
||||
expectProviderCatalog(mockStore, "acc-1", "prov-1", "openai_api")
|
||||
expectConsumptionBatch(mockStore, nil)
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
||||
AccountID: "acc-1",
|
||||
GroupIDs: []string{"grp-eng"},
|
||||
ProviderID: "prov-1",
|
||||
Model: "claude-opus-4",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Allow, "an un-guardrailed policy for the same (provider, group) must leave the request unrestricted")
|
||||
assert.Equal(t, "pol-open", res.SelectedPolicyID, "the unrestricted policy must be the one that pays")
|
||||
}
|
||||
|
||||
// TestSelectPolicy_AllowlistDoesNotLeakAcrossGroups is the false-allow fix: a
|
||||
// model allowlisted only for grp-b must not be usable by a grp-a caller. The
|
||||
// selector considers only policies applicable to the caller's groups.
|
||||
func TestSelectPolicy_AllowlistDoesNotLeakAcrossGroups(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr, mockStore := newSelectorMgr(t, ctrl)
|
||||
|
||||
polA := guardedPolicy("pol-a", "acc-1", []string{"grp-a"}, "prov-1", "g-a")
|
||||
polB := guardedPolicy("pol-b", "acc-1", []string{"grp-b"}, "prov-1", "g-b")
|
||||
expectPolicies(mockStore, "acc-1", polA, polB)
|
||||
expectGuardrails(mockStore, "acc-1",
|
||||
allowlistGuardrail("g-a", "acc-1", "gpt-4o"),
|
||||
allowlistGuardrail("g-b", "acc-1", "claude-opus-4"),
|
||||
)
|
||||
expectProviderCatalog(mockStore, "acc-1", "prov-1", "openai_api")
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
||||
AccountID: "acc-1",
|
||||
GroupIDs: []string{"grp-a"},
|
||||
ProviderID: "prov-1",
|
||||
Model: "claude-opus-4", // only allowed for grp-b
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, res.Allow, "grp-b's allowlisted model must not leak to a grp-a caller")
|
||||
assert.Equal(t, denyCodeModelBlocked, res.DenyCode)
|
||||
}
|
||||
|
||||
// TestSelectPolicy_UndeterminedModelFailsClosed proves the fail-closed contract
|
||||
// mirrors the proxy: with a restricted applicable policy and an empty model
|
||||
// (e.g. a path-routed shape the parser couldn't map), the request is denied.
|
||||
func TestSelectPolicy_UndeterminedModelFailsClosed(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr, mockStore := newSelectorMgr(t, ctrl)
|
||||
|
||||
policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-1")
|
||||
expectPolicies(mockStore, "acc-1", policy)
|
||||
expectGuardrails(mockStore, "acc-1", allowlistGuardrail("g-1", "acc-1", "gpt-4o"))
|
||||
expectProviderCatalog(mockStore, "acc-1", "prov-1", "openai_api")
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
||||
AccountID: "acc-1",
|
||||
GroupIDs: []string{"grp-eng"},
|
||||
ProviderID: "prov-1",
|
||||
Model: "", // undetermined
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, res.Allow, "an undetermined model must fail closed against a restricted policy")
|
||||
assert.Equal(t, denyCodeModelBlocked, res.DenyCode)
|
||||
}
|
||||
|
||||
// TestSelectPolicy_DisabledAllowlistDoesNotRestrict proves a guardrail whose
|
||||
// model allowlist is disabled imposes no model restriction, even though the
|
||||
// policy references it.
|
||||
func TestSelectPolicy_DisabledAllowlistDoesNotRestrict(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr, mockStore := newSelectorMgr(t, ctrl)
|
||||
|
||||
policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-1")
|
||||
disabled := &types.Guardrail{
|
||||
ID: "g-1",
|
||||
AccountID: "acc-1",
|
||||
Checks: types.GuardrailChecks{
|
||||
ModelAllowlist: types.GuardrailModelAllowlist{Enabled: false, Models: []string{"gpt-4o"}},
|
||||
},
|
||||
}
|
||||
expectPolicies(mockStore, "acc-1", policy)
|
||||
expectGuardrails(mockStore, "acc-1", disabled)
|
||||
// Deliberately no provider expectation: with no enabled allowlist the
|
||||
// gate must skip the catalog-id lookup entirely.
|
||||
expectConsumptionBatch(mockStore, nil)
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
||||
AccountID: "acc-1",
|
||||
GroupIDs: []string{"grp-eng"},
|
||||
ProviderID: "prov-1",
|
||||
Model: "anything-goes",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Allow, "a disabled allowlist must not restrict the model")
|
||||
assert.Equal(t, "pol-A", res.SelectedPolicyID)
|
||||
}
|
||||
|
||||
// TestSelectPolicy_UnionAcrossPolicyGuardrails proves a policy with multiple
|
||||
// allowlist guardrails permits the union of their models (not just the first).
|
||||
func TestSelectPolicy_UnionAcrossPolicyGuardrails(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr, mockStore := newSelectorMgr(t, ctrl)
|
||||
|
||||
policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-1", "g-2")
|
||||
expectPolicies(mockStore, "acc-1", policy)
|
||||
expectGuardrails(mockStore, "acc-1",
|
||||
allowlistGuardrail("g-1", "acc-1", "gpt-4o"),
|
||||
allowlistGuardrail("g-2", "acc-1", "claude-opus-4"),
|
||||
)
|
||||
expectProviderCatalog(mockStore, "acc-1", "prov-1", "openai_api")
|
||||
expectConsumptionBatch(mockStore, nil)
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
||||
AccountID: "acc-1",
|
||||
GroupIDs: []string{"grp-eng"},
|
||||
ProviderID: "prov-1",
|
||||
Model: "claude-opus-4", // only in the second guardrail's list
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Allow, "a model in any of the policy's allowlist guardrails must be permitted")
|
||||
assert.Equal(t, "pol-A", res.SelectedPolicyID)
|
||||
}
|
||||
|
||||
// TestSelectPolicy_GuardrailLookupErrorPropagates proves a store failure while
|
||||
// resolving the candidate policies' guardrails surfaces as an error, not a
|
||||
// silent allow/deny.
|
||||
func TestSelectPolicy_GuardrailLookupErrorPropagates(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr, mockStore := newSelectorMgr(t, ctrl)
|
||||
|
||||
policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-1")
|
||||
expectPolicies(mockStore, "acc-1", policy)
|
||||
mockStore.EXPECT().
|
||||
GetAccountAgentNetworkGuardrails(gomock.Any(), gomock.Any(), "acc-1").
|
||||
Return(nil, errors.New("store unavailable"))
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
||||
AccountID: "acc-1",
|
||||
GroupIDs: []string{"grp-eng"},
|
||||
ProviderID: "prov-1",
|
||||
Model: "gpt-4o",
|
||||
})
|
||||
require.Error(t, err, "a guardrail-lookup failure must surface as an error")
|
||||
assert.Nil(t, res)
|
||||
}
|
||||
|
||||
// TestSelectPolicy_MissingGuardrailReferenceTreatedAsUnrestricted proves a
|
||||
// policy referencing a guardrail ID absent from the account's set (a stale
|
||||
// reference) imposes no model restriction — same as no guardrail.
|
||||
func TestSelectPolicy_MissingGuardrailReferenceTreatedAsUnrestricted(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr, mockStore := newSelectorMgr(t, ctrl)
|
||||
|
||||
policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-missing")
|
||||
expectPolicies(mockStore, "acc-1", policy)
|
||||
expectGuardrails(mockStore, "acc-1")
|
||||
// Deliberately no provider expectation: an orphaned guardrail reference
|
||||
// restricts nothing, so the gate must skip the catalog-id lookup.
|
||||
expectConsumptionBatch(mockStore, nil)
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
||||
AccountID: "acc-1",
|
||||
GroupIDs: []string{"grp-eng"},
|
||||
ProviderID: "prov-1",
|
||||
Model: "anything-goes",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Allow, "an orphaned guardrail reference must not restrict the model")
|
||||
assert.Equal(t, "pol-A", res.SelectedPolicyID)
|
||||
}
|
||||
|
||||
// TestSelectPolicy_PartialCandidatesPermittedAfterModelFilter proves the model
|
||||
// gate narrows candidates before cap scoring: the permitting policy is selected
|
||||
// even though the blocked one has a larger, more attractive cap.
|
||||
func TestSelectPolicy_PartialCandidatesPermittedAfterModelFilter(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr, mockStore := newSelectorMgr(t, ctrl)
|
||||
|
||||
polBig := guardedPolicy("pol-big", "acc-1", []string{"grp-eng"}, "prov-1", "g-restrict")
|
||||
polBig.Limits = types.PolicyLimits{
|
||||
TokenLimit: types.PolicyTokenLimit{Enabled: true, GroupCap: 1_000_000, WindowSeconds: 3600},
|
||||
}
|
||||
polSmall := guardedPolicy("pol-small", "acc-1", []string{"grp-eng"}, "prov-1", "g-permit")
|
||||
polSmall.Limits = types.PolicyLimits{
|
||||
TokenLimit: types.PolicyTokenLimit{Enabled: true, GroupCap: 100, WindowSeconds: 3600},
|
||||
}
|
||||
expectPolicies(mockStore, "acc-1", polBig, polSmall)
|
||||
expectGuardrails(mockStore, "acc-1",
|
||||
allowlistGuardrail("g-restrict", "acc-1", "gpt-4o"),
|
||||
allowlistGuardrail("g-permit", "acc-1", "claude-opus-4"),
|
||||
)
|
||||
expectProviderCatalog(mockStore, "acc-1", "prov-1", "openai_api")
|
||||
expectConsumptionBatch(mockStore, nil)
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
||||
AccountID: "acc-1",
|
||||
GroupIDs: []string{"grp-eng"},
|
||||
ProviderID: "prov-1",
|
||||
Model: "claude-opus-4", // only pol-small's guardrail permits this
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Allow)
|
||||
assert.Equal(t, "pol-small", res.SelectedPolicyID,
|
||||
"the model filter must exclude pol-big before cap scoring")
|
||||
}
|
||||
|
||||
// TestSelectPolicy_RawDeclaredAllowlistPermitsCanonicalModel proves an
|
||||
// allowlist holding the raw vendor-issued id — the form the dashboard's
|
||||
// picker copies from a provider's declared models — permits the request:
|
||||
// the parser emits the path-style canonical id, so the entry must match
|
||||
// through the same canonicalization.
|
||||
func TestSelectPolicy_RawDeclaredAllowlistPermitsCanonicalModel(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
catalog string
|
||||
entry string
|
||||
request string
|
||||
}{
|
||||
{"bedrock raw region/version form", "bedrock_api", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", "anthropic.claude-sonnet-4-5"},
|
||||
{"vertex raw @version form", "vertex_ai_api", "claude-sonnet-4-5@20250929", "claude-sonnet-4-5"},
|
||||
{"vertex raw dated @version form", "vertex_ai_api", "gpt-4o@2024-08-06", "gpt-4o"},
|
||||
{"bedrock raw form with case and whitespace", "bedrock_api", " EU.Anthropic.Claude-Sonnet-4-5-20250929-V1:0 ", "anthropic.claude-sonnet-4-5"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr, mockStore := newSelectorMgr(t, ctrl)
|
||||
|
||||
policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-1")
|
||||
expectPolicies(mockStore, "acc-1", policy)
|
||||
expectGuardrails(mockStore, "acc-1", allowlistGuardrail("g-1", "acc-1", tc.entry))
|
||||
expectProviderCatalog(mockStore, "acc-1", "prov-1", tc.catalog)
|
||||
expectConsumptionBatch(mockStore, nil)
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
||||
AccountID: "acc-1",
|
||||
UserID: "user-1",
|
||||
GroupIDs: []string{"grp-eng"},
|
||||
ProviderID: "prov-1",
|
||||
Model: tc.request,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Allow, "the raw declared allowlist entry must permit its canonical model")
|
||||
assert.Equal(t, "pol-A", res.SelectedPolicyID)
|
||||
})
|
||||
}
|
||||
|
||||
// A model outside the allowlist stays denied under the same entry shape.
|
||||
t.Run("unrelated canonical model stays denied", func(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr, mockStore := newSelectorMgr(t, ctrl)
|
||||
|
||||
policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-1")
|
||||
expectPolicies(mockStore, "acc-1", policy)
|
||||
expectGuardrails(mockStore, "acc-1", allowlistGuardrail("g-1", "acc-1", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0"))
|
||||
expectProviderCatalog(mockStore, "acc-1", "prov-1", "bedrock_api")
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
||||
AccountID: "acc-1",
|
||||
UserID: "user-1",
|
||||
GroupIDs: []string{"grp-eng"},
|
||||
ProviderID: "prov-1",
|
||||
Model: "anthropic.claude-opus-4-8",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, res.Allow, "a model the allowlist never names must stay denied")
|
||||
assert.Equal(t, denyCodeModelBlocked, res.DenyCode)
|
||||
})
|
||||
}
|
||||
|
||||
// TestSelectPolicy_PlainProviderEntriesStayVerbatim proves the canonical-form
|
||||
// compare never relaxes an allowlist on a body-routed provider: its catalog
|
||||
// id selects no normalizer, so a suffix that would be stripped under Bedrock
|
||||
// ("-v2") or Vertex ("@...") stays part of the entry and must NOT also admit
|
||||
// the stripped id — on this provider that is a different model.
|
||||
func TestSelectPolicy_PlainProviderEntriesStayVerbatim(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
entry string
|
||||
request string
|
||||
}{
|
||||
{"a -vN suffix is not a Bedrock version tag here", "claude-3-5-sonnet-v2", "claude-3-5-sonnet"},
|
||||
{"an @word suffix is not a Vertex version tag here", "custom-model@team", "custom-model"},
|
||||
{"an @digits suffix is not a Vertex version tag here", "custom-model@2024", "custom-model"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr, mockStore := newSelectorMgr(t, ctrl)
|
||||
|
||||
policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-1")
|
||||
expectPolicies(mockStore, "acc-1", policy)
|
||||
expectGuardrails(mockStore, "acc-1", allowlistGuardrail("g-1", "acc-1", tc.entry))
|
||||
expectProviderCatalog(mockStore, "acc-1", "prov-1", "openai_api")
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
||||
AccountID: "acc-1",
|
||||
UserID: "user-1",
|
||||
GroupIDs: []string{"grp-eng"},
|
||||
ProviderID: "prov-1",
|
||||
Model: tc.request,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, res.Allow, "a plain provider's allowlist entry must not widen to its stripped form")
|
||||
assert.Equal(t, denyCodeModelBlocked, res.DenyCode)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelectPolicy_MissingProviderRecordComparesVerbatim proves a provider the
|
||||
// store no longer holds degrades to the verbatim-only compare — the raw entry
|
||||
// still matches itself, and nothing widens — rather than erroring or guessing
|
||||
// a normalizer.
|
||||
func TestSelectPolicy_MissingProviderRecordComparesVerbatim(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr, mockStore := newSelectorMgr(t, ctrl)
|
||||
|
||||
policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-1")
|
||||
expectPolicies(mockStore, "acc-1", policy)
|
||||
expectGuardrails(mockStore, "acc-1", allowlistGuardrail("g-1", "acc-1", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0"))
|
||||
mockStore.EXPECT().
|
||||
GetAgentNetworkProviderByID(gomock.Any(), gomock.Any(), "acc-1", "prov-1").
|
||||
Return(nil, status.Errorf(status.NotFound, "provider not found")).
|
||||
AnyTimes()
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
||||
AccountID: "acc-1",
|
||||
UserID: "user-1",
|
||||
GroupIDs: []string{"grp-eng"},
|
||||
ProviderID: "prov-1",
|
||||
Model: "anthropic.claude-sonnet-4-5",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, res.Allow, "without the provider record the compare runs verbatim and must not widen")
|
||||
assert.Equal(t, denyCodeModelBlocked, res.DenyCode)
|
||||
}
|
||||
|
||||
// TestSelectPolicy_ProviderLookupErrorPropagates proves a store failure while
|
||||
// resolving the provider's catalog id surfaces as an error — the model gate is
|
||||
// a security decision and must not silently degrade.
|
||||
func TestSelectPolicy_ProviderLookupErrorPropagates(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr, mockStore := newSelectorMgr(t, ctrl)
|
||||
|
||||
policy := guardedPolicy("pol-A", "acc-1", []string{"grp-eng"}, "prov-1", "g-1")
|
||||
expectPolicies(mockStore, "acc-1", policy)
|
||||
expectGuardrails(mockStore, "acc-1", allowlistGuardrail("g-1", "acc-1", "gpt-4o"))
|
||||
mockStore.EXPECT().
|
||||
GetAgentNetworkProviderByID(gomock.Any(), gomock.Any(), "acc-1", "prov-1").
|
||||
Return(nil, errors.New("store unavailable"))
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
||||
AccountID: "acc-1",
|
||||
UserID: "user-1",
|
||||
GroupIDs: []string{"grp-eng"},
|
||||
ProviderID: "prov-1",
|
||||
Model: "gpt-4o",
|
||||
})
|
||||
require.Error(t, err, "a provider-lookup failure must surface as an error")
|
||||
assert.Nil(t, res)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
)
|
||||
|
||||
// This file is the no-mock regression guard for policy limit enforcement.
|
||||
// policyselect_test.go pins the same behavior through a gomock store with
|
||||
// explicit call-sequence expectations — brittle precisely where the upcoming
|
||||
// account-budget work (GC-2) refactors the cap-eval primitive and adds an
|
||||
// account-level gate. These tests drive the REAL sqlite store + REAL
|
||||
// consumption accounting and assert observable behavior (allow / deny /
|
||||
// selection / attribution), not which store methods get called. They must keep
|
||||
// passing unchanged after GC-2 lands, which is what proves "current behavior is
|
||||
// not changed."
|
||||
|
||||
const realSelectAccount = "acc-realselect-1"
|
||||
|
||||
// newRealSelectorMgr builds a managerImpl backed by a real sqlite test store.
|
||||
func newRealSelectorMgr(t *testing.T) (*managerImpl, store.Store) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||
require.NoError(t, err, "real sqlite test store must come up")
|
||||
t.Cleanup(cleanup)
|
||||
return &managerImpl{store: s}, s
|
||||
}
|
||||
|
||||
// TestSelectPolicy_RealStore_NoApplicablePolicies pins the pass-through:
|
||||
// nothing targets the (provider, groups) combination, so the selector allows
|
||||
// without attribution or consumption tracking.
|
||||
func TestSelectPolicy_RealStore_NoApplicablePolicies(t *testing.T) {
|
||||
mgr, _ := newRealSelectorMgr(t)
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
||||
AccountID: realSelectAccount,
|
||||
UserID: "user-1",
|
||||
GroupIDs: []string{"grp-x"},
|
||||
ProviderID: "prov-1",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Allow, "no applicable policy must pass through as allow")
|
||||
assert.Empty(t, res.SelectedPolicyID, "no selection when nothing applies")
|
||||
}
|
||||
|
||||
// TestSelectPolicy_RealStore_AllowAndLowestGroupAttribution pins the v1
|
||||
// attribution rule (lowest intersecting group by string sort) through the
|
||||
// real store, with a fresh (zero) consumption row.
|
||||
func TestSelectPolicy_RealStore_AllowAndLowestGroupAttribution(t *testing.T) {
|
||||
mgr, s := newRealSelectorMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
p := capPolicy("pol-A", realSelectAccount, []string{"grp-zz", "grp-aa", "grp-mm"}, "prov-1", 10_000, 86_400)
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, p))
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(ctx, PolicySelectionInput{
|
||||
AccountID: realSelectAccount,
|
||||
UserID: "user-1",
|
||||
GroupIDs: []string{"grp-zz", "grp-aa", "grp-mm"},
|
||||
ProviderID: "prov-1",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Allow, "fresh state under cap must allow")
|
||||
assert.Equal(t, "pol-A", res.SelectedPolicyID, "only applicable policy must be selected")
|
||||
assert.Equal(t, "grp-aa", res.AttributionGroupID, "lowest-by-sort intersecting group must win")
|
||||
assert.Equal(t, int64(86_400), res.WindowSeconds, "selected policy's window must be returned")
|
||||
}
|
||||
|
||||
// TestSelectPolicy_RealStore_LargerPoolWins_FallsThroughWhenExhausted pins the
|
||||
// core selection behavior end to end. The two policies bind DISTINCT groups so
|
||||
// they read separate counters — the only shape where fall-through actually
|
||||
// yields headroom (policies on the same group share one counter, as
|
||||
// policyselect_test.go notes). Larger pool wins fresh; after real consumption
|
||||
// drains the larger group, selection falls through to the smaller; once both
|
||||
// counters are exhausted the request is denied.
|
||||
func TestSelectPolicy_RealStore_LargerPoolWins_FallsThroughWhenExhausted(t *testing.T) {
|
||||
mgr, s := newRealSelectorMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
tight := capPolicy("pol-tight", realSelectAccount, []string{"grp-tight"}, "prov-1", 100, 86_400)
|
||||
tight.CreatedAt = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
wide := capPolicy("pol-wide", realSelectAccount, []string{"grp-wide"}, "prov-1", 10_000, 86_400)
|
||||
wide.CreatedAt = time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, tight))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, wide))
|
||||
|
||||
// Caller is in both groups, so both policies apply with independent counters.
|
||||
in := PolicySelectionInput{
|
||||
AccountID: realSelectAccount,
|
||||
UserID: "user-1",
|
||||
GroupIDs: []string{"grp-tight", "grp-wide"},
|
||||
ProviderID: "prov-1",
|
||||
}
|
||||
|
||||
// Fresh: larger pool wins.
|
||||
res, err := mgr.SelectPolicyForRequest(ctx, in)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "pol-wide", res.SelectedPolicyID, "larger pool drains first")
|
||||
|
||||
// Drain only the wide group's counter to its cap.
|
||||
require.NoError(t, mgr.RecordConsumption(ctx, realSelectAccount, types.DimensionGroup, "grp-wide", 86_400, 10_000, 0, 0))
|
||||
|
||||
// Wide exhausted, tight's separate counter is fresh → fall through to tight.
|
||||
res, err = mgr.SelectPolicyForRequest(ctx, in)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Allow, "tight pool has its own untouched counter")
|
||||
assert.Equal(t, "pol-tight", res.SelectedPolicyID, "selection falls through to the smaller pool once the larger is exhausted")
|
||||
|
||||
// Drain the tight group's counter too → both exhausted → deny.
|
||||
require.NoError(t, mgr.RecordConsumption(ctx, realSelectAccount, types.DimensionGroup, "grp-tight", 86_400, 100, 0, 0))
|
||||
res, err = mgr.SelectPolicyForRequest(ctx, in)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, res.Allow, "both group counters exhausted must deny")
|
||||
assert.Equal(t, denyCodeTokenCapExceeded, res.DenyCode, "deny code names the offending cap kind")
|
||||
}
|
||||
|
||||
// TestSelectPolicy_RealStore_BudgetCapDenies pins budget (USD) enforcement
|
||||
// through the real store: once recorded cost reaches the cap, deny.
|
||||
func TestSelectPolicy_RealStore_BudgetCapDenies(t *testing.T) {
|
||||
mgr, s := newRealSelectorMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
p := &types.Policy{
|
||||
ID: "pol-budget",
|
||||
AccountID: realSelectAccount,
|
||||
Enabled: true,
|
||||
SourceGroups: []string{"grp-eng"},
|
||||
DestinationProviderIDs: []string{"prov-1"},
|
||||
Limits: types.PolicyLimits{
|
||||
BudgetLimit: types.PolicyBudgetLimit{
|
||||
Enabled: true,
|
||||
GroupCapUsd: 5.0,
|
||||
WindowSeconds: 86_400,
|
||||
},
|
||||
},
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, p))
|
||||
|
||||
in := PolicySelectionInput{
|
||||
AccountID: realSelectAccount,
|
||||
UserID: "user-1",
|
||||
GroupIDs: []string{"grp-eng"},
|
||||
ProviderID: "prov-1",
|
||||
}
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(ctx, in)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Allow, "fresh budget must allow")
|
||||
|
||||
require.NoError(t, mgr.RecordConsumption(ctx, realSelectAccount, types.DimensionGroup, "grp-eng", 86_400, 0, 0, 5.0))
|
||||
|
||||
res, err = mgr.SelectPolicyForRequest(ctx, in)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, res.Allow, "cost at the cap must deny")
|
||||
assert.Equal(t, denyCodeBudgetCapExceeded, res.DenyCode, "budget deny code must be surfaced")
|
||||
}
|
||||
|
||||
// TestSelectPolicy_RealStore_GroupCounterSharedAcrossPolicies pins that two
|
||||
// policies on the same group+window read one shared consumption counter: usage
|
||||
// recorded once is visible to both, so exhausting the group budget denies
|
||||
// regardless of which policy would attribute.
|
||||
func TestSelectPolicy_RealStore_GroupCounterSharedAcrossPolicies(t *testing.T) {
|
||||
mgr, s := newRealSelectorMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
a := capPolicy("pol-a", realSelectAccount, []string{"grp-eng"}, "prov-1", 1_000, 86_400)
|
||||
b := capPolicy("pol-b", realSelectAccount, []string{"grp-eng"}, "prov-1", 1_000, 86_400)
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, a))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, b))
|
||||
|
||||
in := PolicySelectionInput{
|
||||
AccountID: realSelectAccount,
|
||||
UserID: "user-1",
|
||||
GroupIDs: []string{"grp-eng"},
|
||||
ProviderID: "prov-1",
|
||||
}
|
||||
|
||||
require.NoError(t, mgr.RecordConsumption(ctx, realSelectAccount, types.DimensionGroup, "grp-eng", 86_400, 1_000, 0, 0))
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(ctx, in)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, res.Allow, "shared group counter at cap denies both equal policies")
|
||||
assert.Equal(t, denyCodeTokenCapExceeded, res.DenyCode, "token deny code on the shared counter")
|
||||
}
|
||||
|
||||
// TestSelectPolicy_RealStore_DisabledPolicyIgnored pins that a disabled policy
|
||||
// is invisible to selection even when it otherwise matches.
|
||||
func TestSelectPolicy_RealStore_DisabledPolicyIgnored(t *testing.T) {
|
||||
mgr, s := newRealSelectorMgr(t)
|
||||
ctx := context.Background()
|
||||
|
||||
p := capPolicy("pol-disabled", realSelectAccount, []string{"grp-eng"}, "prov-1", 10_000, 86_400)
|
||||
p.Enabled = false
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, p))
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(ctx, PolicySelectionInput{
|
||||
AccountID: realSelectAccount,
|
||||
UserID: "user-1",
|
||||
GroupIDs: []string{"grp-eng"},
|
||||
ProviderID: "prov-1",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Allow, "no enabled policy applies → pass-through allow")
|
||||
assert.Empty(t, res.SelectedPolicyID, "disabled policy must not be selected")
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
nbstatus "github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
func newSelectorMgr(t *testing.T, ctrl *gomock.Controller) (*managerImpl, *store.MockStore) {
|
||||
t.Helper()
|
||||
mockStore := store.NewMockStore(ctrl)
|
||||
// SelectPolicyForRequest evaluates the account-budget ceiling before policy
|
||||
// selection. These policy-selection tests don't exercise account rules, so
|
||||
// default to "no rules" — the no-mock policyselect_realstore_test.go covers
|
||||
// the account gate's behavior end to end.
|
||||
mockStore.EXPECT().
|
||||
GetAccountAgentNetworkBudgetRules(gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
Return(nil, nil).
|
||||
AnyTimes()
|
||||
return &managerImpl{store: mockStore}, mockStore
|
||||
}
|
||||
|
||||
type usedKey struct {
|
||||
kind types.ConsumptionDimension
|
||||
dimID string
|
||||
window int64
|
||||
}
|
||||
|
||||
// expectConsumptionBatch stubs the batched consumption read to return the
|
||||
// supplied per-(kind, dim, window) counters, filling each row's window start
|
||||
// from the actual request keys so it always matches what the selector computed.
|
||||
// Keys absent from used resolve to zero counters.
|
||||
func expectConsumptionBatch(mockStore *store.MockStore, used map[usedKey]*types.Consumption) {
|
||||
mockStore.EXPECT().
|
||||
GetAgentNetworkConsumptionBatch(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
DoAndReturn(func(_ context.Context, _ store.LockingStrength, _ string, keys []types.ConsumptionKey) (map[types.ConsumptionKey]*types.Consumption, error) {
|
||||
out := make(map[types.ConsumptionKey]*types.Consumption)
|
||||
for _, k := range keys {
|
||||
if row, ok := used[usedKey{k.Kind, k.DimID, k.WindowSeconds}]; ok {
|
||||
rc := *row
|
||||
rc.WindowStartUTC = k.WindowStartUTC
|
||||
out[k] = &rc
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}).
|
||||
AnyTimes()
|
||||
}
|
||||
|
||||
func capPolicy(id, account string, sourceGroups []string, providerID string, tokenCap int64, windowSec int64) *types.Policy {
|
||||
return &types.Policy{
|
||||
ID: id,
|
||||
AccountID: account,
|
||||
Enabled: true,
|
||||
SourceGroups: sourceGroups,
|
||||
DestinationProviderIDs: []string{providerID},
|
||||
Limits: types.PolicyLimits{
|
||||
TokenLimit: types.PolicyTokenLimit{
|
||||
Enabled: true,
|
||||
GroupCap: tokenCap,
|
||||
WindowSeconds: windowSec,
|
||||
},
|
||||
},
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelectPolicy_NoApplicablePolicies covers the pass-through path:
|
||||
// llm_router authorisation is upstream of selection; when the
|
||||
// selector finds no policy targeting the (provider, caller-groups)
|
||||
// combination, it returns Allow with no attribution and lets the
|
||||
// request continue without consumption tracking.
|
||||
func TestSelectPolicy_NoApplicablePolicies(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr, mockStore := newSelectorMgr(t, ctrl)
|
||||
|
||||
mockStore.EXPECT().
|
||||
GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1").
|
||||
Return([]*types.Policy{}, nil)
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
||||
AccountID: "acc-1",
|
||||
UserID: "user-1",
|
||||
GroupIDs: []string{"grp-x"},
|
||||
ProviderID: "prov-1",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Allow, "no applicable policies = pass-through allow")
|
||||
assert.Empty(t, res.SelectedPolicyID, "no selection when nothing applies")
|
||||
}
|
||||
|
||||
// TestSelectPolicy_AllowWithLowestGroupAttribution proves the v1
|
||||
// attribution rule: when the caller's groups intersect a policy's
|
||||
// source_groups in multiple positions, the selector picks the lowest
|
||||
// group id by string sort so multi-node selection converges.
|
||||
func TestSelectPolicy_AllowWithLowestGroupAttribution(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr, mockStore := newSelectorMgr(t, ctrl)
|
||||
|
||||
policy := capPolicy("pol-A", "acc-1", []string{"grp-zz", "grp-aa", "grp-mm"}, "prov-1", 10_000, 86_400)
|
||||
|
||||
mockStore.EXPECT().
|
||||
GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1").
|
||||
Return([]*types.Policy{policy}, nil)
|
||||
// Fresh: zero consumption across the board.
|
||||
expectConsumptionBatch(mockStore, nil)
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
||||
AccountID: "acc-1",
|
||||
UserID: "user-1",
|
||||
GroupIDs: []string{"grp-zz", "grp-aa", "grp-mm"},
|
||||
ProviderID: "prov-1",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Allow)
|
||||
assert.Equal(t, "pol-A", res.SelectedPolicyID)
|
||||
assert.Equal(t, "grp-aa", res.AttributionGroupID,
|
||||
"lowest-by-sort intersection wins so multi-node selection converges")
|
||||
assert.Equal(t, int64(86_400), res.WindowSeconds)
|
||||
}
|
||||
|
||||
// TestSelectPolicy_LargerPoolWinsAcrossUsageLevels proves the core
|
||||
// selection rule: among multiple applicable policies with caps, the
|
||||
// selector picks the one with the larger absolute pool — at every
|
||||
// usage level, not just at fresh state. The smaller-pool policy is
|
||||
// only reached when the larger one is exhausted. This is the
|
||||
// "drain biggest first" semantic operators expect for layered
|
||||
// tiers; a fraction-based score would flap between the two as
|
||||
// soon as one is partially used.
|
||||
func TestSelectPolicy_LargerPoolWinsAcrossUsageLevels(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr, mockStore := newSelectorMgr(t, ctrl)
|
||||
|
||||
tight := capPolicy("pol-tight", "acc-1", []string{"grp-engineers"}, "prov-1", 100, 86_400)
|
||||
tight.CreatedAt = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
wide := capPolicy("pol-wide", "acc-1", []string{"grp-engineers"}, "prov-1", 10_000, 86_400)
|
||||
wide.CreatedAt = time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
mockStore.EXPECT().
|
||||
GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1").
|
||||
Return([]*types.Policy{tight, wide}, nil)
|
||||
|
||||
// Both partially used. tight at 50/100 (50% used); wide at
|
||||
// 50/10000 (0.5% used). Old fraction-based algo would pick wide
|
||||
// here too — but for the wrong reason ("more relative slack").
|
||||
// New algo picks wide because its initial group cap is bigger
|
||||
// (10000 > 100), and that decision is stable as wide drains.
|
||||
expectConsumptionBatch(mockStore, map[usedKey]*types.Consumption{
|
||||
{types.DimensionGroup, "grp-engineers", 86_400}: {TokensInput: 50},
|
||||
})
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
||||
AccountID: "acc-1",
|
||||
UserID: "user-1",
|
||||
GroupIDs: []string{"grp-engineers"},
|
||||
ProviderID: "prov-1",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "pol-wide", res.SelectedPolicyID,
|
||||
"the policy with the bigger initial pool wins — operators expect 'drain the privileged tier first', not load-balance across tiers")
|
||||
}
|
||||
|
||||
// TestSelectPolicy_StaysOnLargerPoolAfterPartialDrain locks the
|
||||
// stickiness contract reported by operators: with two policies
|
||||
// where A has a 200-token group cap and B has 150, the very first
|
||||
// request goes to A AND every subsequent request continues to land
|
||||
// on A until A's group cap is exhausted — at which point B becomes
|
||||
// the only candidate. A fraction-based score would flap to B as
|
||||
// soon as A had any consumption (B's 1.0 fraction beats A's 0.75)
|
||||
// even though A still has more absolute headroom; that produced
|
||||
// confusing per-policy attribution ledger entries and stranded
|
||||
// A's remaining capacity behind B's exhaustion.
|
||||
func TestSelectPolicy_StaysOnLargerPoolAfterPartialDrain(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr, mockStore := newSelectorMgr(t, ctrl)
|
||||
|
||||
policyA := capPolicy("pol-A-200", "acc-1", []string{"grp-engineers"}, "prov-1", 200, 86_400)
|
||||
policyB := capPolicy("pol-B-150", "acc-1", []string{"grp-engineers"}, "prov-1", 150, 86_400)
|
||||
|
||||
mockStore.EXPECT().
|
||||
GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1").
|
||||
Return([]*types.Policy{policyA, policyB}, nil)
|
||||
|
||||
// A is partially drained (50/200 used = 25% used; 75% headroom
|
||||
// remaining). B is fresh (0/150). The old fraction-based score
|
||||
// would pick B here (1.0 > 0.75 fraction); the new pool-size
|
||||
// score sticks with A (200 > 150 absolute cap).
|
||||
expectConsumptionBatch(mockStore, map[usedKey]*types.Consumption{
|
||||
{types.DimensionGroup, "grp-engineers", 86_400}: {TokensInput: 50},
|
||||
})
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
||||
AccountID: "acc-1",
|
||||
UserID: "user-1",
|
||||
GroupIDs: []string{"grp-engineers"},
|
||||
ProviderID: "prov-1",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "pol-A-200", res.SelectedPolicyID,
|
||||
"once attribution lands on the bigger pool it must STAY there until exhausted — operators expect 'drain A then B', not 'flip to B as soon as A is touched'")
|
||||
}
|
||||
|
||||
// TestSelectPolicy_FallsThroughToSmallerPoolWhenLargerExhausted
|
||||
// proves the second half of the stickiness contract: once the
|
||||
// larger-pool policy IS exhausted, the smaller one takes over.
|
||||
// Without this we'd deny on requests the smaller policy is fully
|
||||
// equipped to serve.
|
||||
func TestSelectPolicy_FallsThroughToSmallerPoolWhenLargerExhausted(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr, mockStore := newSelectorMgr(t, ctrl)
|
||||
|
||||
policyA := capPolicy("pol-A-200", "acc-1", []string{"grp-engineers"}, "prov-1", 200, 86_400)
|
||||
// B uses a different window length so it has an INDEPENDENT counter — the
|
||||
// realistic shape for fall-through. On the SAME (group, window) tuple the
|
||||
// counter is shared, so A's cap of 200 being reached would also exhaust B's
|
||||
// 150; independent counters are what let A exhaust while B retains headroom.
|
||||
policyB := capPolicy("pol-B-150", "acc-1", []string{"grp-engineers"}, "prov-1", 150, 3_600)
|
||||
|
||||
mockStore.EXPECT().
|
||||
GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1").
|
||||
Return([]*types.Policy{policyA, policyB}, nil)
|
||||
|
||||
expectConsumptionBatch(mockStore, map[usedKey]*types.Consumption{
|
||||
{types.DimensionGroup, "grp-engineers", 86_400}: {TokensInput: 200}, // A: 200 >= 200 → exhausted
|
||||
{types.DimensionGroup, "grp-engineers", 3_600}: {TokensInput: 100}, // B: 100 < 150 → headroom
|
||||
})
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
||||
AccountID: "acc-1",
|
||||
UserID: "user-1",
|
||||
GroupIDs: []string{"grp-engineers"},
|
||||
ProviderID: "prov-1",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "pol-B-150", res.SelectedPolicyID,
|
||||
"once the bigger pool is exhausted, the smaller one must take over — denying when capacity remains would strand B's allowance")
|
||||
}
|
||||
|
||||
// TestSelectPolicy_TiebreakByLargerGroupPool covers the user-reported
|
||||
// bug: an admin in two groups (Users + Admins) where Users is bound
|
||||
// by a smaller-group-cap policy (50 group, 100 user) and Admins is
|
||||
// bound by a bigger-group-cap policy (100 group, 20 user) MUST get
|
||||
// attributed to the Admins policy on the first request.
|
||||
//
|
||||
// Without this rule, the fresh-state fraction is 1.0 for both and
|
||||
// the older policy wins by created_at. The first 24-token request
|
||||
// then drains the shared user counter past Admins's tight 20-token
|
||||
// user cap, locking Admins out of selection forever. The 100-token
|
||||
// Admins group pool ends up stranded while requests pile onto the
|
||||
// 50-token Users pool — the opposite of what the operator intended
|
||||
// when they put the bigger pool on the privileged group.
|
||||
func TestSelectPolicy_TiebreakByLargerGroupPool(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr, mockStore := newSelectorMgr(t, ctrl)
|
||||
|
||||
// Policy A: Users group, smaller group pool, looser per-user cap.
|
||||
policyA := &types.Policy{
|
||||
ID: "pol-Users",
|
||||
AccountID: "acc-1",
|
||||
Enabled: true,
|
||||
SourceGroups: []string{"grp-Users"},
|
||||
DestinationProviderIDs: []string{"prov-1"},
|
||||
Limits: types.PolicyLimits{
|
||||
TokenLimit: types.PolicyTokenLimit{
|
||||
Enabled: true, GroupCap: 50, UserCap: 100, WindowSeconds: 86_400,
|
||||
},
|
||||
},
|
||||
// Older — would win the legacy created_at tiebreak.
|
||||
CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
// Policy B: Admins group, bigger group pool, tighter per-user cap.
|
||||
policyB := &types.Policy{
|
||||
ID: "pol-Admins",
|
||||
AccountID: "acc-1",
|
||||
Enabled: true,
|
||||
SourceGroups: []string{"grp-Admins"},
|
||||
DestinationProviderIDs: []string{"prov-1"},
|
||||
Limits: types.PolicyLimits{
|
||||
TokenLimit: types.PolicyTokenLimit{
|
||||
Enabled: true, GroupCap: 100, UserCap: 20, WindowSeconds: 86_400,
|
||||
},
|
||||
},
|
||||
CreatedAt: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
mockStore.EXPECT().
|
||||
GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1").
|
||||
Return([]*types.Policy{policyA, policyB}, nil)
|
||||
// Fresh state: every cap evaluation reads zero usage.
|
||||
expectConsumptionBatch(mockStore, nil)
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
||||
AccountID: "acc-1",
|
||||
UserID: "user-1",
|
||||
GroupIDs: []string{"grp-Users", "grp-Admins"},
|
||||
ProviderID: "prov-1",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "pol-Admins", res.SelectedPolicyID,
|
||||
"the bigger group pool wins the fresh-state tiebreak — picking Users first would burn the shared user counter past Admins's tight user cap on the very first request and strand the bigger Admins pool")
|
||||
assert.Equal(t, "grp-Admins", res.AttributionGroupID)
|
||||
}
|
||||
|
||||
// TestSelectPolicy_TiebreakByCreatedAt proves the deterministic
|
||||
// final tiebreak: when two applicable policies have the same
|
||||
// headroom fraction AND the same group cap (so the larger-pool rule
|
||||
// can't differentiate either), the older policy wins so attribution
|
||||
// is stable across replays.
|
||||
func TestSelectPolicy_TiebreakByCreatedAt(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr, mockStore := newSelectorMgr(t, ctrl)
|
||||
|
||||
older := capPolicy("pol-old", "acc-1", []string{"grp-engineers"}, "prov-1", 1_000, 86_400)
|
||||
older.CreatedAt = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
newer := capPolicy("pol-new", "acc-1", []string{"grp-engineers"}, "prov-1", 1_000, 86_400)
|
||||
newer.CreatedAt = time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
mockStore.EXPECT().
|
||||
GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1").
|
||||
Return([]*types.Policy{newer, older}, nil)
|
||||
// Both at zero consumption → identical headroom fraction.
|
||||
expectConsumptionBatch(mockStore, nil)
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
||||
AccountID: "acc-1",
|
||||
GroupIDs: []string{"grp-engineers"},
|
||||
ProviderID: "prov-1",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "pol-old", res.SelectedPolicyID,
|
||||
"older policy wins on equal-headroom tiebreak so attribution is stable across replays")
|
||||
}
|
||||
|
||||
// TestSelectPolicy_DeniesWhenAllExhausted proves the deny envelope:
|
||||
// when every applicable policy has at least one cap fully exhausted,
|
||||
// the selector returns Allow=false with the most-recent exhaustion's
|
||||
// deny code + human reason. The proxy's middleware surfaces this as
|
||||
// a 403 with the canonical llm_policy.* code.
|
||||
func TestSelectPolicy_DeniesWhenAllExhausted(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr, mockStore := newSelectorMgr(t, ctrl)
|
||||
|
||||
a := capPolicy("pol-a", "acc-1", []string{"grp-engineers"}, "prov-1", 100, 86_400)
|
||||
b := capPolicy("pol-b", "acc-1", []string{"grp-engineers"}, "prov-1", 200, 86_400)
|
||||
mockStore.EXPECT().
|
||||
GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1").
|
||||
Return([]*types.Policy{a, b}, nil)
|
||||
|
||||
// Shared group counter at 200: A (cap 100) and B (cap 200) both exhausted.
|
||||
expectConsumptionBatch(mockStore, map[usedKey]*types.Consumption{
|
||||
{types.DimensionGroup, "grp-engineers", 86_400}: {TokensInput: 200},
|
||||
})
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
||||
AccountID: "acc-1",
|
||||
GroupIDs: []string{"grp-engineers"},
|
||||
ProviderID: "prov-1",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, res.Allow, "every applicable policy exhausted = deny")
|
||||
assert.Equal(t, denyCodeTokenCapExceeded, res.DenyCode)
|
||||
assert.Contains(t, res.DenyReason, "token cap exhausted",
|
||||
"deny reason must name the exhausted cap kind for operator debugging")
|
||||
}
|
||||
|
||||
// TestSelectPolicy_UncappedPolicyAlwaysWinsAgainstCapped proves the
|
||||
// catch-all-allow contract: a policy with NO enabled caps wins
|
||||
// against any capped policy regardless of how much headroom the
|
||||
// capped one has, because operators who configure unlimited access
|
||||
// expect requests to attribute there until they explicitly add caps.
|
||||
func TestSelectPolicy_UncappedPolicyAlwaysWinsAgainstCapped(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr, mockStore := newSelectorMgr(t, ctrl)
|
||||
|
||||
uncapped := &types.Policy{
|
||||
ID: "pol-uncapped",
|
||||
AccountID: "acc-1",
|
||||
Enabled: true,
|
||||
SourceGroups: []string{"grp-engineers"},
|
||||
DestinationProviderIDs: []string{"prov-1"},
|
||||
// All Limits.*.Enabled = false (zero-value).
|
||||
CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
wide := capPolicy("pol-wide", "acc-1", []string{"grp-engineers"}, "prov-1", 1_000_000, 86_400)
|
||||
wide.CreatedAt = time.Date(2025, 12, 1, 0, 0, 0, 0, time.UTC) // older than uncapped
|
||||
|
||||
mockStore.EXPECT().
|
||||
GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1").
|
||||
Return([]*types.Policy{uncapped, wide}, nil)
|
||||
// Only the wide policy reads consumption; uncapped doesn't query
|
||||
// because it has no enabled caps.
|
||||
expectConsumptionBatch(mockStore, nil)
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
||||
AccountID: "acc-1",
|
||||
GroupIDs: []string{"grp-engineers"},
|
||||
ProviderID: "prov-1",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "pol-uncapped", res.SelectedPolicyID,
|
||||
"a no-caps policy must always win selection — that's how operators express 'unlimited access through this path'")
|
||||
assert.Equal(t, int64(0), res.WindowSeconds, "no caps configured = WindowSeconds=0 so RecordLLMUsage skips counter writes")
|
||||
}
|
||||
|
||||
// TestSelectPolicy_DisabledPolicyIgnored proves disabled policies
|
||||
// don't count toward selection — even when they'd otherwise be the
|
||||
// best match. Operators disable a policy to take it offline; the
|
||||
// selector must respect that and route through whatever's left.
|
||||
func TestSelectPolicy_DisabledPolicyIgnored(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr, mockStore := newSelectorMgr(t, ctrl)
|
||||
|
||||
disabled := capPolicy("pol-disabled", "acc-1", []string{"grp-engineers"}, "prov-1", 1_000_000, 86_400)
|
||||
disabled.Enabled = false
|
||||
enabled := capPolicy("pol-enabled", "acc-1", []string{"grp-engineers"}, "prov-1", 100, 86_400)
|
||||
|
||||
mockStore.EXPECT().
|
||||
GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1").
|
||||
Return([]*types.Policy{disabled, enabled}, nil)
|
||||
expectConsumptionBatch(mockStore, nil)
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
||||
AccountID: "acc-1",
|
||||
GroupIDs: []string{"grp-engineers"},
|
||||
ProviderID: "prov-1",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "pol-enabled", res.SelectedPolicyID,
|
||||
"disabled policies must be ignored at selection time")
|
||||
}
|
||||
|
||||
// TestSelectPolicy_StoreErrorPropagates locks the no-fail-open
|
||||
// contract: a transient store error must surface to the caller, not
|
||||
// be silently treated as "no policies = allow". A false allow on the
|
||||
// hot path would let a request slip past every cap.
|
||||
func TestSelectPolicy_StoreErrorPropagates(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr, mockStore := newSelectorMgr(t, ctrl)
|
||||
|
||||
mockStore.EXPECT().
|
||||
GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1").
|
||||
Return(nil, errors.New("boom"))
|
||||
|
||||
_, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
||||
AccountID: "acc-1",
|
||||
})
|
||||
require.Error(t, err, "store errors must surface — never fail open on the hot path")
|
||||
}
|
||||
|
||||
// TestSelectPolicy_RejectsEmptyAccount is the input-validation guard:
|
||||
// empty account_id is a programmer error and must surface as
|
||||
// InvalidArgument, not as a silent zero-result lookup.
|
||||
func TestSelectPolicy_RejectsEmptyAccount(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr, _ := newSelectorMgr(t, ctrl)
|
||||
|
||||
_, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{})
|
||||
require.Error(t, err)
|
||||
var sErr *nbstatus.Error
|
||||
require.True(t, errors.As(err, &sErr))
|
||||
assert.Equal(t, nbstatus.InvalidArgument, sErr.Type())
|
||||
}
|
||||
|
||||
// TestSelectPolicy_SharesGroupCounterAcrossPolicies locks the
|
||||
// counter-keying design fork: counters are keyed on (account,
|
||||
// dim_kind, dim_id, window_hours, window_start) — NOT on policy_id.
|
||||
// Two policies that target the same group with the SAME window length
|
||||
// share one bucket: spend booked under policy A is visible to policy
|
||||
// B's headroom calculation and counts toward B's cap.
|
||||
//
|
||||
// This is what makes "operator's per-group enforcement" sane — caps
|
||||
// describe how much a GROUP can use, not how much each policy owes.
|
||||
func TestSelectPolicy_SharesGroupCounterAcrossPolicies(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr, mockStore := newSelectorMgr(t, ctrl)
|
||||
|
||||
// Two policies, both targeting grp-engineers + prov-1, same 24h
|
||||
// window length. Different cap sizes.
|
||||
policyA := capPolicy("pol-A", "acc-1", []string{"grp-engineers"}, "prov-1", 1_000, 86_400)
|
||||
policyB := capPolicy("pol-B", "acc-1", []string{"grp-engineers"}, "prov-1", 5_000, 86_400)
|
||||
|
||||
mockStore.EXPECT().
|
||||
GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1").
|
||||
Return([]*types.Policy{policyA, policyB}, nil)
|
||||
// Both policies query the SAME consumption row — same dim_id,
|
||||
// same window_hours, same window_start. The mock returns the
|
||||
// same row for both calls, simulating the shared counter.
|
||||
expectConsumptionBatch(mockStore, map[usedKey]*types.Consumption{
|
||||
{types.DimensionGroup, "grp-engineers", 86_400}: {TokensInput: 800},
|
||||
})
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
||||
AccountID: "acc-1",
|
||||
GroupIDs: []string{"grp-engineers"},
|
||||
ProviderID: "prov-1",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// 800 used → policy A has 200 tokens left of 1000 (20% headroom);
|
||||
// policy B has 4200 left of 5000 (84% headroom). B wins.
|
||||
assert.Equal(t, "pol-B", res.SelectedPolicyID,
|
||||
"the SAME 800 tokens count toward both policies — counters share the (group, window) key, caps differ per policy")
|
||||
}
|
||||
|
||||
// TestSelectPolicy_AntiFallThroughOnLowestGroup locks the no-fall-
|
||||
// through behaviour: when a caller is in multiple of a policy's
|
||||
// source_groups and the lowest-by-sort group is exhausted, we DENY
|
||||
// rather than fall through to a less-loaded sibling. Per-group caps
|
||||
// are independent (each group has its own bucket), but attribution
|
||||
// is one-shot — operators wanting fall-through must split into
|
||||
// separate policies.
|
||||
//
|
||||
// This nails down semantics future contributors might "improve" into
|
||||
// fall-through behaviour by accident.
|
||||
func TestSelectPolicy_AntiFallThroughOnLowestGroup(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr, mockStore := newSelectorMgr(t, ctrl)
|
||||
|
||||
// Policy targets two groups; caller is in both.
|
||||
policy := capPolicy("pol-1", "acc-1", []string{"grp-aaa", "grp-bbb"}, "prov-1", 100, 86_400)
|
||||
mockStore.EXPECT().
|
||||
GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1").
|
||||
Return([]*types.Policy{policy}, nil)
|
||||
|
||||
// grp-aaa is the lowest by sort → attribution picks it, and the
|
||||
// prefetch only collects the attribution group's key. We exhaust
|
||||
// grp-aaa (100/100); grp-bbb's counter is never requested because the
|
||||
// selector attributes one-shot to the lowest group, so it can't fall
|
||||
// through to a less-loaded sibling.
|
||||
expectConsumptionBatch(mockStore, map[usedKey]*types.Consumption{
|
||||
{types.DimensionGroup, "grp-aaa", 86_400}: {TokensInput: 100},
|
||||
})
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
||||
AccountID: "acc-1",
|
||||
GroupIDs: []string{"grp-aaa", "grp-bbb"},
|
||||
ProviderID: "prov-1",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, res.Allow,
|
||||
"lowest-group-by-sort attribution does NOT fall through to a less-loaded sibling — operators wanting fall-through must split into separate policies")
|
||||
assert.Equal(t, denyCodeTokenCapExceeded, res.DenyCode)
|
||||
assert.Contains(t, res.DenyReason, "pol-1",
|
||||
"deny reason names the exhausted policy id so operators can grep it from the access log")
|
||||
}
|
||||
|
||||
// TestSelectPolicy_BudgetOnlyExhaustionDenies covers the symmetric
|
||||
// path to TestSelectPolicy_DeniesWhenAllExhausted but for the budget
|
||||
// cap: a policy with token_limit DISABLED and budget_limit at-cap
|
||||
// must deny with llm_policy.budget_cap_exceeded (not the token code).
|
||||
//
|
||||
// Without this, the budget evaluation path in evalBudgetCap could
|
||||
// silently regress and we'd still pass DeniesWhenAllExhausted (which
|
||||
// only exercises tokens).
|
||||
func TestSelectPolicy_BudgetOnlyExhaustionDenies(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr, mockStore := newSelectorMgr(t, ctrl)
|
||||
|
||||
policy := &types.Policy{
|
||||
ID: "pol-budget",
|
||||
AccountID: "acc-1",
|
||||
Enabled: true,
|
||||
SourceGroups: []string{"grp-engineers"},
|
||||
DestinationProviderIDs: []string{"prov-1"},
|
||||
Limits: types.PolicyLimits{
|
||||
TokenLimit: types.PolicyTokenLimit{Enabled: false},
|
||||
BudgetLimit: types.PolicyBudgetLimit{
|
||||
Enabled: true,
|
||||
GroupCapUsd: 10.00,
|
||||
WindowSeconds: 86_400,
|
||||
},
|
||||
},
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
mockStore.EXPECT().
|
||||
GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1").
|
||||
Return([]*types.Policy{policy}, nil)
|
||||
expectConsumptionBatch(mockStore, map[usedKey]*types.Consumption{
|
||||
{types.DimensionGroup, "grp-engineers", 86_400}: {CostUSD: 10.50}, // over the $10 cap
|
||||
})
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
||||
AccountID: "acc-1",
|
||||
GroupIDs: []string{"grp-engineers"},
|
||||
ProviderID: "prov-1",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, res.Allow, "budget cap exhausted must deny independently of any token cap state")
|
||||
assert.Equal(t, denyCodeBudgetCapExceeded, res.DenyCode,
|
||||
"deny code must be the budget code — token-only deny would silently regress the budget evaluation path")
|
||||
assert.Contains(t, res.DenyReason, "budget", "deny reason names the budget cap kind for operator debugging")
|
||||
}
|
||||
|
||||
// TestSelectPolicy_BudgetTighterThanTokenWins is the dual-cap headroom
|
||||
// fork: when both Token and Budget are enabled on the same policy,
|
||||
// the SMALLER remaining ratio gates the policy. A policy with
|
||||
// abundant token headroom but near-zero budget headroom must deny on
|
||||
// budget, not pass on tokens.
|
||||
func TestSelectPolicy_BudgetTighterThanTokenWins(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mgr, mockStore := newSelectorMgr(t, ctrl)
|
||||
|
||||
policy := &types.Policy{
|
||||
ID: "pol-dual",
|
||||
AccountID: "acc-1",
|
||||
Enabled: true,
|
||||
SourceGroups: []string{"grp-engineers"},
|
||||
DestinationProviderIDs: []string{"prov-1"},
|
||||
Limits: types.PolicyLimits{
|
||||
TokenLimit: types.PolicyTokenLimit{Enabled: true, GroupCap: 10_000_000, WindowSeconds: 86_400},
|
||||
BudgetLimit: types.PolicyBudgetLimit{Enabled: true, GroupCapUsd: 1.00, WindowSeconds: 86_400},
|
||||
},
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
mockStore.EXPECT().
|
||||
GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1").
|
||||
Return([]*types.Policy{policy}, nil)
|
||||
// One shared counter carries both token usage (ample headroom) and cost
|
||||
// (at the $1 budget cap); the tighter budget cap gates the policy.
|
||||
expectConsumptionBatch(mockStore, map[usedKey]*types.Consumption{
|
||||
{types.DimensionGroup, "grp-engineers", 86_400}: {TokensInput: 100, CostUSD: 1.00},
|
||||
})
|
||||
|
||||
res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{
|
||||
AccountID: "acc-1",
|
||||
GroupIDs: []string{"grp-engineers"},
|
||||
ProviderID: "prov-1",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, res.Allow,
|
||||
"the tighter of (token, budget) wins — abundant token headroom must NOT mask an exhausted budget")
|
||||
assert.Equal(t, denyCodeBudgetCapExceeded, res.DenyCode)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Package pricing builds the default LLM pricing table the synthesizer
|
||||
// ships to the proxy's cost_meter middleware. The catalog is the single
|
||||
// source of default rates: every catalog provider's models are folded
|
||||
// into the pricing surfaces the provider declares (PricingSurfaces),
|
||||
// then a small supplemental list adds priced-but-not-operator-selectable
|
||||
// entries. Management is the sole pricing authority — the proxy carries
|
||||
// no embedded price list and bills exclusively from the table it is
|
||||
// sent.
|
||||
//go:generate go run gen.go
|
||||
|
||||
package pricing
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
|
||||
)
|
||||
|
||||
// Entry is a single model's pricing in USD per 1k tokens. This struct IS
|
||||
// the wire shape: the synthesizer marshals it verbatim into cost_meter's
|
||||
// ConfigJSON, and the proxy unmarshals the same field names.
|
||||
//
|
||||
// A zero rate means "no rate configured" — the proxy bills that cache
|
||||
// bucket at InputPer1k (identical semantics to the retired proxy-embedded
|
||||
// table). CachedInputPer1k is the OpenAI shape (cached prompt tokens are
|
||||
// a subset of input); CacheReadPer1k / CacheCreationPer1k are the
|
||||
// Anthropic shape (additive buckets).
|
||||
type Entry struct {
|
||||
InputPer1k float64 `json:"input_per_1k"`
|
||||
OutputPer1k float64 `json:"output_per_1k"`
|
||||
CachedInputPer1k float64 `json:"cached_input_per_1k,omitempty"`
|
||||
CacheReadPer1k float64 `json:"cache_read_per_1k,omitempty"`
|
||||
CacheCreationPer1k float64 `json:"cache_creation_per_1k,omitempty"`
|
||||
}
|
||||
|
||||
// supplementalDefaults are (surface, model) entries that are priced but
|
||||
// deliberately not operator-selectable in the catalog. Each carries a
|
||||
// reason; when one of these models joins a catalog lineup, delete the
|
||||
// row here — the collision test fails loudly if the rates ever disagree.
|
||||
var supplementalDefaults = map[string]map[string]Entry{
|
||||
"openai": {
|
||||
// GPT-5 (2025) family — kept for gateway requests using the
|
||||
// unsuffixed ids; the dashboard offers only the 5.x lineup.
|
||||
"gpt-5": {InputPer1k: 0.00125, OutputPer1k: 0.01, CachedInputPer1k: 0.000125},
|
||||
"gpt-5-mini": {InputPer1k: 0.00025, OutputPer1k: 0.002, CachedInputPer1k: 0.000025},
|
||||
"gpt-5-nano": {InputPer1k: 0.00005, OutputPer1k: 0.0004, CachedInputPer1k: 0.000005},
|
||||
},
|
||||
"anthropic": {
|
||||
// "kimi-k3[1m]" is the 1M-context alias some Claude Code guides
|
||||
// configure against Moonshot's Anthropic-compatible endpoint;
|
||||
// priced identically to kimi-k3 so those requests aren't skipped.
|
||||
"kimi-k3[1m]": {InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003},
|
||||
},
|
||||
}
|
||||
|
||||
var (
|
||||
compiledOnce sync.Once
|
||||
compiledTable map[string]map[string]Entry
|
||||
// mergedTable holds the current live table when a pricing defaults
|
||||
// file is loaded: the file merged entry-whole over the compiled-in
|
||||
// base. Nil while no file is loaded (or after the file is removed),
|
||||
// in which case the compiled-in table serves. Swapped atomically by
|
||||
// the file loader/reloader; readers never block.
|
||||
mergedTable atomic.Pointer[map[string]map[string]Entry]
|
||||
)
|
||||
|
||||
// DefaultTable returns the current default pricing table keyed
|
||||
// surface -> model -> Entry: the management-side defaults file (see
|
||||
// LoadFile / StartReloader) when one is loaded, merged over the
|
||||
// compiled-in catalog table, which alone serves as the fallback when no
|
||||
// file exists. The snapshot may change between calls as the file is
|
||||
// re-read — consumers (the synthesizer on every reconcile, the catalog
|
||||
// endpoint on every request) pick up fresh rates automatically. Callers
|
||||
// must not mutate the returned maps.
|
||||
func DefaultTable() map[string]map[string]Entry {
|
||||
if t := mergedTable.Load(); t != nil {
|
||||
return *t
|
||||
}
|
||||
return compiledBase()
|
||||
}
|
||||
|
||||
// compiledBase returns the compiled-in table (catalog + supplementals),
|
||||
// built once.
|
||||
func compiledBase() map[string]map[string]Entry {
|
||||
compiledOnce.Do(func() {
|
||||
compiledTable = buildDefaultTable()
|
||||
})
|
||||
return compiledTable
|
||||
}
|
||||
|
||||
func buildDefaultTable() map[string]map[string]Entry {
|
||||
out := make(map[string]map[string]Entry)
|
||||
for _, p := range catalog.All() {
|
||||
for _, surface := range p.PricingSurfaces {
|
||||
inner, ok := out[surface]
|
||||
if !ok {
|
||||
inner = make(map[string]Entry)
|
||||
out[surface] = inner
|
||||
}
|
||||
for _, m := range p.Models {
|
||||
// First writer wins; providers contributing the same
|
||||
// (surface, model) must agree on rates — enforced by
|
||||
// TestDefaultTable_NoConflictingContributions.
|
||||
if _, dup := inner[m.ID]; dup {
|
||||
continue
|
||||
}
|
||||
inner[m.ID] = entryFromCatalogModel(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
for surface, models := range supplementalDefaults {
|
||||
inner, ok := out[surface]
|
||||
if !ok {
|
||||
inner = make(map[string]Entry)
|
||||
out[surface] = inner
|
||||
}
|
||||
for id, e := range models {
|
||||
if _, dup := inner[id]; dup {
|
||||
continue
|
||||
}
|
||||
inner[id] = e
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func entryFromCatalogModel(m catalog.Model) Entry {
|
||||
return Entry{
|
||||
InputPer1k: m.InputPer1k,
|
||||
OutputPer1k: m.OutputPer1k,
|
||||
CachedInputPer1k: m.CachedInputPer1k,
|
||||
CacheReadPer1k: m.CacheReadPer1k,
|
||||
CacheCreationPer1k: m.CacheCreationPer1k,
|
||||
}
|
||||
}
|
||||
|
||||
// LookupDefault returns the default entry for model on the first of the
|
||||
// given surfaces that prices it. Used by the synthesizer to seed a
|
||||
// per-provider entry with default cache rates before overlaying the
|
||||
// operator's stored prices.
|
||||
func LookupDefault(surfaces []string, model string) (Entry, bool) {
|
||||
table := DefaultTable()
|
||||
for _, s := range surfaces {
|
||||
if e, ok := table[s][model]; ok {
|
||||
return e, true
|
||||
}
|
||||
}
|
||||
return Entry{}, false
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
# Default LLM pricing used by NetBird's Agent Network cost metering.
|
||||
# GENERATED from the management catalog — do not edit this file in the
|
||||
# repository; regenerate with:
|
||||
#
|
||||
# go generate ./management/internals/modules/agentnetwork/pricing
|
||||
#
|
||||
# Operators: copy this file to <datadir>/defaults_llm_pricing.yaml (or
|
||||
# any path configured via management.json:
|
||||
#
|
||||
# { "AgentNetwork": { "PricingDefaultsFile": "/path/defaults_llm_pricing.yaml" } }
|
||||
#
|
||||
# ) and adjust the entries you want to change. Management re-reads the
|
||||
# file periodically (mtime poll, every minute): the live table feeds the
|
||||
# proxies' cost metering and the dashboard's model-price prefill, so
|
||||
# edits apply without a restart. Your file only needs the entries you
|
||||
# want to change — but each entry REPLACES the built-in entry for that
|
||||
# surface+model whole, so repeat the cache rates you want to keep.
|
||||
# Unknown fields and negative or non-finite rates are rejected: at
|
||||
# startup that fails boot (for an explicitly configured path); at
|
||||
# runtime the previous table is kept and a warning is logged. Deleting
|
||||
# the file reverts to the built-in defaults below.
|
||||
#
|
||||
# Top-level keys are pricing surfaces — the parser shape requests are
|
||||
# metered under: "openai" (also Azure, Mistral, and OpenAI-compatible
|
||||
# gateways), "anthropic" (also Anthropic-on-Vertex), "bedrock"
|
||||
# (normalized ids, e.g. anthropic.claude-sonnet-4-5). Model keys must be
|
||||
# the normalized id the proxy meters (version/region suffixes stripped).
|
||||
#
|
||||
# Values are USD per 1_000 tokens. Optional cache fields:
|
||||
# cached_input_per_1k OpenAI shape: rate for cached prompt tokens
|
||||
# (a SUBSET of input tokens). Absent -> cached
|
||||
# portion bills at input_per_1k.
|
||||
# cache_read_per_1k Anthropic shape: rate for cache_read tokens
|
||||
# (ADDITIVE to input). Absent -> input rate.
|
||||
# cache_creation_per_1k Anthropic shape: rate for cache_creation
|
||||
# tokens (ADDITIVE to input). Absent -> input
|
||||
# rate.
|
||||
|
||||
anthropic:
|
||||
claude-fable-5:
|
||||
input_per_1k: 0.01
|
||||
output_per_1k: 0.05
|
||||
cache_read_per_1k: 0.001
|
||||
cache_creation_per_1k: 0.0125
|
||||
claude-haiku-4-5:
|
||||
input_per_1k: 0.001
|
||||
output_per_1k: 0.005
|
||||
cache_read_per_1k: 0.0001
|
||||
cache_creation_per_1k: 0.00125
|
||||
claude-opus-4-1:
|
||||
input_per_1k: 0.015
|
||||
output_per_1k: 0.075
|
||||
cache_read_per_1k: 0.0015
|
||||
cache_creation_per_1k: 0.01875
|
||||
claude-opus-4-6:
|
||||
input_per_1k: 0.005
|
||||
output_per_1k: 0.025
|
||||
cache_read_per_1k: 0.0005
|
||||
cache_creation_per_1k: 0.00625
|
||||
claude-opus-4-7:
|
||||
input_per_1k: 0.005
|
||||
output_per_1k: 0.025
|
||||
cache_read_per_1k: 0.0005
|
||||
cache_creation_per_1k: 0.00625
|
||||
claude-opus-4-8:
|
||||
input_per_1k: 0.005
|
||||
output_per_1k: 0.025
|
||||
cache_read_per_1k: 0.0005
|
||||
cache_creation_per_1k: 0.00625
|
||||
claude-opus-5:
|
||||
input_per_1k: 0.005
|
||||
output_per_1k: 0.025
|
||||
cache_read_per_1k: 0.0005
|
||||
cache_creation_per_1k: 0.00625
|
||||
claude-sonnet-4-5:
|
||||
input_per_1k: 0.003
|
||||
output_per_1k: 0.015
|
||||
cache_read_per_1k: 0.0003
|
||||
cache_creation_per_1k: 0.00375
|
||||
claude-sonnet-4-6:
|
||||
input_per_1k: 0.003
|
||||
output_per_1k: 0.015
|
||||
cache_read_per_1k: 0.0003
|
||||
cache_creation_per_1k: 0.00375
|
||||
claude-sonnet-5:
|
||||
input_per_1k: 0.003
|
||||
output_per_1k: 0.015
|
||||
cache_read_per_1k: 0.0003
|
||||
cache_creation_per_1k: 0.00375
|
||||
kimi-k3:
|
||||
input_per_1k: 0.003
|
||||
output_per_1k: 0.015
|
||||
cached_input_per_1k: 0.0003
|
||||
cache_read_per_1k: 0.0003
|
||||
"kimi-k3[1m]":
|
||||
input_per_1k: 0.003
|
||||
output_per_1k: 0.015
|
||||
cache_read_per_1k: 0.0003
|
||||
|
||||
bedrock:
|
||||
amazon.nova-2-lite:
|
||||
input_per_1k: 0.0003
|
||||
output_per_1k: 0.0025
|
||||
amazon.nova-lite:
|
||||
input_per_1k: 0.00006
|
||||
output_per_1k: 0.00024
|
||||
amazon.nova-micro:
|
||||
input_per_1k: 0.000035
|
||||
output_per_1k: 0.00014
|
||||
amazon.nova-pro:
|
||||
input_per_1k: 0.0008
|
||||
output_per_1k: 0.0032
|
||||
anthropic.claude-haiku-4-5:
|
||||
input_per_1k: 0.001
|
||||
output_per_1k: 0.005
|
||||
cache_read_per_1k: 0.0001
|
||||
cache_creation_per_1k: 0.00125
|
||||
anthropic.claude-opus-4-1:
|
||||
input_per_1k: 0.015
|
||||
output_per_1k: 0.075
|
||||
cache_read_per_1k: 0.0015
|
||||
cache_creation_per_1k: 0.01875
|
||||
anthropic.claude-opus-4-6:
|
||||
input_per_1k: 0.005
|
||||
output_per_1k: 0.025
|
||||
cache_read_per_1k: 0.0005
|
||||
cache_creation_per_1k: 0.00625
|
||||
anthropic.claude-opus-4-7:
|
||||
input_per_1k: 0.005
|
||||
output_per_1k: 0.025
|
||||
cache_read_per_1k: 0.0005
|
||||
cache_creation_per_1k: 0.00625
|
||||
anthropic.claude-opus-4-8:
|
||||
input_per_1k: 0.005
|
||||
output_per_1k: 0.025
|
||||
cache_read_per_1k: 0.0005
|
||||
cache_creation_per_1k: 0.00625
|
||||
anthropic.claude-opus-5:
|
||||
input_per_1k: 0.005
|
||||
output_per_1k: 0.025
|
||||
cache_read_per_1k: 0.0005
|
||||
cache_creation_per_1k: 0.00625
|
||||
anthropic.claude-sonnet-4-5:
|
||||
input_per_1k: 0.003
|
||||
output_per_1k: 0.015
|
||||
cache_read_per_1k: 0.0003
|
||||
cache_creation_per_1k: 0.00375
|
||||
anthropic.claude-sonnet-4-6:
|
||||
input_per_1k: 0.003
|
||||
output_per_1k: 0.015
|
||||
cache_read_per_1k: 0.0003
|
||||
cache_creation_per_1k: 0.00375
|
||||
anthropic.claude-sonnet-5:
|
||||
input_per_1k: 0.003
|
||||
output_per_1k: 0.015
|
||||
cache_read_per_1k: 0.0003
|
||||
cache_creation_per_1k: 0.00375
|
||||
meta.llama3-3-70b-instruct:
|
||||
input_per_1k: 0.00072
|
||||
output_per_1k: 0.00072
|
||||
|
||||
openai:
|
||||
codestral-2508:
|
||||
input_per_1k: 0.0003
|
||||
output_per_1k: 0.0009
|
||||
codestral-latest:
|
||||
input_per_1k: 0.001
|
||||
output_per_1k: 0.003
|
||||
devstral-medium-latest:
|
||||
input_per_1k: 0.0004
|
||||
output_per_1k: 0.002
|
||||
devstral-small-latest:
|
||||
input_per_1k: 0.0001
|
||||
output_per_1k: 0.0003
|
||||
gpt-3.5-turbo:
|
||||
input_per_1k: 0.0005
|
||||
output_per_1k: 0.0015
|
||||
gpt-35-turbo:
|
||||
input_per_1k: 0.0005
|
||||
output_per_1k: 0.0015
|
||||
gpt-4-turbo:
|
||||
input_per_1k: 0.01
|
||||
output_per_1k: 0.03
|
||||
gpt-4.1:
|
||||
input_per_1k: 0.002
|
||||
output_per_1k: 0.008
|
||||
cached_input_per_1k: 0.0005
|
||||
gpt-4.1-mini:
|
||||
input_per_1k: 0.0004
|
||||
output_per_1k: 0.0016
|
||||
cached_input_per_1k: 0.0001
|
||||
gpt-4.1-nano:
|
||||
input_per_1k: 0.0001
|
||||
output_per_1k: 0.0004
|
||||
cached_input_per_1k: 0.000025
|
||||
gpt-4o:
|
||||
input_per_1k: 0.0025
|
||||
output_per_1k: 0.01
|
||||
cached_input_per_1k: 0.00125
|
||||
gpt-4o-mini:
|
||||
input_per_1k: 0.00015
|
||||
output_per_1k: 0.0006
|
||||
cached_input_per_1k: 0.000075
|
||||
gpt-5:
|
||||
input_per_1k: 0.00125
|
||||
output_per_1k: 0.01
|
||||
cached_input_per_1k: 0.000125
|
||||
gpt-5-mini:
|
||||
input_per_1k: 0.00025
|
||||
output_per_1k: 0.002
|
||||
cached_input_per_1k: 0.000025
|
||||
gpt-5-nano:
|
||||
input_per_1k: 0.00005
|
||||
output_per_1k: 0.0004
|
||||
cached_input_per_1k: 0.000005
|
||||
gpt-5.3-chat-latest:
|
||||
input_per_1k: 0.00175
|
||||
output_per_1k: 0.014
|
||||
cached_input_per_1k: 0.000175
|
||||
gpt-5.3-codex:
|
||||
input_per_1k: 0.00175
|
||||
output_per_1k: 0.014
|
||||
cached_input_per_1k: 0.000175
|
||||
gpt-5.4:
|
||||
input_per_1k: 0.0025
|
||||
output_per_1k: 0.015
|
||||
cached_input_per_1k: 0.00025
|
||||
gpt-5.4-mini:
|
||||
input_per_1k: 0.00075
|
||||
output_per_1k: 0.0045
|
||||
cached_input_per_1k: 0.000075
|
||||
gpt-5.4-nano:
|
||||
input_per_1k: 0.0002
|
||||
output_per_1k: 0.00125
|
||||
cached_input_per_1k: 0.00002
|
||||
gpt-5.4-pro:
|
||||
input_per_1k: 0.03
|
||||
output_per_1k: 0.18
|
||||
cached_input_per_1k: 0.003
|
||||
gpt-5.5:
|
||||
input_per_1k: 0.005
|
||||
output_per_1k: 0.03
|
||||
cached_input_per_1k: 0.0005
|
||||
gpt-5.5-pro:
|
||||
input_per_1k: 0.03
|
||||
output_per_1k: 0.18
|
||||
cached_input_per_1k: 0.003
|
||||
kimi-k3:
|
||||
input_per_1k: 0.003
|
||||
output_per_1k: 0.015
|
||||
cached_input_per_1k: 0.0003
|
||||
cache_read_per_1k: 0.0003
|
||||
magistral-medium-latest:
|
||||
input_per_1k: 0.002
|
||||
output_per_1k: 0.005
|
||||
magistral-small-latest:
|
||||
input_per_1k: 0.0005
|
||||
output_per_1k: 0.0015
|
||||
ministral-3-14b-2512:
|
||||
input_per_1k: 0.0002
|
||||
output_per_1k: 0.0002
|
||||
ministral-3-3b-2512:
|
||||
input_per_1k: 0.0001
|
||||
output_per_1k: 0.0001
|
||||
ministral-8b-latest:
|
||||
input_per_1k: 0.00015
|
||||
output_per_1k: 0.00015
|
||||
mistral-embed:
|
||||
input_per_1k: 0.0001
|
||||
output_per_1k: 0
|
||||
mistral-large-latest:
|
||||
input_per_1k: 0.0005
|
||||
output_per_1k: 0.0015
|
||||
mistral-medium-3-5:
|
||||
input_per_1k: 0.0015
|
||||
output_per_1k: 0.0075
|
||||
mistral-medium-latest:
|
||||
input_per_1k: 0.0004
|
||||
output_per_1k: 0.002
|
||||
mistral-small-latest:
|
||||
input_per_1k: 0.00006
|
||||
output_per_1k: 0.00018
|
||||
o4-mini:
|
||||
input_per_1k: 0.0011
|
||||
output_per_1k: 0.0044
|
||||
cached_input_per_1k: 0.000275
|
||||
text-embedding-3-large:
|
||||
input_per_1k: 0.00013
|
||||
output_per_1k: 0
|
||||
text-embedding-3-small:
|
||||
input_per_1k: 0.00002
|
||||
output_per_1k: 0
|
||||
@@ -0,0 +1,150 @@
|
||||
package pricing
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
|
||||
)
|
||||
|
||||
// TestDefaultTable_CoversEveryCatalogModel replaces the proxy's old
|
||||
// hand-maintained coverage list: because the table is built FROM the
|
||||
// catalog, drift is impossible by construction — this test guards the
|
||||
// fold itself (every catalog model of every surfaced provider resolves,
|
||||
// with exactly the catalog's rates).
|
||||
func TestDefaultTable_CoversEveryCatalogModel(t *testing.T) {
|
||||
table := DefaultTable()
|
||||
for _, p := range catalog.All() {
|
||||
if len(p.PricingSurfaces) == 0 {
|
||||
assert.Empty(t, p.Models, "catalog entry %s declares models but no pricing surfaces — those models would never be priced", p.ID)
|
||||
continue
|
||||
}
|
||||
for _, surface := range p.PricingSurfaces {
|
||||
byModel, ok := table[surface]
|
||||
require.True(t, ok, "surface %q (provider %s) missing from default table", surface, p.ID)
|
||||
for _, m := range p.Models {
|
||||
e, ok := byModel[m.ID]
|
||||
require.True(t, ok, "%s/%s (provider %s) missing from default table", surface, m.ID, p.ID)
|
||||
assert.Equal(t, m.InputPer1k, e.InputPer1k, "%s/%s input rate", surface, m.ID)
|
||||
assert.Equal(t, m.OutputPer1k, e.OutputPer1k, "%s/%s output rate", surface, m.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultTable_NoConflictingContributions enforces the collision rule
|
||||
// documented on catalog.Provider.PricingSurfaces: when two catalog
|
||||
// providers contribute the same (surface, model) pair — azure/vertex
|
||||
// mirroring openai/anthropic, kimi on both surfaces — their rates must be
|
||||
// identical, because the surface-keyed table can only hold one entry.
|
||||
// If a provider ever diverges (e.g. Azure reprices a model), this fails
|
||||
// and the divergence must move to per-provider-record pricing.
|
||||
func TestDefaultTable_NoConflictingContributions(t *testing.T) {
|
||||
type contribution struct {
|
||||
providerID string
|
||||
entry Entry
|
||||
}
|
||||
seen := map[string]map[string]contribution{}
|
||||
for _, p := range catalog.All() {
|
||||
for _, surface := range p.PricingSurfaces {
|
||||
if seen[surface] == nil {
|
||||
seen[surface] = map[string]contribution{}
|
||||
}
|
||||
for _, m := range p.Models {
|
||||
e := entryFromCatalogModel(m)
|
||||
if prev, dup := seen[surface][m.ID]; dup {
|
||||
assert.Equal(t, prev.entry, e,
|
||||
"%s/%s: %s and %s contribute different rates", surface, m.ID, prev.providerID, p.ID)
|
||||
continue
|
||||
}
|
||||
seen[surface][m.ID] = contribution{providerID: p.ID, entry: e}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Supplemental entries must never shadow a catalog-contributed model —
|
||||
// they exist precisely because the catalog does NOT list them.
|
||||
for surface, models := range supplementalDefaults {
|
||||
for id := range models {
|
||||
_, fromCatalog := seen[surface][id]
|
||||
assert.False(t, fromCatalog, "supplemental %s/%s is now in the catalog — delete the supplemental row", surface, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultTable_AllRatesFiniteNonNegative mirrors the proxy-side
|
||||
// NewTable validation so a bad catalog edit is caught here, at unit-test
|
||||
// time, rather than as a chain-build failure on every proxy.
|
||||
func TestDefaultTable_AllRatesFiniteNonNegative(t *testing.T) {
|
||||
for surface, models := range DefaultTable() {
|
||||
for id, e := range models {
|
||||
for field, v := range map[string]float64{
|
||||
"input": e.InputPer1k,
|
||||
"output": e.OutputPer1k,
|
||||
"cached_input": e.CachedInputPer1k,
|
||||
"cache_read": e.CacheReadPer1k,
|
||||
"cache_creation": e.CacheCreationPer1k,
|
||||
} {
|
||||
assert.False(t, v < 0 || math.IsNaN(v) || math.IsInf(v, 0),
|
||||
"%s/%s: %s rate %v must be finite and non-negative", surface, id, field, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultTable_PinnedRates pins rates that previously drifted or are
|
||||
// easy to mis-enter (carried over from the proxy's retired
|
||||
// defaults_coverage_test), plus the supplemental entries.
|
||||
func TestDefaultTable_PinnedRates(t *testing.T) {
|
||||
table := DefaultTable()
|
||||
|
||||
gpt54 := table["openai"]["gpt-5.4"]
|
||||
assert.InDelta(t, 0.0025, gpt54.InputPer1k, 1e-9, "gpt-5.4 input")
|
||||
assert.InDelta(t, 0.015, gpt54.OutputPer1k, 1e-9, "gpt-5.4 output")
|
||||
assert.InDelta(t, 0.00025, gpt54.CachedInputPer1k, 1e-9, "gpt-5.4 cached input")
|
||||
|
||||
sonnet := table["bedrock"]["anthropic.claude-sonnet-4-5"]
|
||||
assert.InDelta(t, 0.003, sonnet.InputPer1k, 1e-9, "bedrock sonnet-4-5 input")
|
||||
assert.InDelta(t, 0.015, sonnet.OutputPer1k, 1e-9, "bedrock sonnet-4-5 output")
|
||||
assert.InDelta(t, 0.0003, sonnet.CacheReadPer1k, 1e-9, "bedrock sonnet-4-5 cache read")
|
||||
assert.InDelta(t, 0.00375, sonnet.CacheCreationPer1k, 1e-9, "bedrock sonnet-4-5 cache creation")
|
||||
|
||||
// Vertex Claude prices under "anthropic" with the bare id.
|
||||
fable := table["anthropic"]["claude-fable-5"]
|
||||
assert.InDelta(t, 0.010, fable.InputPer1k, 1e-9, "claude-fable-5 input")
|
||||
assert.InDelta(t, 0.0125, fable.CacheCreationPer1k, 1e-9, "claude-fable-5 cache creation")
|
||||
|
||||
// Every id below must stay priced whichever source provides it: the
|
||||
// catalog lineup for the current Claude 5 family, supplementalDefaults
|
||||
// for the ids the dashboard deliberately doesn't offer.
|
||||
for surface, ids := range map[string][]string{
|
||||
"openai": {"gpt-5", "gpt-5-mini", "gpt-5-nano"},
|
||||
"anthropic": {"claude-opus-5", "claude-sonnet-5", "kimi-k3[1m]", "kimi-k3"},
|
||||
"bedrock": {"anthropic.claude-opus-5", "anthropic.claude-sonnet-5"},
|
||||
} {
|
||||
for _, id := range ids {
|
||||
_, ok := table[surface][id]
|
||||
assert.True(t, ok, "%s/%s must be priced", surface, id)
|
||||
}
|
||||
}
|
||||
|
||||
// Embeddings bill input-only — output stays zero.
|
||||
emb := table["openai"]["text-embedding-3-large"]
|
||||
assert.Zero(t, emb.OutputPer1k, "embedding output rate must be zero")
|
||||
assert.Positive(t, emb.InputPer1k, "embedding input rate must be set")
|
||||
}
|
||||
|
||||
func TestLookupDefault_SurfaceOrder(t *testing.T) {
|
||||
// kimi-k3 exists on both surfaces; first surface in the slice wins.
|
||||
e, ok := LookupDefault([]string{"openai", "anthropic"}, "kimi-k3")
|
||||
require.True(t, ok)
|
||||
assert.InDelta(t, 0.003, e.InputPer1k, 1e-9)
|
||||
|
||||
_, ok = LookupDefault([]string{"bedrock"}, "gpt-4o")
|
||||
assert.False(t, ok, "gpt-4o is not a bedrock model")
|
||||
|
||||
_, ok = LookupDefault(nil, "gpt-4o")
|
||||
assert.False(t, ok, "no surfaces, no match")
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package pricing
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// MarshalDefaultsYAML renders the built-in default pricing table (catalog
|
||||
// + supplementals, WITHOUT any operator override) as the YAML schema
|
||||
// LoadOverrideFile consumes. It backs the generated
|
||||
// defaults_llm_pricing.example.yaml so operators start from a file that
|
||||
// matches the compiled-in rates exactly; a golden test keeps the two in
|
||||
// sync. Output is deterministic (sorted surfaces and models).
|
||||
func MarshalDefaultsYAML() []byte {
|
||||
var b bytes.Buffer
|
||||
b.WriteString(exampleHeader)
|
||||
|
||||
table := buildDefaultTable()
|
||||
surfaces := make([]string, 0, len(table))
|
||||
for s := range table {
|
||||
surfaces = append(surfaces, s)
|
||||
}
|
||||
sort.Strings(surfaces)
|
||||
|
||||
for _, surface := range surfaces {
|
||||
fmt.Fprintf(&b, "\n%s:\n", surface)
|
||||
models := table[surface]
|
||||
ids := make([]string, 0, len(models))
|
||||
for id := range models {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
for _, id := range ids {
|
||||
e := models[id]
|
||||
fmt.Fprintf(&b, " %s:\n", yamlKey(id))
|
||||
fmt.Fprintf(&b, " input_per_1k: %s\n", rate(e.InputPer1k))
|
||||
fmt.Fprintf(&b, " output_per_1k: %s\n", rate(e.OutputPer1k))
|
||||
if e.CachedInputPer1k > 0 {
|
||||
fmt.Fprintf(&b, " cached_input_per_1k: %s\n", rate(e.CachedInputPer1k))
|
||||
}
|
||||
if e.CacheReadPer1k > 0 {
|
||||
fmt.Fprintf(&b, " cache_read_per_1k: %s\n", rate(e.CacheReadPer1k))
|
||||
}
|
||||
if e.CacheCreationPer1k > 0 {
|
||||
fmt.Fprintf(&b, " cache_creation_per_1k: %s\n", rate(e.CacheCreationPer1k))
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
// rate renders a USD-per-1k rate without float noise ("0.00015", not
|
||||
// "0.000150000000...").
|
||||
func rate(v float64) string {
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
}
|
||||
|
||||
// yamlKey quotes model ids that YAML would otherwise misparse (e.g.
|
||||
// "kimi-k3[1m]" starts a flow sequence unquoted).
|
||||
func yamlKey(id string) string {
|
||||
for _, r := range id {
|
||||
switch r {
|
||||
case '[', ']', '{', '}', ':', '#', ',', '&', '*', '!', '|', '>', '\'', '"', '%', '@', '`':
|
||||
return strconv.Quote(id)
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
const exampleHeader = `# Default LLM pricing used by NetBird's Agent Network cost metering.
|
||||
# GENERATED from the management catalog — do not edit this file in the
|
||||
# repository; regenerate with:
|
||||
#
|
||||
# go generate ./management/internals/modules/agentnetwork/pricing
|
||||
#
|
||||
# Operators: copy this file to <datadir>/defaults_llm_pricing.yaml (or
|
||||
# any path configured via management.json:
|
||||
#
|
||||
# { "AgentNetwork": { "PricingDefaultsFile": "/path/defaults_llm_pricing.yaml" } }
|
||||
#
|
||||
# ) and adjust the entries you want to change. Management re-reads the
|
||||
# file periodically (mtime poll, every minute): the live table feeds the
|
||||
# proxies' cost metering and the dashboard's model-price prefill, so
|
||||
# edits apply without a restart. Your file only needs the entries you
|
||||
# want to change — but each entry REPLACES the built-in entry for that
|
||||
# surface+model whole, so repeat the cache rates you want to keep.
|
||||
# Unknown fields and negative or non-finite rates are rejected: at
|
||||
# startup that fails boot (for an explicitly configured path); at
|
||||
# runtime the previous table is kept and a warning is logged. Deleting
|
||||
# the file reverts to the built-in defaults below.
|
||||
#
|
||||
# Top-level keys are pricing surfaces — the parser shape requests are
|
||||
# metered under: "openai" (also Azure, Mistral, and OpenAI-compatible
|
||||
# gateways), "anthropic" (also Anthropic-on-Vertex), "bedrock"
|
||||
# (normalized ids, e.g. anthropic.claude-sonnet-4-5). Model keys must be
|
||||
# the normalized id the proxy meters (version/region suffixes stripped).
|
||||
#
|
||||
# Values are USD per 1_000 tokens. Optional cache fields:
|
||||
# cached_input_per_1k OpenAI shape: rate for cached prompt tokens
|
||||
# (a SUBSET of input tokens). Absent -> cached
|
||||
# portion bills at input_per_1k.
|
||||
# cache_read_per_1k Anthropic shape: rate for cache_read tokens
|
||||
# (ADDITIVE to input). Absent -> input rate.
|
||||
# cache_creation_per_1k Anthropic shape: rate for cache_creation
|
||||
# tokens (ADDITIVE to input). Absent -> input
|
||||
# rate.
|
||||
`
|
||||
@@ -0,0 +1,20 @@
|
||||
//go:build ignore
|
||||
|
||||
// Regenerates defaults_llm_pricing.example.yaml from the compiled-in
|
||||
// default pricing table. Run via:
|
||||
//
|
||||
// go generate ./management/internals/modules/agentnetwork/pricing
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := os.WriteFile("defaults_llm_pricing.example.yaml", pricing.MarshalDefaultsYAML(), 0o644); err != nil {
|
||||
log.Fatalf("write defaults_llm_pricing.example.yaml: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package pricing
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"math"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// DefaultFileName is the basename probed under management's datadir when
|
||||
// AgentNetwork.PricingDefaultsFile doesn't configure an explicit path.
|
||||
const DefaultFileName = "defaults_llm_pricing.yaml"
|
||||
|
||||
// ReloadInterval is the cadence at which the pricing file's mtime is
|
||||
// polled for changes.
|
||||
const ReloadInterval = time.Minute
|
||||
|
||||
// maxFileBytes bounds the pricing file read so a misconfigured path
|
||||
// (pointed at a huge file) cannot exhaust process memory.
|
||||
const maxFileBytes = 4 << 20
|
||||
|
||||
// pricingFile mirrors the on-disk YAML schema — the same schema the
|
||||
// proxy's retired embedded defaults_pricing.yaml used, so files written
|
||||
// for it keep working. Keys are pricing surfaces ("openai", "anthropic",
|
||||
// "bedrock"); nested keys are normalized model ids.
|
||||
type pricingFile map[string]map[string]struct {
|
||||
InputPer1k float64 `yaml:"input_per_1k"`
|
||||
OutputPer1k float64 `yaml:"output_per_1k"`
|
||||
CachedInputPer1k float64 `yaml:"cached_input_per_1k"`
|
||||
CacheReadPer1k float64 `yaml:"cache_read_per_1k"`
|
||||
CacheCreationPer1k float64 `yaml:"cache_creation_per_1k"`
|
||||
}
|
||||
|
||||
// fileState tracks the watched pricing file across reloads.
|
||||
var fileState struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
mtime int64
|
||||
}
|
||||
|
||||
// LoadFile loads the management-side pricing defaults file and makes it
|
||||
// the live table (merged entry-whole over the compiled-in fallback; see
|
||||
// DefaultTable). The path stays registered for the periodic reloader, so
|
||||
// later edits — or the file (re)appearing after deletion — are picked up
|
||||
// without a restart.
|
||||
//
|
||||
// required governs the missing-file case: true for an explicitly
|
||||
// configured path (a typo must fail startup rather than silently bill
|
||||
// with built-ins the operator believes they replaced), false for the
|
||||
// conventional <datadir>/defaults_llm_pricing.yaml probe (absent file =
|
||||
// compiled-in defaults, still watched in case it appears). A file that
|
||||
// exists but is malformed is always an error at load time.
|
||||
func LoadFile(path string, required bool) error {
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
fileState.mu.Lock()
|
||||
fileState.path = path
|
||||
fileState.mu.Unlock()
|
||||
|
||||
table, mtime, err := readFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) && !required {
|
||||
log.Infof("agent-network pricing defaults file %s not present; serving built-in defaults", path)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
storeFileTable(table, mtime)
|
||||
log.Infof("agent-network pricing defaults loaded from %s", path)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartReloader launches the periodic mtime-poll goroutine for the file
|
||||
// registered by LoadFile. Runtime failures are lenient — a parse error
|
||||
// keeps the previously loaded table and logs a warning; a deleted file
|
||||
// reverts to the compiled-in defaults — so a mid-edit save can never
|
||||
// take pricing down. Returns immediately when no path was registered.
|
||||
func StartReloader(ctx context.Context, interval time.Duration) {
|
||||
fileState.mu.Lock()
|
||||
path := fileState.path
|
||||
fileState.mu.Unlock()
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
if interval <= 0 {
|
||||
interval = ReloadInterval
|
||||
}
|
||||
go func() {
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
reload()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// reload performs one mtime check + reload cycle.
|
||||
func reload() {
|
||||
fileState.mu.Lock()
|
||||
path, lastMtime := fileState.path, fileState.mtime
|
||||
fileState.mu.Unlock()
|
||||
|
||||
log.Debugf("agent-network pricing defaults reload: checking %s for changes", path)
|
||||
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
// File removed (or not yet created): serve compiled-in
|
||||
// defaults and reset mtime so a future (re)appearance loads.
|
||||
if mergedTable.Swap(nil) != nil {
|
||||
log.Warnf("agent-network pricing defaults file %s removed; reverting to built-in defaults", path)
|
||||
}
|
||||
setMtime(0)
|
||||
return
|
||||
}
|
||||
log.Warnf("agent-network pricing defaults reload: stat %s: %v", path, err)
|
||||
return
|
||||
}
|
||||
if st.ModTime().UnixNano() == lastMtime {
|
||||
log.Debugf("agent-network pricing defaults %s unchanged since last check", path)
|
||||
return
|
||||
}
|
||||
|
||||
table, mtime, err := readFile(path)
|
||||
if err != nil {
|
||||
// Keep the previously loaded table — never blank prices because
|
||||
// an operator saved mid-edit.
|
||||
log.Warnf("agent-network pricing defaults reload failed for %s (keeping previous table): %v", path, err)
|
||||
return
|
||||
}
|
||||
storeFileTable(table, mtime)
|
||||
log.Infof("agent-network pricing defaults reloaded from %s", path)
|
||||
}
|
||||
|
||||
func readFile(path string) (map[string]map[string]Entry, int64, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("open pricing defaults %s: %w", path, err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
st, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("stat pricing defaults %s: %w", path, err)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(f, maxFileBytes+1))
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("read pricing defaults %s: %w", path, err)
|
||||
}
|
||||
if len(data) > maxFileBytes {
|
||||
return nil, 0, fmt.Errorf("pricing defaults %s exceeds %d bytes", path, maxFileBytes)
|
||||
}
|
||||
table, err := parsePricingYAML(data)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("parse pricing defaults %s: %w", path, err)
|
||||
}
|
||||
return table, st.ModTime().UnixNano(), nil
|
||||
}
|
||||
|
||||
// storeFileTable merges the parsed file over the compiled-in base and
|
||||
// publishes the result as the live table. File entries replace the
|
||||
// built-in entry for the same (surface, model) whole — they are not
|
||||
// field-merged — and surfaces/models the file doesn't mention keep the
|
||||
// built-in rates, so a partial file only needs the entries it changes.
|
||||
func storeFileTable(table map[string]map[string]Entry, mtime int64) {
|
||||
base := compiledBase()
|
||||
merged := make(map[string]map[string]Entry, len(base)+len(table))
|
||||
for surface, models := range base {
|
||||
inner := make(map[string]Entry, len(models))
|
||||
for id, e := range models {
|
||||
inner[id] = e
|
||||
}
|
||||
merged[surface] = inner
|
||||
}
|
||||
for surface, models := range table {
|
||||
inner, ok := merged[surface]
|
||||
if !ok {
|
||||
inner = make(map[string]Entry, len(models))
|
||||
merged[surface] = inner
|
||||
}
|
||||
for id, e := range models {
|
||||
inner[id] = e
|
||||
}
|
||||
}
|
||||
mergedTable.Store(&merged)
|
||||
setMtime(mtime)
|
||||
}
|
||||
|
||||
func setMtime(v int64) {
|
||||
fileState.mu.Lock()
|
||||
fileState.mtime = v
|
||||
fileState.mu.Unlock()
|
||||
}
|
||||
|
||||
// parsePricingYAML decodes and validates the pricing YAML. Unknown
|
||||
// fields are rejected (typos surface instead of silently pricing at 0)
|
||||
// and every rate must be a finite, non-negative USD amount — the same
|
||||
// constraints the HTTP API enforces on operator per-provider prices.
|
||||
func parsePricingYAML(data []byte) (map[string]map[string]Entry, error) {
|
||||
dec := yaml.NewDecoder(bytes.NewReader(data))
|
||||
dec.KnownFields(true)
|
||||
|
||||
var raw pricingFile
|
||||
if err := dec.Decode(&raw); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, fmt.Errorf("decode yaml: %w", err)
|
||||
}
|
||||
|
||||
out := make(map[string]map[string]Entry, len(raw))
|
||||
for surface, models := range raw {
|
||||
inner := make(map[string]Entry, len(models))
|
||||
for model, e := range models {
|
||||
for field, v := range map[string]float64{
|
||||
"input_per_1k": e.InputPer1k,
|
||||
"output_per_1k": e.OutputPer1k,
|
||||
"cached_input_per_1k": e.CachedInputPer1k,
|
||||
"cache_read_per_1k": e.CacheReadPer1k,
|
||||
"cache_creation_per_1k": e.CacheCreationPer1k,
|
||||
} {
|
||||
if v < 0 || math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
return nil, fmt.Errorf("%s/%s: %s must be a finite, non-negative rate, got %v", surface, model, field, v)
|
||||
}
|
||||
}
|
||||
inner[model] = Entry{
|
||||
InputPer1k: e.InputPer1k,
|
||||
OutputPer1k: e.OutputPer1k,
|
||||
CachedInputPer1k: e.CachedInputPer1k,
|
||||
CacheReadPer1k: e.CacheReadPer1k,
|
||||
CacheCreationPer1k: e.CacheCreationPer1k,
|
||||
}
|
||||
}
|
||||
out[surface] = inner
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package pricing
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// resetFileState snapshots and restores the package-level file state so
|
||||
// tests stay order-independent.
|
||||
func resetFileState(t *testing.T) {
|
||||
t.Helper()
|
||||
prevMerged := mergedTable.Load()
|
||||
fileState.mu.Lock()
|
||||
prevPath, prevMtime := fileState.path, fileState.mtime
|
||||
fileState.mu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
mergedTable.Store(prevMerged)
|
||||
fileState.mu.Lock()
|
||||
fileState.path, fileState.mtime = prevPath, prevMtime
|
||||
fileState.mu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func writePricing(t *testing.T, path, yml string) {
|
||||
t.Helper()
|
||||
require.NoError(t, os.WriteFile(path, []byte(yml), 0o600))
|
||||
}
|
||||
|
||||
func TestLoadFile_MergesOverCompiledDefaults(t *testing.T) {
|
||||
resetFileState(t)
|
||||
path := filepath.Join(t.TempDir(), DefaultFileName)
|
||||
writePricing(t, path, `
|
||||
openai:
|
||||
# Reprice a built-in model. The entry replaces the built-in WHOLE:
|
||||
# omitting the cache rate here drops the built-in 0.00125 discount.
|
||||
gpt-4o:
|
||||
input_per_1k: 0.9
|
||||
output_per_1k: 1.8
|
||||
# A model NetBird doesn't know at all.
|
||||
my-private-ft:
|
||||
input_per_1k: 0.01
|
||||
output_per_1k: 0.02
|
||||
cached_input_per_1k: 0.005
|
||||
gemini:
|
||||
gemini-pro:
|
||||
input_per_1k: 0.00125
|
||||
output_per_1k: 0.005
|
||||
`)
|
||||
require.NoError(t, LoadFile(path, true))
|
||||
table := DefaultTable()
|
||||
|
||||
gpt4o := table["openai"]["gpt-4o"]
|
||||
assert.InDelta(t, 0.9, gpt4o.InputPer1k, 1e-9, "file rate replaces the compiled-in rate")
|
||||
assert.Zero(t, gpt4o.CachedInputPer1k, "entries replace whole — omitted cache rate is dropped, not inherited")
|
||||
|
||||
ft := table["openai"]["my-private-ft"]
|
||||
assert.InDelta(t, 0.005, ft.CachedInputPer1k, 1e-9, "unknown models are added to the surface")
|
||||
_, ok := table["gemini"]["gemini-pro"]
|
||||
assert.True(t, ok, "a surface the catalog doesn't declare can be added")
|
||||
|
||||
// Untouched entries keep compiled-in rates (catalog, other surface,
|
||||
// supplemental).
|
||||
assert.InDelta(t, 0.00015, table["openai"]["gpt-4o-mini"].InputPer1k, 1e-9, "unlisted model keeps compiled rate")
|
||||
assert.InDelta(t, 0.003, table["anthropic"]["claude-sonnet-4-5"].InputPer1k, 1e-9, "unlisted surface untouched")
|
||||
assert.InDelta(t, 0.00125, table["openai"]["gpt-5"].InputPer1k, 1e-9, "supplemental entries untouched")
|
||||
|
||||
// The synthesizer-facing lookup reads the live table too.
|
||||
e, ok := LookupDefault([]string{"openai"}, "gpt-4o")
|
||||
require.True(t, ok)
|
||||
assert.InDelta(t, 0.9, e.InputPer1k, 1e-9, "LookupDefault serves the file-backed rate")
|
||||
}
|
||||
|
||||
func TestLoadFile_MissingPath(t *testing.T) {
|
||||
resetFileState(t)
|
||||
missing := filepath.Join(t.TempDir(), DefaultFileName)
|
||||
|
||||
require.Error(t, LoadFile(missing, true),
|
||||
"explicitly configured path that doesn't exist must fail startup")
|
||||
|
||||
require.NoError(t, LoadFile(missing, false),
|
||||
"conventional datadir probe tolerates an absent file (compiled-in defaults serve)")
|
||||
assert.Nil(t, mergedTable.Load(), "no file, no merged table")
|
||||
fileState.mu.Lock()
|
||||
path := fileState.path
|
||||
fileState.mu.Unlock()
|
||||
assert.Equal(t, missing, path, "the path stays registered so the reloader picks the file up when it appears")
|
||||
}
|
||||
|
||||
func TestLoadFile_RejectsInvalid(t *testing.T) {
|
||||
resetFileState(t)
|
||||
dir := t.TempDir()
|
||||
cases := map[string]string{
|
||||
"unknown field (typo)": "openai:\n gpt-4o:\n input_per1k: 0.1\n",
|
||||
"negative rate": "openai:\n gpt-4o:\n input_per_1k: -0.1\n",
|
||||
"non-numeric rate": "openai:\n gpt-4o:\n input_per_1k: cheap\n",
|
||||
"not a mapping": "- just\n- a\n- list\n",
|
||||
}
|
||||
for name, yml := range cases {
|
||||
path := filepath.Join(dir, DefaultFileName)
|
||||
writePricing(t, path, yml)
|
||||
assert.Error(t, LoadFile(path, true), "case %q must be rejected", name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReload_LifeCycle drives the reloader's single-shot reload through
|
||||
// its full lifecycle: file edit picked up on mtime change, a broken save
|
||||
// keeps the previous table, and file removal reverts to the compiled-in
|
||||
// defaults (then a re-created file loads again).
|
||||
func TestReload_LifeCycle(t *testing.T) {
|
||||
resetFileState(t)
|
||||
path := filepath.Join(t.TempDir(), DefaultFileName)
|
||||
writePricing(t, path, "openai:\n gpt-4o:\n input_per_1k: 0.5\n output_per_1k: 1\n")
|
||||
require.NoError(t, LoadFile(path, true))
|
||||
require.InDelta(t, 0.5, DefaultTable()["openai"]["gpt-4o"].InputPer1k, 1e-9)
|
||||
|
||||
// Edit: new mtime, new rates.
|
||||
writePricing(t, path, "openai:\n gpt-4o:\n input_per_1k: 0.7\n output_per_1k: 1.4\n")
|
||||
bumpMtime(t, path)
|
||||
reload()
|
||||
assert.InDelta(t, 0.7, DefaultTable()["openai"]["gpt-4o"].InputPer1k, 1e-9, "edit must be picked up")
|
||||
|
||||
// Broken save: previous table survives.
|
||||
writePricing(t, path, "openai:\n gpt-4o:\n input_per_1k: -1\n")
|
||||
bumpMtime(t, path)
|
||||
reload()
|
||||
assert.InDelta(t, 0.7, DefaultTable()["openai"]["gpt-4o"].InputPer1k, 1e-9,
|
||||
"a malformed save must keep the previously loaded table, never blank prices")
|
||||
|
||||
// Removal: compiled-in defaults serve again.
|
||||
require.NoError(t, os.Remove(path))
|
||||
reload()
|
||||
assert.InDelta(t, 0.0025, DefaultTable()["openai"]["gpt-4o"].InputPer1k, 1e-9,
|
||||
"file removal reverts to the compiled-in rate")
|
||||
|
||||
// Re-created file loads without a restart.
|
||||
writePricing(t, path, "openai:\n gpt-4o:\n input_per_1k: 0.9\n output_per_1k: 1.8\n")
|
||||
bumpMtime(t, path)
|
||||
reload()
|
||||
assert.InDelta(t, 0.9, DefaultTable()["openai"]["gpt-4o"].InputPer1k, 1e-9,
|
||||
"a file appearing after removal (or after a missing-probe boot) must load")
|
||||
}
|
||||
|
||||
// bumpMtime pushes the file's mtime forward past the previously recorded
|
||||
// value — timestamps can otherwise collide within the test's timescale.
|
||||
func bumpMtime(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
st, err := os.Stat(path)
|
||||
require.NoError(t, err)
|
||||
next := st.ModTime().Add(2 * 1e9)
|
||||
require.NoError(t, os.Chtimes(path, next, next))
|
||||
}
|
||||
|
||||
// TestExampleYAML_InSyncWithBuiltins is the golden guard for
|
||||
// defaults_llm_pricing.example.yaml: the shipped example must stay
|
||||
// byte-identical to what the compiled-in table renders (catalog edits
|
||||
// require `go generate ./management/internals/modules/agentnetwork/pricing`)
|
||||
// and must round-trip through the same parser operators' files go
|
||||
// through, reproducing the compiled-in table exactly.
|
||||
func TestExampleYAML_InSyncWithBuiltins(t *testing.T) {
|
||||
onDisk, err := os.ReadFile("defaults_llm_pricing.example.yaml")
|
||||
require.NoError(t, err, "example file must exist next to the package")
|
||||
require.Equal(t, string(MarshalDefaultsYAML()), string(onDisk),
|
||||
"defaults_llm_pricing.example.yaml is stale — run: go generate ./management/internals/modules/agentnetwork/pricing")
|
||||
|
||||
parsed, err := parsePricingYAML(onDisk)
|
||||
require.NoError(t, err, "the example must be a valid pricing defaults file")
|
||||
assert.Equal(t, buildDefaultTable(), parsed,
|
||||
"parsing the example must reproduce the compiled-in table exactly")
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/permissions"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/permissions/modules"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/permissions/operations"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
nbtypes "github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
// These tests pin the provider read surface per grant: a caller holding
|
||||
// providers read together with update (managers) gets the full record,
|
||||
// while read-only viewers (usage_viewer) get the display surface only —
|
||||
// connection configuration is redacted before it reaches the wire layer.
|
||||
|
||||
func TestGetAllProviders_RedactsConnectionConfigForReadOnlyViewer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
|
||||
saved := newSynthTestProvider()
|
||||
saved.ExtraValues = map[string]string{"x-portkey-config": "cfg-123"}
|
||||
saved.IdentityHeaderUserID = "X-User"
|
||||
saved.IdentityHeaderGroups = "X-Groups"
|
||||
saved.SkipTLSVerification = true
|
||||
require.NoError(t, f.store.SaveAgentNetworkProvider(ctx, saved))
|
||||
|
||||
f.expectPermission(testAccountID, "viewer", modules.AgentNetworkProviders, operations.Read, true)
|
||||
f.expectPermission(testAccountID, "viewer", modules.AgentNetworkProviders, operations.Update, false)
|
||||
|
||||
providers, err := f.manager.GetAllProviders(ctx, testAccountID, "viewer")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, providers, 1)
|
||||
p := providers[0]
|
||||
assert.Equal(t, saved.ID, p.ID, "identity survives redaction")
|
||||
assert.Equal(t, saved.Name, p.Name)
|
||||
assert.Equal(t, saved.ProviderID, p.ProviderID)
|
||||
assert.Equal(t, saved.Models, p.Models, "the model list backs the usage filters and stays")
|
||||
assert.True(t, p.Enabled)
|
||||
assert.Empty(t, p.UpstreamURL, "upstream URL is connection config")
|
||||
assert.Empty(t, p.ExtraValues, "operator-typed header values are connection config")
|
||||
assert.Empty(t, p.IdentityHeaderUserID)
|
||||
assert.Empty(t, p.IdentityHeaderGroups)
|
||||
assert.False(t, p.SkipTLSVerification)
|
||||
assert.Empty(t, p.APIKey)
|
||||
assert.Empty(t, p.SessionPrivateKey)
|
||||
|
||||
stored, err := f.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, testAccountID, saved.ID)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, stored.UpstreamURL, "redaction must not write back to the store")
|
||||
}
|
||||
|
||||
func TestGetProvider_FullConfigForManagingCaller(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
|
||||
saved := newSynthTestProvider()
|
||||
saved.ExtraValues = map[string]string{"x-portkey-config": "cfg-123"}
|
||||
require.NoError(t, f.store.SaveAgentNetworkProvider(ctx, saved))
|
||||
|
||||
f.expectPermission(testAccountID, "admin", modules.AgentNetworkProviders, operations.Read, true)
|
||||
f.expectPermission(testAccountID, "admin", modules.AgentNetworkProviders, operations.Update, true)
|
||||
|
||||
p, err := f.manager.GetProvider(ctx, testAccountID, "admin", saved.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, saved.UpstreamURL, p.UpstreamURL, "a caller who can edit the provider sees its config")
|
||||
assert.Equal(t, saved.ExtraValues, p.ExtraValues)
|
||||
}
|
||||
|
||||
func TestGetProvider_RedactsForReadOnlyViewer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
|
||||
saved := newSynthTestProvider()
|
||||
require.NoError(t, f.store.SaveAgentNetworkProvider(ctx, saved))
|
||||
|
||||
f.expectPermission(testAccountID, "viewer", modules.AgentNetworkProviders, operations.Read, true)
|
||||
f.expectPermission(testAccountID, "viewer", modules.AgentNetworkProviders, operations.Update, false)
|
||||
|
||||
p, err := f.manager.GetProvider(ctx, testAccountID, "viewer", saved.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, saved.ID, p.ID)
|
||||
assert.Empty(t, p.UpstreamURL)
|
||||
}
|
||||
|
||||
// The self-scope tests drive the real permissions manager over the real
|
||||
// store, so role resolution is the production one: a plain user holds no
|
||||
// providers grant and must fall back to the caller-scoped list — the same
|
||||
// selection the self-service setup answer derives from — while an admin
|
||||
// keeps the account-wide view with full config.
|
||||
|
||||
// newSelfScopeStore seeds the account and its users only, so each test
|
||||
// declares exactly the providers and policies it asserts on — the store
|
||||
// rejects re-saving a policy id on MySQL, so tests never overwrite each
|
||||
// other's rows.
|
||||
func newSelfScopeStore(t *testing.T) (*managerImpl, store.Store) {
|
||||
t.Helper()
|
||||
mgr, s := newAgentConfigTestMgr(t)
|
||||
mgr.permissionsManager = permissions.NewManager(s)
|
||||
ctx := context.Background()
|
||||
|
||||
require.NoError(t, s.SaveAccount(ctx, &nbtypes.Account{Id: testAccountID}))
|
||||
require.NoError(t, s.SaveUser(ctx, &nbtypes.User{
|
||||
Id: "user-a", AccountID: testAccountID, Role: nbtypes.UserRoleUser, AutoGroups: []string{"grp-eng"},
|
||||
}))
|
||||
require.NoError(t, s.SaveUser(ctx, &nbtypes.User{
|
||||
Id: "user-out", AccountID: testAccountID, Role: nbtypes.UserRoleUser,
|
||||
}))
|
||||
require.NoError(t, s.SaveUser(ctx, &nbtypes.User{
|
||||
Id: "admin", AccountID: testAccountID, Role: nbtypes.UserRoleAdmin,
|
||||
}))
|
||||
return mgr, s
|
||||
}
|
||||
|
||||
func newSelfScopeProvidersFixture(t *testing.T) (*managerImpl, store.Store) {
|
||||
t.Helper()
|
||||
mgr, s := newSelfScopeStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
granted := newSynthTestProvider()
|
||||
granted.ID = "prov-granted"
|
||||
granted.Name = "Granted"
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, granted))
|
||||
|
||||
other := newSynthTestProvider()
|
||||
other.ID = "prov-other"
|
||||
other.Name = "Other"
|
||||
other.CreatedAt = granted.CreatedAt.Add(time.Hour)
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, other))
|
||||
|
||||
disabled := newSynthTestProvider()
|
||||
disabled.ID = "prov-disabled"
|
||||
disabled.Name = "Disabled"
|
||||
disabled.Enabled = false
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, disabled))
|
||||
|
||||
// user-a's group authorizes the granted and the disabled provider; the
|
||||
// disabled one must still not surface (the proxy never routes it).
|
||||
policy := newSynthTestPolicy(granted.ID, "grp-eng", "")
|
||||
policy.DestinationProviderIDs = []string{granted.ID, disabled.ID}
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy))
|
||||
|
||||
return mgr, s
|
||||
}
|
||||
|
||||
func TestGetAllProviders_SelfScopedForPlainUser(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, _ := newSelfScopeProvidersFixture(t)
|
||||
|
||||
scoped, err := mgr.GetAllProviders(ctx, testAccountID, "user-a")
|
||||
require.NoError(t, err, "a caller without the read grant self-scopes instead of being denied")
|
||||
require.Len(t, scoped, 1)
|
||||
assert.Equal(t, "prov-granted", scoped[0].ID)
|
||||
assert.Empty(t, scoped[0].UpstreamURL, "the caller-scoped list is the redacted display surface")
|
||||
assert.NotEmpty(t, scoped[0].Models, "model list backs the dashboard filters")
|
||||
|
||||
empty, err := mgr.GetAllProviders(ctx, testAccountID, "user-out")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, empty, "a caller outside every policy gets an empty list, not an error")
|
||||
|
||||
all, err := mgr.GetAllProviders(ctx, testAccountID, "admin")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, all, 3, "grant holders keep the account-wide list, disabled providers included")
|
||||
for _, p := range all {
|
||||
if p.ID == "prov-granted" {
|
||||
assert.NotEmpty(t, p.UpstreamURL, "a managing caller sees the connection config")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProvider_SelfScopedForPlainUser(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, _ := newSelfScopeProvidersFixture(t)
|
||||
|
||||
p, err := mgr.GetProvider(ctx, testAccountID, "user-a", "prov-granted")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "prov-granted", p.ID)
|
||||
assert.Empty(t, p.UpstreamURL)
|
||||
|
||||
assertNotFound := func(id string) {
|
||||
t.Helper()
|
||||
_, err := mgr.GetProvider(ctx, testAccountID, "user-a", id)
|
||||
require.Error(t, err)
|
||||
var sErr *status.Error
|
||||
require.ErrorAs(t, err, &sErr)
|
||||
assert.Equal(t, status.NotFound, sErr.Type(),
|
||||
"out-of-scope and nonexistent providers must be indistinguishable")
|
||||
}
|
||||
assertNotFound("prov-other")
|
||||
assertNotFound("prov-disabled")
|
||||
assertNotFound("prov-does-not-exist")
|
||||
}
|
||||
|
||||
func TestGetAllProviders_SelfScopedModelsFollowGuardrails(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, s := newSelfScopeStore(t)
|
||||
|
||||
// A provider declaring two models, restricted by an allowlist admitting
|
||||
// one declared model plus one the operator never declared (unreachable —
|
||||
// the router only claims declared models, so it must not surface).
|
||||
granted := newSynthTestProvider()
|
||||
granted.ID = "prov-models"
|
||||
granted.Name = "Granted"
|
||||
granted.Models = []types.ProviderModel{
|
||||
{ID: "gpt-5.4", InputPer1k: 0.004, OutputPer1k: 0.02},
|
||||
{ID: "gpt-4o", InputPer1k: 0.0025, OutputPer1k: 0.01},
|
||||
}
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, granted))
|
||||
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-models", "gpt-5.4", "gpt-undeclared")))
|
||||
policy := newSynthTestPolicy(granted.ID, "grp-eng", "guard-models")
|
||||
policy.ID = "pol-guard-models"
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy))
|
||||
|
||||
scoped, err := mgr.GetAllProviders(ctx, testAccountID, "user-a")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, scoped, 1)
|
||||
require.Len(t, scoped[0].Models, 1,
|
||||
"the self-scoped model list is the effective set: allowlist ∩ declared")
|
||||
assert.Equal(t, "gpt-5.4", scoped[0].Models[0].ID)
|
||||
assert.Equal(t, 0.004, scoped[0].Models[0].InputPer1k, "declared entry survives, prices included")
|
||||
|
||||
all, err := mgr.GetAllProviders(ctx, testAccountID, "admin")
|
||||
require.NoError(t, err)
|
||||
for _, p := range all {
|
||||
if p.ID == granted.ID {
|
||||
assert.Len(t, p.Models, 2,
|
||||
"grant holders keep the full declared list — their usage view spans everyone's requests")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAllProviders_SelfScopedAllowlistWithoutDeclaredModels(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, s := newSelfScopeStore(t)
|
||||
|
||||
// No operator declaration: the router claims every model, so the
|
||||
// allowlist union is the effective set and comes back as bare entries.
|
||||
granted := newSynthTestProvider()
|
||||
granted.ID = "prov-bare"
|
||||
granted.Name = "Granted"
|
||||
granted.Models = nil
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, granted))
|
||||
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, newSetupTestGuardrail("guard-bare", "gpt-5.4")))
|
||||
policy := newSynthTestPolicy(granted.ID, "grp-eng", "guard-bare")
|
||||
policy.ID = "pol-guard-bare"
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy))
|
||||
|
||||
scoped, err := mgr.GetAllProviders(ctx, testAccountID, "user-a")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, scoped, 1)
|
||||
require.Len(t, scoped[0].Models, 1)
|
||||
assert.Equal(t, "gpt-5.4", scoped[0].Models[0].ID)
|
||||
}
|
||||
|
||||
func TestGetAllProviders_SelfScopedUnrestrictedFallsBackToCatalogModels(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, s := newSelfScopeStore(t)
|
||||
|
||||
// Unrestricted policy on a provider without an operator declaration:
|
||||
// the setup answer advertises the catalog models, and the scoped
|
||||
// provider list must match so the model filter is never emptier than
|
||||
// the setup page.
|
||||
granted := newSynthTestProvider()
|
||||
granted.ID = "prov-catalog"
|
||||
granted.Name = "Granted"
|
||||
granted.Models = nil
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, granted))
|
||||
policy := newSynthTestPolicy(granted.ID, "grp-eng", "")
|
||||
policy.ID = "pol-catalog"
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy))
|
||||
|
||||
scoped, err := mgr.GetAllProviders(ctx, testAccountID, "user-a")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, scoped, 1)
|
||||
require.NotEmpty(t, scoped[0].Models, "catalog models back the filter when the operator declared none")
|
||||
ids := make([]string, 0, len(scoped[0].Models))
|
||||
for _, m := range scoped[0].Models {
|
||||
ids = append(ids, m.ID)
|
||||
}
|
||||
assert.Equal(t, declaredModelIDs(granted), ids, "the scoped list mirrors the setup answer's declared/catalog set")
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
// syntheticMapping pairs a synthesised proxy mapping with the address of the
|
||||
// proxy that serves it. The cluster is recorded rather than derived from the
|
||||
// mapping's domain: ProxyMapping does not carry it, and the previous derivation
|
||||
// -- everything after the first DNS label -- is wrong whenever the service's
|
||||
// domain is not one label under its proxy's address, which silently addressed
|
||||
// updates to a cluster no proxy declares.
|
||||
type syntheticMapping struct {
|
||||
mapping *proto.ProxyMapping
|
||||
cluster string
|
||||
}
|
||||
|
||||
// reconcile recomputes the synthesised reverse-proxy services for an
|
||||
// account, diffs them against the previously-synthesised set in the
|
||||
// in-memory cache, and emits Create / Update / Delete proxy mappings
|
||||
// to the affected clusters. Also triggers a peer-side network-map
|
||||
// recompute via accountManager.UpdateAccountPeers so the
|
||||
// private-service ACL injection picks up the new state immediately.
|
||||
//
|
||||
// Reconcile failures are logged and swallowed — the underlying CRUD
|
||||
// has already completed, and the next mutation (or proxy reconnect)
|
||||
// will re-converge the cluster's view.
|
||||
func (m *managerImpl) reconcile(ctx context.Context, accountID string) {
|
||||
if accountID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if m.accountManager != nil {
|
||||
m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{
|
||||
Resource: types.UpdateResourceService,
|
||||
Operation: types.UpdateOperationUpdate,
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
if m.proxyController == nil {
|
||||
return
|
||||
}
|
||||
|
||||
services, err := SynthesizeServices(ctx, m.store, accountID)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).WithError(err).Warnf("agent-network reconcile: synthesise services for account %s", accountID)
|
||||
return
|
||||
}
|
||||
|
||||
oidcCfg := m.proxyController.GetOIDCValidationConfig()
|
||||
current := make(map[string]syntheticMapping, len(services))
|
||||
for _, svc := range services {
|
||||
if svc == nil || svc.ID == "" {
|
||||
continue
|
||||
}
|
||||
current[svc.ID] = syntheticMapping{
|
||||
mapping: svc.ToProtoMapping(rpservice.Update, "", oidcCfg),
|
||||
cluster: svc.ProxyCluster,
|
||||
}
|
||||
}
|
||||
|
||||
m.reconcileMu.Lock()
|
||||
previous := m.reconcileCache[accountID]
|
||||
if previous == nil {
|
||||
previous = make(map[string]syntheticMapping)
|
||||
}
|
||||
|
||||
creates, updates, deletes := diffMappings(previous, current)
|
||||
if len(current) == 0 {
|
||||
delete(m.reconcileCache, accountID)
|
||||
} else {
|
||||
m.reconcileCache[accountID] = current
|
||||
}
|
||||
m.reconcileMu.Unlock()
|
||||
|
||||
for _, entry := range creates {
|
||||
entry.mapping.Type = proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, entry.mapping, entry.cluster)
|
||||
}
|
||||
for _, entry := range updates {
|
||||
entry.mapping.Type = proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, entry.mapping, entry.cluster)
|
||||
}
|
||||
for _, entry := range deletes {
|
||||
entry.mapping.Type = proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, entry.mapping, entry.cluster)
|
||||
}
|
||||
}
|
||||
|
||||
// diffMappings classifies the previous→current transition for a single
|
||||
// account into Create / Update / Delete sets.
|
||||
//
|
||||
// A change of serving proxy for the same service ID is surfaced as a Delete
|
||||
// addressed to the old proxy plus a Create addressed to the new one, so the
|
||||
// mapping actually moves. Comparing the recorded cluster is what makes that
|
||||
// detectable: with a placement-free endpoint the mapping's domain is identical
|
||||
// before and after the move, so nothing about the mapping itself reveals it.
|
||||
func diffMappings(previous, current map[string]syntheticMapping) (creates, updates, deletes []syntheticMapping) {
|
||||
for id, cur := range current {
|
||||
prev, existed := previous[id]
|
||||
switch {
|
||||
case !existed:
|
||||
creates = append(creates, cur)
|
||||
case prev.mapping.GetDomain() == "" ||
|
||||
cur.mapping.GetAccountId() == prev.mapping.GetAccountId() && prev.cluster != cur.cluster:
|
||||
deletes = append(deletes, prev)
|
||||
creates = append(creates, cur)
|
||||
default:
|
||||
updates = append(updates, cur)
|
||||
}
|
||||
}
|
||||
for id, prev := range previous {
|
||||
if _, stillThere := current[id]; !stillThere {
|
||||
deletes = append(deletes, prev)
|
||||
}
|
||||
}
|
||||
return creates, updates, deletes
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
func newReconcileMgr(t *testing.T, ctrl *gomock.Controller) (*managerImpl, *store.MockStore, *proxy.MockController) {
|
||||
t.Helper()
|
||||
mockStore := store.NewMockStore(ctrl)
|
||||
mockProxy := proxy.NewMockController(ctrl)
|
||||
return &managerImpl{
|
||||
store: mockStore,
|
||||
proxyController: mockProxy,
|
||||
reconcileCache: make(map[string]map[string]syntheticMapping),
|
||||
}, mockStore, mockProxy
|
||||
}
|
||||
|
||||
func newReconcileTestProvider() *types.Provider {
|
||||
return &types.Provider{
|
||||
ID: "prov-1",
|
||||
AccountID: "acct-1",
|
||||
ProviderID: "openai_api",
|
||||
Name: "OpenAI",
|
||||
UpstreamURL: "https://api.openai.com",
|
||||
APIKey: "sk-test-key",
|
||||
Enabled: true,
|
||||
SessionPrivateKey: "test-priv-key",
|
||||
SessionPublicKey: "test-pub-key",
|
||||
}
|
||||
}
|
||||
|
||||
func newReconcileTestPolicy(providerID, sourceGroupID string) *types.Policy {
|
||||
return &types.Policy{
|
||||
ID: "pol-1",
|
||||
AccountID: "acct-1",
|
||||
Name: "engineers",
|
||||
Enabled: true,
|
||||
SourceGroups: []string{sourceGroupID},
|
||||
DestinationProviderIDs: []string{providerID},
|
||||
}
|
||||
}
|
||||
|
||||
func newReconcileTestSettings() *types.Settings {
|
||||
return &types.Settings{
|
||||
AccountID: "acct-1",
|
||||
Domain: "violet.eu.proxy.netbird.io",
|
||||
ProxyAddress: "eu.proxy.netbird.io",
|
||||
}
|
||||
}
|
||||
|
||||
func expectReconcileSynthInputs(mockStore *store.MockStore, ctx context.Context, providers []*types.Provider, policies []*types.Policy, guardrails []*types.Guardrail) {
|
||||
mockStore.EXPECT().
|
||||
GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "acct-1").
|
||||
Return(newReconcileTestSettings(), nil)
|
||||
mockStore.EXPECT().
|
||||
GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, "acct-1").
|
||||
Return(providers, nil)
|
||||
mockStore.EXPECT().
|
||||
GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, "acct-1").
|
||||
Return(policies, nil)
|
||||
mockStore.EXPECT().
|
||||
GetAccountAgentNetworkGuardrails(ctx, store.LockingStrengthNone, "acct-1").
|
||||
Return(guardrails, nil)
|
||||
}
|
||||
|
||||
func TestReconcile_FirstSynth_EmitsCreate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
mgr, mockStore, mockProxy := newReconcileMgr(t, ctrl)
|
||||
provider := newReconcileTestProvider()
|
||||
policy := newReconcileTestPolicy(provider.ID, "grp-eng")
|
||||
|
||||
expectReconcileSynthInputs(mockStore, ctx, []*types.Provider{provider}, []*types.Policy{policy}, []*types.Guardrail{})
|
||||
mockProxy.EXPECT().GetOIDCValidationConfig().Return(proxy.OIDCValidationConfig{})
|
||||
|
||||
var sentMappings []*proto.ProxyMapping
|
||||
mockProxy.EXPECT().
|
||||
SendServiceUpdateToCluster(ctx, "acct-1", gomock.Any(), "eu.proxy.netbird.io").
|
||||
Do(func(_ context.Context, _ string, m *proto.ProxyMapping, _ string) {
|
||||
sentMappings = append(sentMappings, m)
|
||||
})
|
||||
|
||||
mgr.reconcile(ctx, "acct-1")
|
||||
|
||||
require.Len(t, sentMappings, 1, "first synth must emit one mapping")
|
||||
assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, sentMappings[0].Type, "first synth is a Create")
|
||||
assert.Equal(t, "agent-net-svc-acct-1", sentMappings[0].Id, "stable account-scoped virtual service id")
|
||||
assert.Equal(t, "violet.eu.proxy.netbird.io", sentMappings[0].Domain, "domain comes from settings (subdomain.cluster)")
|
||||
|
||||
mgr.reconcileMu.Lock()
|
||||
cached := mgr.reconcileCache["acct-1"]
|
||||
mgr.reconcileMu.Unlock()
|
||||
require.Len(t, cached, 1, "cache must hold the synth result for next diff")
|
||||
}
|
||||
|
||||
func TestReconcile_NoChange_EmitsNothingExtra(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
mgr, mockStore, mockProxy := newReconcileMgr(t, ctrl)
|
||||
provider := newReconcileTestProvider()
|
||||
policy := newReconcileTestPolicy(provider.ID, "grp-eng")
|
||||
|
||||
// Two identical synth runs.
|
||||
mockStore.EXPECT().
|
||||
GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "acct-1").
|
||||
Return(newReconcileTestSettings(), nil).Times(2)
|
||||
mockStore.EXPECT().
|
||||
GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, "acct-1").
|
||||
Return([]*types.Provider{provider}, nil).Times(2)
|
||||
mockStore.EXPECT().
|
||||
GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, "acct-1").
|
||||
Return([]*types.Policy{policy}, nil).Times(2)
|
||||
mockStore.EXPECT().
|
||||
GetAccountAgentNetworkGuardrails(ctx, store.LockingStrengthNone, "acct-1").
|
||||
Return([]*types.Guardrail{}, nil).Times(2)
|
||||
mockProxy.EXPECT().GetOIDCValidationConfig().Return(proxy.OIDCValidationConfig{}).Times(2)
|
||||
|
||||
createCalls := 0
|
||||
updateCalls := 0
|
||||
mockProxy.EXPECT().
|
||||
SendServiceUpdateToCluster(ctx, "acct-1", gomock.Any(), gomock.Any()).
|
||||
Do(func(_ context.Context, _ string, m *proto.ProxyMapping, _ string) {
|
||||
switch m.Type {
|
||||
case proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED:
|
||||
createCalls++
|
||||
case proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED:
|
||||
updateCalls++
|
||||
}
|
||||
}).
|
||||
AnyTimes()
|
||||
|
||||
mgr.reconcile(ctx, "acct-1")
|
||||
mgr.reconcile(ctx, "acct-1")
|
||||
|
||||
assert.Equal(t, 1, createCalls, "first reconcile creates")
|
||||
assert.Equal(t, 1, updateCalls, "second reconcile re-pushes as Modified (no semantic change but mapping fields refresh)")
|
||||
}
|
||||
|
||||
func TestReconcile_PolicyRemoved_EmitsDelete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
mgr, mockStore, mockProxy := newReconcileMgr(t, ctrl)
|
||||
provider := newReconcileTestProvider()
|
||||
policy := newReconcileTestPolicy(provider.ID, "grp-eng")
|
||||
|
||||
gomock.InOrder(
|
||||
// First reconcile: provider + policy, synthesised.
|
||||
mockStore.EXPECT().GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "acct-1").Return(newReconcileTestSettings(), nil),
|
||||
mockStore.EXPECT().GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, "acct-1").Return([]*types.Provider{provider}, nil),
|
||||
mockStore.EXPECT().GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, "acct-1").Return([]*types.Policy{policy}, nil),
|
||||
mockStore.EXPECT().GetAccountAgentNetworkGuardrails(ctx, store.LockingStrengthNone, "acct-1").Return([]*types.Guardrail{}, nil),
|
||||
// Second reconcile: policy gone, provider stays but no longer referenced.
|
||||
mockStore.EXPECT().GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "acct-1").Return(newReconcileTestSettings(), nil),
|
||||
mockStore.EXPECT().GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, "acct-1").Return([]*types.Provider{provider}, nil),
|
||||
mockStore.EXPECT().GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, "acct-1").Return([]*types.Policy{}, nil),
|
||||
)
|
||||
mockProxy.EXPECT().GetOIDCValidationConfig().Return(proxy.OIDCValidationConfig{}).AnyTimes()
|
||||
|
||||
var seenTypes []proto.ProxyMappingUpdateType
|
||||
mockProxy.EXPECT().
|
||||
SendServiceUpdateToCluster(ctx, "acct-1", gomock.Any(), "eu.proxy.netbird.io").
|
||||
Do(func(_ context.Context, _ string, m *proto.ProxyMapping, _ string) {
|
||||
seenTypes = append(seenTypes, m.Type)
|
||||
}).
|
||||
AnyTimes()
|
||||
|
||||
mgr.reconcile(ctx, "acct-1")
|
||||
mgr.reconcile(ctx, "acct-1")
|
||||
|
||||
require.Len(t, seenTypes, 2, "create then delete")
|
||||
assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, seenTypes[0])
|
||||
assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, seenTypes[1])
|
||||
|
||||
mgr.reconcileMu.Lock()
|
||||
_, present := mgr.reconcileCache["acct-1"]
|
||||
mgr.reconcileMu.Unlock()
|
||||
assert.False(t, present, "cache for the account must be cleared once nothing is synthesised")
|
||||
}
|
||||
|
||||
func TestReconcile_NilProxyController_NoOp(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr := &managerImpl{
|
||||
reconcileCache: make(map[string]map[string]syntheticMapping),
|
||||
}
|
||||
// Must not panic; must not query the store.
|
||||
mgr.reconcile(ctx, "acct-1")
|
||||
}
|
||||
|
||||
func TestReconcile_EmptyAccountID_NoOp(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
mgr, _, _ := newReconcileMgr(t, ctrl)
|
||||
// Empty accountID short-circuits before any store call.
|
||||
mgr.reconcile(ctx, "")
|
||||
}
|
||||
|
||||
// TestDiffMappings_ServingProxyChange — when the proxy serving an account
|
||||
// changes, the same service ID must be deleted on the old proxy and created on
|
||||
// the new one. The cluster cannot be recovered from the mapping's domain: with a
|
||||
// placement-free endpoint the domain does not change at all when the serving
|
||||
// proxy does, so a domain-derived cluster sees no change and emits a plain
|
||||
// update, addressed to a proxy that does not exist.
|
||||
func TestDiffMappings_ServingProxyChange(t *testing.T) {
|
||||
previous := map[string]syntheticMapping{
|
||||
"svc-1": {
|
||||
mapping: &proto.ProxyMapping{Id: "svc-1", AccountId: "acct-1", Domain: "brave-otter.gateway.example.com"},
|
||||
cluster: "proxy.example.com",
|
||||
},
|
||||
}
|
||||
current := map[string]syntheticMapping{
|
||||
"svc-1": {
|
||||
mapping: &proto.ProxyMapping{Id: "svc-1", AccountId: "acct-1", Domain: "brave-otter.gateway.example.com"},
|
||||
cluster: "brave-otter.gateway.example.com",
|
||||
},
|
||||
}
|
||||
|
||||
creates, updates, deletes := diffMappings(previous, current)
|
||||
|
||||
if assert.Len(t, deletes, 1, "the old proxy must be told to drop the mapping") {
|
||||
assert.Equal(t, "proxy.example.com", deletes[0].cluster)
|
||||
}
|
||||
if assert.Len(t, creates, 1, "the new proxy must be told to add it") {
|
||||
assert.Equal(t, "brave-otter.gateway.example.com", creates[0].cluster)
|
||||
}
|
||||
assert.Empty(t, updates, "a serving-proxy move is a delete plus a create, not an update")
|
||||
}
|
||||
|
||||
// TestDiffMappings_UnchangedClusterIsAnUpdate keeps the ordinary path: same
|
||||
// service, same proxy, changed contents.
|
||||
func TestDiffMappings_UnchangedClusterIsAnUpdate(t *testing.T) {
|
||||
previous := map[string]syntheticMapping{
|
||||
"svc-1": {
|
||||
mapping: &proto.ProxyMapping{Id: "svc-1", AccountId: "acct-1", Domain: "otter.proxy.example.com"},
|
||||
cluster: "proxy.example.com",
|
||||
},
|
||||
}
|
||||
current := map[string]syntheticMapping{
|
||||
"svc-1": {
|
||||
mapping: &proto.ProxyMapping{Id: "svc-1", AccountId: "acct-1", Domain: "otter.proxy.example.com"},
|
||||
cluster: "proxy.example.com",
|
||||
},
|
||||
}
|
||||
|
||||
creates, updates, deletes := diffMappings(previous, current)
|
||||
|
||||
assert.Empty(t, creates)
|
||||
assert.Empty(t, deletes)
|
||||
if assert.Len(t, updates, 1) {
|
||||
assert.Equal(t, "proxy.example.com", updates[0].cluster)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiffMappings_RemovedServiceIsDeletedOnItsOwnCluster — a service that has
|
||||
// gone away is deleted on the cluster it was last served by, which is recorded
|
||||
// rather than re-derived.
|
||||
func TestDiffMappings_RemovedServiceIsDeletedOnItsOwnCluster(t *testing.T) {
|
||||
previous := map[string]syntheticMapping{
|
||||
"svc-1": {
|
||||
mapping: &proto.ProxyMapping{Id: "svc-1", AccountId: "acct-1", Domain: "brave-otter.gateway.example.com"},
|
||||
cluster: "brave-otter.gateway.example.com",
|
||||
},
|
||||
}
|
||||
|
||||
creates, updates, deletes := diffMappings(previous, map[string]syntheticMapping{})
|
||||
|
||||
assert.Empty(t, creates)
|
||||
assert.Empty(t, updates)
|
||||
if assert.Len(t, deletes, 1) {
|
||||
assert.Equal(t, "brave-otter.gateway.example.com", deletes[0].cluster)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/server/account"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/permissions"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/permissions/modules"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/permissions/operations"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
nbtypes "github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
// bootstrapFixture wires a real sqlite store to a gomock permissions manager
|
||||
// so tests can grant or deny the settings permission per case.
|
||||
type bootstrapFixture struct {
|
||||
manager Manager
|
||||
store store.Store
|
||||
perms *permissions.MockManager
|
||||
// vendor stands in for the provider credential check's vendor call, which
|
||||
// runs on every provider write. Without it these tests would reach a real
|
||||
// vendor to save a record.
|
||||
vendor *stubLister
|
||||
}
|
||||
|
||||
func newBootstrapFixture(t *testing.T) *bootstrapFixture {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("sqlite store not properly supported on Windows yet")
|
||||
}
|
||||
t.Setenv("NETBIRD_STORE_ENGINE", string(nbtypes.SqliteStoreEngine))
|
||||
|
||||
st, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir())
|
||||
require.NoError(t, err, "test store setup must succeed")
|
||||
t.Cleanup(cleanUp)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
perms := permissions.NewMockManager(ctrl)
|
||||
|
||||
accounts := account.NewMockManager(ctrl)
|
||||
accounts.EXPECT().StoreEvent(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
|
||||
accounts.EXPECT().UpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
|
||||
accounts.EXPECT().BufferUpdateAccountPeers(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
|
||||
|
||||
vendor := &stubLister{}
|
||||
return &bootstrapFixture{
|
||||
manager: NewManager(st, perms, accounts, nil, WithModelLister(vendor)),
|
||||
store: st,
|
||||
perms: perms,
|
||||
vendor: vendor,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *bootstrapFixture) expectPermission(accountID, userID string, module modules.Module, op operations.Operation, allowed bool) {
|
||||
f.perms.EXPECT().
|
||||
ValidateUserPermissions(gomock.Any(), accountID, userID, module, op).
|
||||
Return(allowed, context.Background(), nil)
|
||||
}
|
||||
|
||||
func (f *bootstrapFixture) createSettings(ctx context.Context, accountID, userID, proxyAddress, endpoint string) (*types.Settings, error) {
|
||||
return f.manager.CreateSettings(ctx, userID, types.DefaultSettings(accountID), proxyAddress, endpoint)
|
||||
}
|
||||
|
||||
// TestCreateSettingsRequiresPermission pins the gate: bootstrap assigns the
|
||||
// account's immutable endpoint, a settings write requiring the settings
|
||||
// Create permission — and a denial leaves no row behind.
|
||||
func TestCreateSettingsRequiresPermission(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, false)
|
||||
|
||||
_, err := f.createSettings(ctx, "account1", "user1", "cluster1.example.com", "")
|
||||
require.Error(t, err, "bootstrap without the settings permission must fail")
|
||||
var sErr *status.Error
|
||||
require.ErrorAs(t, err, &sErr)
|
||||
assert.Equal(t, status.PermissionDenied, sErr.Type(), "denial should surface as permission denied")
|
||||
|
||||
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
||||
assert.Error(t, err, "settings row must not be created when bootstrap is denied")
|
||||
}
|
||||
|
||||
// TestCreateSettingsLabeled pins the labeled shape: the server allocates an
|
||||
// adjective-noun label beneath the proxy address, the pin is not dedicated,
|
||||
// and the domain records the full endpoint hostname.
|
||||
func TestCreateSettingsLabeled(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
||||
|
||||
created, err := f.createSettings(ctx, "account1", "user1", "Cluster1.Example.com", "")
|
||||
require.NoError(t, err, "labeled bootstrap must succeed")
|
||||
assert.Equal(t, "cluster1.example.com", created.ProxyAddress, "proxy address must be pinned lowercased")
|
||||
require.True(t, strings.HasSuffix(created.Domain, ".cluster1.example.com"),
|
||||
"domain must hang one label beneath the proxy address: %s", created.Domain)
|
||||
label := strings.TrimSuffix(created.Domain, ".cluster1.example.com")
|
||||
assert.NotContains(t, label, ".", "the allocated label must be a single DNS label: %s", label)
|
||||
assert.False(t, created.Dedicated(), "a labeled pin is not dedicated")
|
||||
assert.Equal(t, created.Domain, created.Endpoint(), "the endpoint is the domain column")
|
||||
|
||||
stored, err := f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
||||
require.NoError(t, err, "bootstrap must persist the row")
|
||||
assert.Equal(t, created.Domain, stored.Domain)
|
||||
assert.Equal(t, created.ProxyAddress, stored.ProxyAddress)
|
||||
}
|
||||
|
||||
// TestCreateSettingsSelfAddressed pins the dedicated shape: the endpoint is
|
||||
// claimed verbatim (normalized), Domain == ProxyAddress, and the claim
|
||||
// succeeds with no proxy declaring the address yet (address-first).
|
||||
func TestCreateSettingsSelfAddressed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
||||
|
||||
created, err := f.createSettings(ctx, "account1", "user1", "", "Brave-Otter.GW.Example.com")
|
||||
require.NoError(t, err, "self-addressed bootstrap must succeed")
|
||||
assert.Equal(t, "brave-otter.gw.example.com", created.Domain, "endpoint must be claimed lowercased")
|
||||
assert.Equal(t, created.Domain, created.ProxyAddress, "self-addressed: proxy address is the endpoint")
|
||||
assert.True(t, created.Dedicated(), "a self-addressed pin is dedicated")
|
||||
}
|
||||
|
||||
// TestCreateSettingsIdentityFieldValidation pins the request contract: exactly
|
||||
// one of proxyAddress and endpoint, and both must be well-formed hostnames.
|
||||
func TestCreateSettingsIdentityFieldValidation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
cases := map[string]struct {
|
||||
proxyAddress string
|
||||
endpoint string
|
||||
}{
|
||||
"neither": {"", ""},
|
||||
"both": {"cluster1.example.com", "gw.example.com"},
|
||||
"trailing dot endpoint": {"", "gw.example.com."},
|
||||
"leading dot endpoint": {"", ".gw.example.com"},
|
||||
"whitespace inside": {"", "g w.example.com"},
|
||||
"empty label in parent": {"eu..example.com", ""},
|
||||
"hyphen-edged label": {"", "-gw.example.com"},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
f := newBootstrapFixture(t)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
||||
|
||||
_, err := f.createSettings(ctx, "account1", "user1", tc.proxyAddress, tc.endpoint)
|
||||
require.Error(t, err, "invalid identity input must be rejected")
|
||||
var sErr *status.Error
|
||||
require.ErrorAs(t, err, &sErr)
|
||||
assert.Equal(t, status.InvalidArgument, sErr.Type(), "rejection must be a validation error")
|
||||
|
||||
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
||||
assert.Error(t, err, "no row may be left behind by a rejected bootstrap")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateSettingsConflictsOnSecondBootstrap pins that bootstrap is a
|
||||
// one-time create per account: a second call is a conflict, whatever shape it
|
||||
// asks for, and the original row survives untouched.
|
||||
func TestCreateSettingsConflictsOnSecondBootstrap(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
||||
|
||||
first, err := f.createSettings(ctx, "account1", "user1", "cluster1.example.com", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
||||
_, err = f.createSettings(ctx, "account1", "user1", "", "other.example.com")
|
||||
require.Error(t, err, "second bootstrap must fail")
|
||||
var sErr *status.Error
|
||||
require.ErrorAs(t, err, &sErr)
|
||||
assert.Equal(t, status.AlreadyExists, sErr.Type(), "second bootstrap must surface as a conflict")
|
||||
|
||||
stored, err := f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, first.Domain, stored.Domain, "the original endpoint must survive the rejected bootstrap")
|
||||
}
|
||||
|
||||
// TestCreateSettingsEndpointTaken pins global hostname uniqueness: a hostname
|
||||
// held by one account cannot be claimed by another, in either direction —
|
||||
// self-addressed onto self-addressed, or self-addressed onto an allocated
|
||||
// labeled endpoint.
|
||||
func TestCreateSettingsEndpointTaken(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkSettings, operations.Create, true)
|
||||
first, err := f.createSettings(ctx, "account1", "user1", "", "gw.example.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
f.expectPermission("account2", "user2", modules.AgentNetworkSettings, operations.Create, true)
|
||||
_, err = f.createSettings(ctx, "account2", "user2", "", "gw.example.com")
|
||||
require.Error(t, err, "a taken hostname must be refused")
|
||||
var sErr *status.Error
|
||||
require.ErrorAs(t, err, &sErr)
|
||||
assert.Equal(t, status.AlreadyExists, sErr.Type(), "the refusal must surface as a conflict")
|
||||
|
||||
f.expectPermission("account3", "user3", modules.AgentNetworkSettings, operations.Create, true)
|
||||
_, err = f.createSettings(ctx, "account3", "user3", "", first.Domain)
|
||||
require.Error(t, err, "claiming another account's endpoint must be refused")
|
||||
}
|
||||
|
||||
// TestCreateProviderHasNoSettingsSideEffects pins the decoupling: provider
|
||||
// create needs only the providers permission (gomock fails the test on any
|
||||
// settings-permission call) and never creates a settings row.
|
||||
func TestCreateProviderHasNoSettingsSideEffects(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f := newBootstrapFixture(t)
|
||||
f.expectPermission("account1", "user1", modules.AgentNetworkProviders, operations.Create, true)
|
||||
|
||||
provider := types.NewProvider("account1")
|
||||
provider.ProviderID = "openai_api"
|
||||
provider.Name = "openai"
|
||||
provider.UpstreamURL = "https://api.openai.com"
|
||||
provider.APIKey = "sk-test"
|
||||
provider.Enabled = true
|
||||
|
||||
created, err := f.manager.CreateProvider(ctx, "user1", provider)
|
||||
require.NoError(t, err, "provider create must succeed on the providers permission alone")
|
||||
require.NotNil(t, created)
|
||||
|
||||
_, err = f.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "account1")
|
||||
assert.Error(t, err, "provider create must not conjure a settings row")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,178 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
)
|
||||
|
||||
// decodeServiceGuardrailConfig pulls the llm_guardrail middleware config off the
|
||||
// synthesised service's single target.
|
||||
func decodeServiceGuardrailConfig(t *testing.T, svc *rpservice.Service) guardrailConfig {
|
||||
t.Helper()
|
||||
require.NotEmpty(t, svc.Targets, "synth service must carry a target")
|
||||
for _, mw := range svc.Targets[0].Options.Middlewares {
|
||||
if mw.ID == middlewareIDLLMGuardrail {
|
||||
var cfg guardrailConfig
|
||||
require.NoError(t, json.Unmarshal(mw.ConfigJSON, &cfg), "guardrail config must decode")
|
||||
return cfg
|
||||
}
|
||||
}
|
||||
t.Fatal("llm_guardrail middleware not present on synthesised service")
|
||||
return guardrailConfig{}
|
||||
}
|
||||
|
||||
// decodeMiddlewareRawConfig returns the raw ConfigJSON bytes for the named
|
||||
// middleware on the synth service's target, or fails the test.
|
||||
func decodeMiddlewareRawConfig(t *testing.T, svc *rpservice.Service, id string) []byte {
|
||||
t.Helper()
|
||||
require.NotEmpty(t, svc.Targets, "synth service must carry a target")
|
||||
for _, mw := range svc.Targets[0].Options.Middlewares {
|
||||
if mw.ID == id {
|
||||
return mw.ConfigJSON
|
||||
}
|
||||
}
|
||||
t.Fatalf("middleware %q not present on synthesised service", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveGuardrailAndPolicy persists a guardrail with prompt capture + redact + a
|
||||
// model allowlist, referenced by one enabled policy. Shared by the GC-3 tests.
|
||||
func saveGuardrailAndPolicy(t *testing.T, ctx context.Context, s store.Store, provider *types.Provider) {
|
||||
t.Helper()
|
||||
guardrail := &types.Guardrail{
|
||||
ID: "ainguard-1",
|
||||
AccountID: testAccountID,
|
||||
Name: "strict",
|
||||
Checks: types.GuardrailChecks{
|
||||
ModelAllowlist: types.GuardrailModelAllowlist{Enabled: true, Models: []string{"gpt-5.4"}},
|
||||
PromptCapture: types.GuardrailPromptCapture{Enabled: true, RedactPii: true},
|
||||
},
|
||||
}
|
||||
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, guardrail))
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", guardrail.ID)))
|
||||
}
|
||||
|
||||
// TestSynthesizeServices_RealStore_PromptCaptureAccountIsSoleControl is the
|
||||
// GC-3 contract: the account master switch (EnablePromptCollection) is the
|
||||
// SOLE control for capture enablement. Policy-level guardrail prompt_capture is
|
||||
// ignored for enablement — operators don't need to attach a capture guardrail
|
||||
// to a policy just to turn capture on for the account. Off by default.
|
||||
func TestSynthesizeServices_RealStore_PromptCaptureAccountIsSoleControl(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||
require.NoError(t, err, "real sqlite test store must come up")
|
||||
defer cleanup()
|
||||
|
||||
// Account collection master switch OFF (default).
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
saveGuardrailAndPolicy(t, ctx, s, newSynthTestProvider())
|
||||
|
||||
services, err := SynthesizeServices(ctx, s, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, services, 1)
|
||||
|
||||
cfg := decodeServiceGuardrailConfig(t, services[0])
|
||||
assert.Equal(t, map[string][]string{"prov-1": {"gpt-5.4"}}, cfg.ProviderAllowlists,
|
||||
"model allowlist is a pure policy guardrail and must reach the per-provider config")
|
||||
assert.False(t, cfg.PromptCapture.Enabled,
|
||||
"prompt capture must be off when the account toggle is off, even with a capture-enabled guardrail")
|
||||
}
|
||||
|
||||
// TestSynthesizeServices_RealStore_PromptCaptureFlowsWhenAccountOptsIn proves
|
||||
// the account toggle is sufficient on its own — even with NO guardrail
|
||||
// attached to the policy, capture fires when the account opts in. Redact is
|
||||
// the OR of account + guardrail.
|
||||
func TestSynthesizeServices_RealStore_PromptCaptureFlowsWhenAccountOptsIn(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||
require.NoError(t, err, "real sqlite test store must come up")
|
||||
defer cleanup()
|
||||
|
||||
settings := newSynthTestSettings()
|
||||
settings.EnablePromptCollection = true
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, settings))
|
||||
|
||||
// Save a provider and a policy with NO guardrails attached — proves the
|
||||
// account toggle is sufficient on its own.
|
||||
provider := newSynthTestProvider()
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
|
||||
|
||||
services, err := SynthesizeServices(ctx, s, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, services, 1)
|
||||
|
||||
cfg := decodeServiceGuardrailConfig(t, services[0])
|
||||
assert.True(t, cfg.PromptCapture.Enabled,
|
||||
"account toggle alone must enable capture; no guardrail attachment required")
|
||||
}
|
||||
|
||||
// TestSynthesizeServices_RealStore_AccountRedactWithoutGuardrailRedact proves
|
||||
// the redact OR-merge from the account side: account RedactPii on, guardrail
|
||||
// redact off, capture on at both levels.
|
||||
func TestSynthesizeServices_RealStore_AccountRedactWithoutGuardrailRedact(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||
require.NoError(t, err, "real sqlite test store must come up")
|
||||
defer cleanup()
|
||||
|
||||
settings := newSynthTestSettings()
|
||||
settings.EnablePromptCollection = true
|
||||
settings.RedactPii = true
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, settings))
|
||||
|
||||
provider := newSynthTestProvider()
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
guardrail := &types.Guardrail{
|
||||
ID: "ainguard-noredact",
|
||||
AccountID: testAccountID,
|
||||
Name: "capture-only",
|
||||
Checks: types.GuardrailChecks{
|
||||
PromptCapture: types.GuardrailPromptCapture{Enabled: true, RedactPii: false},
|
||||
},
|
||||
}
|
||||
require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, guardrail))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", guardrail.ID)))
|
||||
|
||||
services, err := SynthesizeServices(ctx, s, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, services, 1)
|
||||
|
||||
cfg := decodeServiceGuardrailConfig(t, services[0])
|
||||
assert.True(t, cfg.PromptCapture.Enabled, "capture on (account + guardrail)")
|
||||
assert.True(t, cfg.PromptCapture.RedactPii, "account RedactPii must apply even when the guardrail leaves it off (OR)")
|
||||
}
|
||||
|
||||
// TestSynthesizeServices_RealStore_NoGuardrail_CaptureOff pins the default:
|
||||
// with no guardrail referenced, the synth service's guardrail config has prompt
|
||||
// capture disabled and an empty allowlist. This is the "off by default" baseline
|
||||
// the account switch must preserve.
|
||||
func TestSynthesizeServices_RealStore_NoGuardrail_CaptureOff(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||
require.NoError(t, err, "real sqlite test store must come up")
|
||||
defer cleanup()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
provider := newSynthTestProvider()
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
|
||||
|
||||
services, err := SynthesizeServices(ctx, s, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, services, 1, "exactly one synth service expected")
|
||||
|
||||
cfg := decodeServiceGuardrailConfig(t, services[0])
|
||||
assert.Empty(t, cfg.ProviderAllowlists, "no guardrail → provider unrestricted (absent from map)")
|
||||
assert.False(t, cfg.PromptCapture.Enabled, "no guardrail → prompt capture off by default")
|
||||
assert.False(t, cfg.PromptCapture.RedactPii, "no guardrail → redact off by default")
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
rpproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
||||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
)
|
||||
|
||||
// TestSynthesizeServices_RealStore_LogCollectionOff_SuppressesAccessLog drives the
|
||||
// happy default: account settings ship with EnableLogCollection=false, so the
|
||||
// synthesised target opts out of access-log emission (DisableAccessLog=true) and
|
||||
// the proto mapping the proxy receives reflects that.
|
||||
func TestSynthesizeServices_RealStore_LogCollectionOff_SuppressesAccessLog(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||
require.NoError(t, err, "real sqlite test store must come up")
|
||||
defer cleanup()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
provider := newSynthTestProvider()
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
|
||||
|
||||
services, err := SynthesizeServices(ctx, s, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, services, 1, "exactly one synth service expected")
|
||||
require.NotEmpty(t, services[0].Targets, "synth service must carry a target")
|
||||
assert.True(t, services[0].Targets[0].Options.DisableAccessLog,
|
||||
"EnableLogCollection=false (default) must produce DisableAccessLog=true on the synth target")
|
||||
|
||||
mapping := services[0].ToProtoMapping(rpservice.Update, "", rpproxy.OIDCValidationConfig{})
|
||||
require.NotEmpty(t, mapping.GetPath(), "proto mapping must carry a path")
|
||||
assert.True(t, mapping.GetPath()[0].GetOptions().GetDisableAccessLog(),
|
||||
"proto mapping must propagate DisableAccessLog=true so the proxy suppresses access-log emission")
|
||||
}
|
||||
|
||||
// TestSynthesizeServices_RealStore_LogCollectionOn_PermitsAccessLog asserts the
|
||||
// inverse: once the account opts in, the synth target leaves DisableAccessLog
|
||||
// at its default false and the proto wire stays unset.
|
||||
func TestSynthesizeServices_RealStore_LogCollectionOn_PermitsAccessLog(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||
require.NoError(t, err, "real sqlite test store must come up")
|
||||
defer cleanup()
|
||||
|
||||
settings := newSynthTestSettings()
|
||||
settings.EnableLogCollection = true
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, settings))
|
||||
provider := newSynthTestProvider()
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
|
||||
|
||||
services, err := SynthesizeServices(ctx, s, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, services, 1, "exactly one synth service expected")
|
||||
require.NotEmpty(t, services[0].Targets, "synth service must carry a target")
|
||||
assert.False(t, services[0].Targets[0].Options.DisableAccessLog,
|
||||
"EnableLogCollection=true must leave DisableAccessLog=false on the synth target")
|
||||
|
||||
mapping := services[0].ToProtoMapping(rpservice.Update, "", rpproxy.OIDCValidationConfig{})
|
||||
require.NotEmpty(t, mapping.GetPath(), "proto mapping must carry a path")
|
||||
assert.False(t, mapping.GetPath()[0].GetOptions().GetDisableAccessLog(),
|
||||
"proto mapping must propagate DisableAccessLog=false so access-log emission stays on")
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
)
|
||||
|
||||
// parserRedactConfig mirrors the on-wire shape of the redact + capture knobs
|
||||
// that both llm_request_parser and llm_response_parser unmarshal. We don't
|
||||
// import the proxy-side packages from a management test (cross-module), so we
|
||||
// decode the JSON directly and assert on the fields that are part of the
|
||||
// synth contract.
|
||||
type parserRedactConfig struct {
|
||||
RedactPii bool `json:"redact_pii,omitempty"`
|
||||
CapturePrompt *bool `json:"capture_prompt,omitempty"` // present only on the request parser
|
||||
CaptureCompletion *bool `json:"capture_completion,omitempty"` // present only on the response parser
|
||||
}
|
||||
|
||||
// TestSynthesizeServices_RealStore_ParserConfigsCarryRedactPii is the
|
||||
// management-side contract test for the request/response parser redaction
|
||||
// wiring. When settings.RedactPii is true, the synthesised middleware chain
|
||||
// MUST stamp redact_pii=true on both llm_request_parser and llm_response_parser
|
||||
// configs — otherwise the parsers ship raw prompts / completions to the
|
||||
// access log even though the account has opted in. This is exactly the live
|
||||
// leak path that motivated the parser-side redaction in the first place.
|
||||
func TestSynthesizeServices_RealStore_ParserConfigsCarryRedactPii(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||
require.NoError(t, err, "real sqlite test store must come up")
|
||||
defer cleanup()
|
||||
|
||||
settings := newSynthTestSettings()
|
||||
settings.RedactPii = true
|
||||
settings.EnablePromptCollection = true
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, settings))
|
||||
|
||||
provider := newSynthTestProvider()
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
|
||||
|
||||
services, err := SynthesizeServices(ctx, s, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, services, 1, "exactly one synth service expected")
|
||||
|
||||
for _, parserID := range []string{middlewareIDLLMRequestParser, middlewareIDLLMResponseParser} {
|
||||
raw := decodeMiddlewareRawConfig(t, services[0], parserID)
|
||||
var cfg parserRedactConfig
|
||||
require.NoError(t, json.Unmarshal(raw, &cfg), "%s config must be valid JSON", parserID)
|
||||
assert.True(t, cfg.RedactPii, "%s config must carry redact_pii=true when settings.RedactPii is on (otherwise the parser ships raw prompts/completions to the access log)", parserID)
|
||||
}
|
||||
// The capture flag is set explicitly to enable_prompt_collection on each
|
||||
// parser. With it on here, both must allow emission.
|
||||
reqCfg := decodeParserConfig(t, services[0], middlewareIDLLMRequestParser)
|
||||
require.NotNil(t, reqCfg.CapturePrompt, "request parser must carry an explicit capture_prompt")
|
||||
assert.True(t, *reqCfg.CapturePrompt, "capture_prompt=true when EnablePromptCollection=true")
|
||||
respCfg := decodeParserConfig(t, services[0], middlewareIDLLMResponseParser)
|
||||
require.NotNil(t, respCfg.CaptureCompletion, "response parser must carry an explicit capture_completion")
|
||||
assert.True(t, *respCfg.CaptureCompletion, "capture_completion=true when EnablePromptCollection=true")
|
||||
}
|
||||
|
||||
// decodeParserConfig is a small helper around decodeMiddlewareRawConfig that
|
||||
// also unmarshals into parserRedactConfig.
|
||||
func decodeParserConfig(t *testing.T, svc *rpservice.Service, parserID string) parserRedactConfig {
|
||||
t.Helper()
|
||||
raw := decodeMiddlewareRawConfig(t, svc, parserID)
|
||||
var cfg parserRedactConfig
|
||||
require.NoError(t, json.Unmarshal(raw, &cfg), "%s config must be valid JSON", parserID)
|
||||
return cfg
|
||||
}
|
||||
|
||||
// TestSynthesizeServices_RealStore_ParserConfigsSuppressCaptureWhenLogCollectionOnly
|
||||
// is the contract test for the bug: enable_log_collection=true with
|
||||
// enable_prompt_collection=false MUST result in capture_prompt=false on the
|
||||
// request parser AND capture_completion=false on the response parser, so the
|
||||
// access-log row stays metadata-only (provider, model, tokens, cost) and
|
||||
// carries NO prompt input nor response output. Without this, operators who
|
||||
// want billing-style logs end up with raw user prompts and model outputs in
|
||||
// every access-log entry.
|
||||
func TestSynthesizeServices_RealStore_ParserConfigsSuppressCaptureWhenLogCollectionOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||
require.NoError(t, err, "real sqlite test store must come up")
|
||||
defer cleanup()
|
||||
|
||||
settings := newSynthTestSettings()
|
||||
settings.EnableLogCollection = true // operator wants logs ON
|
||||
settings.EnablePromptCollection = false // but NOT content capture
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, settings))
|
||||
|
||||
provider := newSynthTestProvider()
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
|
||||
|
||||
services, err := SynthesizeServices(ctx, s, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, services, 1)
|
||||
|
||||
reqCfg := decodeParserConfig(t, services[0], middlewareIDLLMRequestParser)
|
||||
require.NotNil(t, reqCfg.CapturePrompt, "request parser must carry an explicit capture_prompt gate")
|
||||
assert.False(t, *reqCfg.CapturePrompt, "capture_prompt MUST be false when EnablePromptCollection is off — otherwise llm.request_prompt_raw leaks user input into the access log")
|
||||
|
||||
respCfg := decodeParserConfig(t, services[0], middlewareIDLLMResponseParser)
|
||||
require.NotNil(t, respCfg.CaptureCompletion, "response parser must carry an explicit capture_completion gate")
|
||||
assert.False(t, *respCfg.CaptureCompletion, "capture_completion MUST be false when EnablePromptCollection is off — otherwise llm.response_completion leaks model output into the access log")
|
||||
}
|
||||
|
||||
// TestSynthesizeServices_RealStore_ParserConfigsOmitRedactPiiWhenOff proves
|
||||
// the inverse: with the account toggle off, the parser configs stay clean (no
|
||||
// redact_pii field, which the parsers treat as zero / no redaction). This is
|
||||
// the operator-opt-out path — the access log keeps raw prompts/completions
|
||||
// for debugging until the operator opts in.
|
||||
func TestSynthesizeServices_RealStore_ParserConfigsOmitRedactPiiWhenOff(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||
require.NoError(t, err)
|
||||
defer cleanup()
|
||||
|
||||
// Default settings: RedactPii = false.
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
provider := newSynthTestProvider()
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
|
||||
|
||||
services, err := SynthesizeServices(ctx, s, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, services, 1)
|
||||
|
||||
for _, parserID := range []string{middlewareIDLLMRequestParser, middlewareIDLLMResponseParser} {
|
||||
raw := decodeMiddlewareRawConfig(t, services[0], parserID)
|
||||
// Inspect the decoded JSON directly: a struct decode would also pass
|
||||
// if redact_pii were present-but-false. The contract is that the key
|
||||
// is omitted entirely while the account toggle is off.
|
||||
var rawCfg map[string]json.RawMessage
|
||||
require.NoError(t, json.Unmarshal(raw, &rawCfg), "%s config must be valid JSON", parserID)
|
||||
assert.NotContains(t, rawCfg, "redact_pii",
|
||||
"%s config must omit redact_pii entirely while the account toggle is off", parserID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
sharedllm "github.com/netbirdio/netbird/shared/llm"
|
||||
)
|
||||
|
||||
// costMeterConfig is the JSON shape the proxy-side cost_meter middleware
|
||||
// expects (mirror-type pattern, same as routerConfig). The top-level
|
||||
// "pricing" wrapper is the feature-detection signal: an old proxy's config
|
||||
// struct ignores it as an unknown field, and a new proxy treats its
|
||||
// absence as "old management" (skips every cost computation and warns).
|
||||
type costMeterConfig struct {
|
||||
Pricing *costMeterPricing `json:"pricing,omitempty"`
|
||||
}
|
||||
|
||||
// costMeterPricing carries the full pricing table:
|
||||
// - Defaults: surface ("openai"/"anthropic"/"bedrock") -> normalized
|
||||
// model id -> rates. The full default table ships to every account —
|
||||
// it is small (~10 KB) and keeps gateway-style providers (which
|
||||
// enumerate no models) priced for every catalog model.
|
||||
// - Providers: provider record id (matched against the
|
||||
// llm.resolved_provider_id metadata llm_router stamps) -> normalized
|
||||
// model id -> rates. Entries are fully materialized here at synth
|
||||
// time — default cache rates already folded in — so the proxy does
|
||||
// two map lookups and no merging.
|
||||
type costMeterPricing struct {
|
||||
Defaults map[string]map[string]pricing.Entry `json:"defaults,omitempty"`
|
||||
Providers map[string]map[string]pricing.Entry `json:"providers,omitempty"`
|
||||
}
|
||||
|
||||
// buildCostMeterConfigJSON assembles the cost_meter middleware config
|
||||
// from the default pricing table plus the operator's stored per-provider
|
||||
// model prices. Same orphan rule as the router: a provider no enabled
|
||||
// policy authorises is unreachable, so its prices are not shipped.
|
||||
//
|
||||
// Overlay semantics per model row:
|
||||
// - The row's model id is normalized exactly the way the proxy's
|
||||
// request parser normalizes the ids it meters (bedrock ARN/region/
|
||||
// version stripping, vertex "@version" stripping), so the per-record
|
||||
// lookup key compares equal to llm.model at billing time.
|
||||
// - The entry starts from the default entry for that model (when one
|
||||
// exists) to inherit cache rates the operator didn't state.
|
||||
// - Operator input/output overlay verbatim — including an explicit 0,
|
||||
// which prices the model as free (self-hosted / internal endpoints)
|
||||
// rather than silently reverting to list price.
|
||||
// - Cache-rate pointers overlay only when non-nil: nil means "inherit
|
||||
// the default", an explicit 0 means "no discount, bill this bucket
|
||||
// at the input rate".
|
||||
func buildCostMeterConfigJSON(providers []*types.Provider, groupIndex map[string][]string) ([]byte, error) {
|
||||
cfg := costMeterConfig{Pricing: &costMeterPricing{
|
||||
Defaults: pricing.DefaultTable(),
|
||||
}}
|
||||
|
||||
perRecord := make(map[string]map[string]pricing.Entry)
|
||||
for _, p := range providers {
|
||||
if _, hasPolicy := groupIndex[p.ID]; !hasPolicy {
|
||||
// Orphan: unreachable via the router, so unpriceable.
|
||||
continue
|
||||
}
|
||||
if len(p.Models) == 0 {
|
||||
// Gateway-style "claim every model" provider: the defaults
|
||||
// table is its price list.
|
||||
continue
|
||||
}
|
||||
entry, _ := catalog.Lookup(p.ProviderID)
|
||||
models := make(map[string]pricing.Entry, len(p.Models))
|
||||
for _, m := range p.Models {
|
||||
id := normalizePricingModelID(p.ProviderID, m.ID)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := models[id]; dup {
|
||||
// First occurrence wins on post-normalization duplicates,
|
||||
// matching providerModelIDs' dedup order for routing.
|
||||
continue
|
||||
}
|
||||
models[id] = materializeEntry(entry.PricingSurfaces, id, m)
|
||||
}
|
||||
if len(models) > 0 {
|
||||
perRecord[p.ID] = models
|
||||
}
|
||||
}
|
||||
if len(perRecord) > 0 {
|
||||
cfg.Pricing.Providers = perRecord
|
||||
}
|
||||
|
||||
out, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal cost_meter middleware config: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// normalizePricingModelID maps an operator-entered model id onto the
|
||||
// normalized id the proxy's request parser emits as llm.model — the key
|
||||
// the cost meter looks up at billing time.
|
||||
func normalizePricingModelID(catalogProviderID, modelID string) string {
|
||||
switch {
|
||||
case catalog.IsBedrockPathStyle(catalogProviderID):
|
||||
return sharedllm.NormalizeBedrockModel(modelID)
|
||||
case catalog.IsVertexPathStyle(catalogProviderID):
|
||||
return sharedllm.NormalizeVertexModel(modelID)
|
||||
default:
|
||||
return modelID
|
||||
}
|
||||
}
|
||||
|
||||
// materializeEntry folds the default entry for (surfaces, model) — when
|
||||
// one exists — under the operator's stored prices, producing the fully
|
||||
// materialized wire entry.
|
||||
func materializeEntry(surfaces []string, normalizedID string, m types.ProviderModel) pricing.Entry {
|
||||
e, _ := pricing.LookupDefault(surfaces, normalizedID) // zero Entry on miss
|
||||
e.InputPer1k = m.InputPer1k
|
||||
e.OutputPer1k = m.OutputPer1k
|
||||
if m.CachedInputPer1k != nil {
|
||||
e.CachedInputPer1k = *m.CachedInputPer1k
|
||||
}
|
||||
if m.CacheReadPer1k != nil {
|
||||
e.CacheReadPer1k = *m.CacheReadPer1k
|
||||
}
|
||||
if m.CacheCreationPer1k != nil {
|
||||
e.CacheCreationPer1k = *m.CacheCreationPer1k
|
||||
}
|
||||
return e
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
)
|
||||
|
||||
func fptr(v float64) *float64 { return &v }
|
||||
|
||||
func decodeCostMeterConfig(t *testing.T, raw []byte) costMeterConfig {
|
||||
t.Helper()
|
||||
var cfg costMeterConfig
|
||||
require.NoError(t, json.Unmarshal(raw, &cfg), "cost meter config must round-trip")
|
||||
require.NotNil(t, cfg.Pricing, "pricing wrapper must be present")
|
||||
return cfg
|
||||
}
|
||||
|
||||
// TestBuildCostMeterConfig_BedrockModelNormalization: the operator may
|
||||
// paste region-prefixed, versioned, or ARN-wrapped Bedrock ids; the
|
||||
// per-record map must be keyed by the normalized id the request parser
|
||||
// emits as llm.model, or the lookup never hits at billing time.
|
||||
func TestBuildCostMeterConfig_BedrockModelNormalization(t *testing.T) {
|
||||
bedrock := &types.Provider{
|
||||
ID: "prov-bedrock",
|
||||
ProviderID: "bedrock_api",
|
||||
Enabled: true,
|
||||
Models: []types.ProviderModel{
|
||||
{ID: "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", InputPer1k: 0.0033, OutputPer1k: 0.0165},
|
||||
// Post-normalization duplicate of the row above under a
|
||||
// different regional spelling — first occurrence wins.
|
||||
{ID: "us.anthropic.claude-sonnet-4-5-20250929-v1:0", InputPer1k: 9.9, OutputPer1k: 9.9},
|
||||
},
|
||||
}
|
||||
raw, err := buildCostMeterConfigJSON([]*types.Provider{bedrock}, map[string][]string{"prov-bedrock": {"grp"}})
|
||||
require.NoError(t, err)
|
||||
cfg := decodeCostMeterConfig(t, raw)
|
||||
|
||||
models := cfg.Pricing.Providers["prov-bedrock"]
|
||||
require.Len(t, models, 1, "both spellings normalize to one model; first row wins")
|
||||
e, ok := models["anthropic.claude-sonnet-4-5"]
|
||||
require.True(t, ok, "key must be the normalized id the parser emits, not the operator's raw spelling")
|
||||
assert.InDelta(t, 0.0033, e.InputPer1k, 1e-9, "first row's rate wins the dedup")
|
||||
assert.InDelta(t, 0.0003, e.CacheReadPer1k, 1e-9, "cache read inherited from the bedrock default entry")
|
||||
assert.InDelta(t, 0.00375, e.CacheCreationPer1k, 1e-9, "cache creation inherited from the bedrock default entry")
|
||||
}
|
||||
|
||||
// TestBuildCostMeterConfig_CacheRateNilVsZero pins the pointer semantics:
|
||||
// nil inherits the default cache rate, explicit 0 clears it (that bucket
|
||||
// bills at the input rate on the proxy).
|
||||
func TestBuildCostMeterConfig_CacheRateNilVsZero(t *testing.T) {
|
||||
p := &types.Provider{
|
||||
ID: "prov-oai",
|
||||
ProviderID: "openai_api",
|
||||
Enabled: true,
|
||||
Models: []types.ProviderModel{
|
||||
{ID: "gpt-4o", InputPer1k: 0.002, OutputPer1k: 0.008}, // nil → inherit 0.00125
|
||||
{ID: "gpt-4o-mini", InputPer1k: 0.0001, OutputPer1k: 0.0005, CachedInputPer1k: fptr(0)}, // explicit 0 → no discount
|
||||
{ID: "my-custom-ft", InputPer1k: 0.01, OutputPer1k: 0.02, CachedInputPer1k: fptr(0.005)}, // unknown model, explicit rate
|
||||
},
|
||||
}
|
||||
raw, err := buildCostMeterConfigJSON([]*types.Provider{p}, map[string][]string{"prov-oai": {"grp"}})
|
||||
require.NoError(t, err)
|
||||
cfg := decodeCostMeterConfig(t, raw)
|
||||
models := cfg.Pricing.Providers["prov-oai"]
|
||||
|
||||
assert.InDelta(t, 0.00125, models["gpt-4o"].CachedInputPer1k, 1e-9, "nil cache pointer inherits the default rate")
|
||||
assert.Zero(t, models["gpt-4o-mini"].CachedInputPer1k, "explicit 0 overrides the default (0.000075) — bucket bills at input rate")
|
||||
custom := models["my-custom-ft"]
|
||||
assert.InDelta(t, 0.005, custom.CachedInputPer1k, 1e-9, "unknown model keeps the operator's explicit cache rate")
|
||||
assert.Zero(t, custom.CacheReadPer1k, "no default to inherit for a model outside the catalog")
|
||||
}
|
||||
|
||||
// TestBuildCostMeterConfig_OrphanAndGatewayProviders: an orphan (no
|
||||
// authorising policy) is unreachable so its prices must not ship; a
|
||||
// gateway with no model rows relies on the defaults table and gets no
|
||||
// per-record entry.
|
||||
func TestBuildCostMeterConfig_OrphanAndGatewayProviders(t *testing.T) {
|
||||
orphan := &types.Provider{
|
||||
ID: "prov-orphan",
|
||||
ProviderID: "openai_api",
|
||||
Enabled: true,
|
||||
Models: []types.ProviderModel{{ID: "gpt-4o", InputPer1k: 1, OutputPer1k: 1}},
|
||||
}
|
||||
gateway := &types.Provider{
|
||||
ID: "prov-litellm",
|
||||
ProviderID: "litellm_proxy",
|
||||
Enabled: true,
|
||||
Models: []types.ProviderModel{},
|
||||
}
|
||||
raw, err := buildCostMeterConfigJSON(
|
||||
[]*types.Provider{orphan, gateway},
|
||||
map[string][]string{"prov-litellm": {"grp"}}, // orphan has no policy
|
||||
)
|
||||
require.NoError(t, err)
|
||||
cfg := decodeCostMeterConfig(t, raw)
|
||||
|
||||
assert.NotContains(t, cfg.Pricing.Providers, "prov-orphan", "orphan provider prices must not ship")
|
||||
assert.NotContains(t, cfg.Pricing.Providers, "prov-litellm", "empty-models gateway needs no per-record entry")
|
||||
assert.NotEmpty(t, cfg.Pricing.Defaults["openai"], "defaults still ship so the gateway's catalog-model traffic is priced")
|
||||
}
|
||||
|
||||
// TestBuildCostMeterConfig_BedrockGeographyOutsideTheOriginalFour is the
|
||||
// accounting half of the geography bug. The docs tell operators to register a
|
||||
// Bedrock id exactly as AWS issues it, region prefix included, and the cost
|
||||
// meter keys its table by the normalized form. While the geography was matched
|
||||
// against a list of four, a profile issued anywhere else kept its prefix,
|
||||
// missed the catalog entry it was meant to inherit from, and billed with a
|
||||
// zero entry underneath the operator's own rates — so every cache bucket
|
||||
// metered free and a model priced only by catalog defaults metered at nothing
|
||||
// at all.
|
||||
func TestBuildCostMeterConfig_BedrockGeographyOutsideTheOriginalFour(t *testing.T) {
|
||||
for _, geo := range []string{"jp", "au", "ca", "sa", "us-gov"} {
|
||||
t.Run(geo, func(t *testing.T) {
|
||||
bedrock := &types.Provider{
|
||||
ID: "prov-bedrock",
|
||||
ProviderID: "bedrock_api",
|
||||
Enabled: true,
|
||||
Models: []types.ProviderModel{
|
||||
{ID: geo + ".anthropic.claude-sonnet-5-20260514-v1:0", InputPer1k: 0.003, OutputPer1k: 0.015},
|
||||
},
|
||||
}
|
||||
raw, err := buildCostMeterConfigJSON([]*types.Provider{bedrock}, map[string][]string{"prov-bedrock": {"grp"}})
|
||||
require.NoError(t, err)
|
||||
cfg := decodeCostMeterConfig(t, raw)
|
||||
|
||||
e, ok := cfg.Pricing.Providers["prov-bedrock"]["anthropic.claude-sonnet-5"]
|
||||
require.True(t, ok, "a %s profile must key by the same normalized id the parser emits", geo)
|
||||
assert.InDelta(t, 0.0003, e.CacheReadPer1k, 1e-9,
|
||||
"cache read must be inherited from the bedrock default entry, not left at zero")
|
||||
assert.InDelta(t, 0.00375, e.CacheCreationPer1k, 1e-9,
|
||||
"cache creation must be inherited from the bedrock default entry, not left at zero")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
)
|
||||
|
||||
// policyForProviders builds an enabled policy authorising the given providers
|
||||
// under the given guardrails (both optional). Groups are irrelevant to
|
||||
// buildProviderAllowlists, which keys purely on destination provider.
|
||||
func policyForProviders(id string, guardrailIDs []string, providerIDs ...string) *types.Policy {
|
||||
return &types.Policy{
|
||||
ID: id,
|
||||
Enabled: true,
|
||||
DestinationProviderIDs: providerIDs,
|
||||
GuardrailIDs: guardrailIDs,
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProviderAllowlists(t *testing.T) {
|
||||
byID := map[string]*types.Guardrail{
|
||||
"g-4o": allowlistGuardrail("g-4o", "acc-1", "gpt-4o"),
|
||||
"g-opus": allowlistGuardrail("g-opus", "acc-1", "claude-opus-4"),
|
||||
"g-disabled": {ID: "g-disabled", Checks: types.GuardrailChecks{ModelAllowlist: types.GuardrailModelAllowlist{Enabled: false, Models: []string{"gpt-4o"}}}},
|
||||
}
|
||||
|
||||
t.Run("all authorising policies restrict yields per-provider union", func(t *testing.T) {
|
||||
policies := []*types.Policy{
|
||||
policyForProviders("p1", []string{"g-4o"}, "prov-x"),
|
||||
policyForProviders("p2", []string{"g-opus"}, "prov-x"),
|
||||
}
|
||||
got := buildProviderAllowlists(policies, byID, nil)
|
||||
assert.Equal(t, map[string][]string{"prov-x": {"claude-opus-4", "gpt-4o"}}, got,
|
||||
"a provider every policy restricts carries the sorted union of their models")
|
||||
})
|
||||
|
||||
t.Run("any un-guardrailed policy leaves the provider unrestricted (omitted)", func(t *testing.T) {
|
||||
policies := []*types.Policy{
|
||||
policyForProviders("p1", []string{"g-4o"}, "prov-x"),
|
||||
policyForProviders("p2", nil, "prov-x"), // no guardrail
|
||||
}
|
||||
got := buildProviderAllowlists(policies, byID, nil)
|
||||
assert.NotContains(t, got, "prov-x",
|
||||
"a provider reachable by an un-guardrailed policy must be omitted so the proxy treats it as unrestricted")
|
||||
})
|
||||
|
||||
t.Run("a disabled allowlist counts as unrestricted", func(t *testing.T) {
|
||||
policies := []*types.Policy{
|
||||
policyForProviders("p1", []string{"g-disabled"}, "prov-x"),
|
||||
}
|
||||
got := buildProviderAllowlists(policies, byID, nil)
|
||||
assert.NotContains(t, got, "prov-x",
|
||||
"a policy whose only guardrail has a disabled allowlist is unrestricted")
|
||||
})
|
||||
|
||||
t.Run("providers are isolated from one another", func(t *testing.T) {
|
||||
policies := []*types.Policy{
|
||||
policyForProviders("p1", []string{"g-4o"}, "prov-x"),
|
||||
policyForProviders("p2", []string{"g-opus"}, "prov-y"),
|
||||
}
|
||||
got := buildProviderAllowlists(policies, byID, nil)
|
||||
assert.Equal(t, []string{"gpt-4o"}, got["prov-x"], "prov-x keeps only its own model")
|
||||
assert.Equal(t, []string{"claude-opus-4"}, got["prov-y"], "prov-y keeps only its own model")
|
||||
})
|
||||
|
||||
t.Run("one policy authorising two providers restricts both", func(t *testing.T) {
|
||||
policies := []*types.Policy{
|
||||
policyForProviders("p1", []string{"g-4o"}, "prov-x", "prov-y"),
|
||||
}
|
||||
got := buildProviderAllowlists(policies, byID, nil)
|
||||
assert.Equal(t, []string{"gpt-4o"}, got["prov-x"])
|
||||
assert.Equal(t, []string{"gpt-4o"}, got["prov-y"])
|
||||
})
|
||||
|
||||
t.Run("union across a single policy's guardrails", func(t *testing.T) {
|
||||
policies := []*types.Policy{
|
||||
policyForProviders("p1", []string{"g-4o", "g-opus"}, "prov-x"),
|
||||
}
|
||||
got := buildProviderAllowlists(policies, byID, nil)
|
||||
assert.ElementsMatch(t, []string{"claude-opus-4", "gpt-4o"}, got["prov-x"],
|
||||
"a policy's own multiple allowlist guardrails union together")
|
||||
})
|
||||
|
||||
t.Run("an enabled allowlist with no models denies everything", func(t *testing.T) {
|
||||
empty := map[string]*types.Guardrail{"g-empty": allowlistGuardrail("g-empty", "acc-1")}
|
||||
got := buildProviderAllowlists([]*types.Policy{
|
||||
policyForProviders("p1", []string{"g-empty"}, "prov-x"),
|
||||
}, empty, nil)
|
||||
assert.Equal(t, map[string][]string{"prov-x": {}}, got,
|
||||
"an enabled-but-empty allowlist is restricted with an empty set, not unrestricted")
|
||||
})
|
||||
}
|
||||
|
||||
// policyForGroups builds an enabled policy binding the given source groups to
|
||||
// the given providers under an optional guardrail.
|
||||
func policyForGroups(id string, groups []string, guardrailIDs []string, providerIDs ...string) *types.Policy {
|
||||
return &types.Policy{
|
||||
ID: id,
|
||||
Enabled: true,
|
||||
SourceGroups: groups,
|
||||
DestinationProviderIDs: providerIDs,
|
||||
GuardrailIDs: guardrailIDs,
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildModelPolicies covers the finer index discovery needs. Where
|
||||
// buildProviderAllowlists flattens every authorising policy into one list per
|
||||
// provider — enough for a fail-closed backstop, but blind to who is asking —
|
||||
// this keeps each policy's source groups beside its models so the router can
|
||||
// bound a listing to the calling groups.
|
||||
func TestBuildModelPolicies(t *testing.T) {
|
||||
byID := map[string]*types.Guardrail{
|
||||
"g-4o": allowlistGuardrail("g-4o", "acc-1", "gpt-4o"),
|
||||
"g-opus": allowlistGuardrail("g-opus", "acc-1", "claude-opus-4"),
|
||||
"g-disabled": {ID: "g-disabled", Checks: types.GuardrailChecks{ModelAllowlist: types.GuardrailModelAllowlist{Enabled: false, Models: []string{"gpt-4o"}}}},
|
||||
}
|
||||
|
||||
t.Run("each policy keeps its own groups and models", func(t *testing.T) {
|
||||
policies := []*types.Policy{
|
||||
policyForGroups("p1", []string{"grp-eng"}, []string{"g-4o"}, "prov-x"),
|
||||
policyForGroups("p2", []string{"grp-sales"}, []string{"g-opus"}, "prov-x"),
|
||||
}
|
||||
got := buildModelPolicies(policies, byID, nil)
|
||||
assert.Equal(t, []routerModelPolicy{
|
||||
{GroupIDs: []string{"grp-eng"}, Models: []string{"gpt-4o"}},
|
||||
{GroupIDs: []string{"grp-sales"}, Models: []string{"claude-opus-4"}},
|
||||
}, got["prov-x"],
|
||||
"the two policies must stay separable so neither group is offered the other's models")
|
||||
})
|
||||
|
||||
t.Run("an unrestricted policy carries nil models", func(t *testing.T) {
|
||||
policies := []*types.Policy{
|
||||
policyForGroups("p1", []string{"grp-eng"}, []string{"g-4o"}, "prov-x"),
|
||||
policyForGroups("p2", []string{"grp-admin"}, nil, "prov-x"),
|
||||
}
|
||||
got := buildModelPolicies(policies, byID, nil)
|
||||
assert.Nil(t, got["prov-x"][1].Models,
|
||||
"no allowlist must reach the router as nil, which lifts the restriction for its groups")
|
||||
})
|
||||
|
||||
t.Run("a disabled allowlist is not a restriction", func(t *testing.T) {
|
||||
policies := []*types.Policy{policyForGroups("p1", []string{"grp-eng"}, []string{"g-disabled"}, "prov-x")}
|
||||
got := buildModelPolicies(policies, byID, nil)
|
||||
assert.Nil(t, got["prov-x"][0].Models,
|
||||
"a guardrail with the allowlist check off restricts nothing")
|
||||
})
|
||||
|
||||
t.Run("an enabled allowlist with no models permits nothing", func(t *testing.T) {
|
||||
byIDEmpty := map[string]*types.Guardrail{
|
||||
"g-empty": {ID: "g-empty", Checks: types.GuardrailChecks{ModelAllowlist: types.GuardrailModelAllowlist{Enabled: true}}},
|
||||
}
|
||||
policies := []*types.Policy{policyForGroups("p1", []string{"grp-eng"}, []string{"g-empty"}, "prov-x")}
|
||||
got := buildModelPolicies(policies, byIDEmpty, nil)
|
||||
require.NotNil(t, got["prov-x"][0].Models,
|
||||
"an empty allowlist must not arrive as nil — that would read as unrestricted")
|
||||
assert.Empty(t, got["prov-x"][0].Models)
|
||||
})
|
||||
|
||||
t.Run("a policy binding no groups is skipped", func(t *testing.T) {
|
||||
policies := []*types.Policy{policyForGroups("p1", nil, []string{"g-4o"}, "prov-x")}
|
||||
assert.Empty(t, buildModelPolicies(policies, byID, nil),
|
||||
"a policy with no source groups authorises nobody, so it bounds nobody's listing")
|
||||
})
|
||||
}
|
||||
|
||||
// TestSynthesizedAllowlists_ExpandDeclaredIDsPerProvider proves the
|
||||
// synthesized allowlists carry the canonical form alongside a raw declared
|
||||
// entry — under the destination provider's own catalog id, never another's —
|
||||
// so the proxy-side compares (guardrail backstop, per-group router rules)
|
||||
// admit the allowlist however the operator wrote it, while a plain provider's
|
||||
// "-vN"- or "@"-suffixed entries stay verbatim and cannot widen.
|
||||
func TestSynthesizedAllowlists_ExpandDeclaredIDsPerProvider(t *testing.T) {
|
||||
byID := map[string]*types.Guardrail{
|
||||
"g-raw": allowlistGuardrail("g-raw", "acc-1",
|
||||
"eu.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
"claude-sonnet-4-5@20250929",
|
||||
"gpt-4o"),
|
||||
}
|
||||
catalogByProvider := map[string]string{
|
||||
"prov-bedrock": "bedrock_api",
|
||||
"prov-vertex": "vertex_ai_api",
|
||||
"prov-plain": "openai_api",
|
||||
}
|
||||
policies := []*types.Policy{
|
||||
policyForGroups("p1", []string{"grp-eng"}, []string{"g-raw"},
|
||||
"prov-bedrock", "prov-vertex", "prov-plain"),
|
||||
}
|
||||
|
||||
t.Run("guardrail backstop expands under each provider's own normalizer", func(t *testing.T) {
|
||||
got := buildProviderAllowlists(policies, byID, catalogByProvider)
|
||||
assert.ElementsMatch(t, []string{
|
||||
"eu.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
"anthropic.claude-sonnet-4-5",
|
||||
"claude-sonnet-4-5@20250929",
|
||||
"gpt-4o",
|
||||
}, got["prov-bedrock"],
|
||||
"the Bedrock destination strips geography/version, but must not apply Vertex's @-strip")
|
||||
assert.ElementsMatch(t, []string{
|
||||
"eu.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
"claude-sonnet-4-5@20250929",
|
||||
"claude-sonnet-4-5",
|
||||
"gpt-4o",
|
||||
}, got["prov-vertex"],
|
||||
"the Vertex destination strips @version, but must not apply Bedrock's suffix strip")
|
||||
assert.ElementsMatch(t, []string{
|
||||
"eu.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
"claude-sonnet-4-5@20250929",
|
||||
"gpt-4o",
|
||||
}, got["prov-plain"],
|
||||
"a body-routed provider keeps every entry verbatim — no alternate can widen it")
|
||||
})
|
||||
|
||||
t.Run("router model rules expand the same way", func(t *testing.T) {
|
||||
got := buildModelPolicies(policies, byID, catalogByProvider)
|
||||
require.Len(t, got["prov-bedrock"], 1)
|
||||
assert.Contains(t, got["prov-bedrock"][0].Models, "anthropic.claude-sonnet-4-5")
|
||||
assert.NotContains(t, got["prov-bedrock"][0].Models, "claude-sonnet-4-5")
|
||||
require.Len(t, got["prov-vertex"], 1)
|
||||
assert.Contains(t, got["prov-vertex"][0].Models, "claude-sonnet-4-5")
|
||||
assert.NotContains(t, got["prov-vertex"][0].Models, "anthropic.claude-sonnet-4-5")
|
||||
require.Len(t, got["prov-plain"], 1)
|
||||
assert.ElementsMatch(t, []string{
|
||||
"eu.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
"claude-sonnet-4-5@20250929",
|
||||
"gpt-4o",
|
||||
}, got["prov-plain"][0].Models)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
rpproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
||||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
"github.com/netbirdio/netbird/management/server/account"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
nbtypes "github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
// decodeServiceRouterConfig finds the llm_router middleware on the synthesised
|
||||
// service's single target and decodes its config — the model→provider routing
|
||||
// table the proxy authorises against.
|
||||
func decodeServiceRouterConfig(t *testing.T, svc *rpservice.Service) routerConfig {
|
||||
t.Helper()
|
||||
require.NotEmpty(t, svc.Targets, "synth service must carry a target")
|
||||
for _, mw := range svc.Targets[0].Options.Middlewares {
|
||||
if mw.ID == middlewareIDLLMRouter {
|
||||
var cfg routerConfig
|
||||
require.NoError(t, json.Unmarshal(mw.ConfigJSON, &cfg), "router config must decode")
|
||||
return cfg
|
||||
}
|
||||
}
|
||||
t.Fatal("llm_router middleware not present on synthesised service")
|
||||
return routerConfig{}
|
||||
}
|
||||
|
||||
// decodeMappingRouterConfig is the proto-wire equivalent: it pulls the
|
||||
// llm_router config off the ProxyMapping the proxy actually receives.
|
||||
func decodeMappingRouterConfig(t *testing.T, m *proto.ProxyMapping) routerConfig {
|
||||
t.Helper()
|
||||
require.NotEmpty(t, m.GetPath(), "mapping must carry a path")
|
||||
for _, mw := range m.GetPath()[0].GetOptions().GetMiddlewares() {
|
||||
if mw.GetId() == middlewareIDLLMRouter {
|
||||
var cfg routerConfig
|
||||
require.NoError(t, json.Unmarshal(mw.GetConfigJson(), &cfg), "wire router config must decode")
|
||||
return cfg
|
||||
}
|
||||
}
|
||||
t.Fatal("llm_router middleware not present on proxy mapping")
|
||||
return routerConfig{}
|
||||
}
|
||||
|
||||
// TestSynthesizeServices_RealStore_SurvivesStatusToggle drives synthesis through
|
||||
// a REAL sqlite store (Save → gorm/JSON serialize → reload → decrypt) instead of
|
||||
// a MockStore, so it exercises the field round-trip that a provider/policy edit
|
||||
// actually hits. Mock-based tests can't catch a field that dies in persistence;
|
||||
// this one can. It then performs the exact operation that reproduced the live
|
||||
// 403 — disable then re-enable the provider — and asserts the re-enabled state
|
||||
// is fully routable again.
|
||||
func TestSynthesizeServices_RealStore_SurvivesStatusToggle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||
require.NoError(t, err, "real sqlite test store must come up")
|
||||
defer cleanup()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
provider := newSynthTestProvider()
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
|
||||
|
||||
assertRoutable := func(t *testing.T, stage string) {
|
||||
services, err := SynthesizeServices(ctx, s, testAccountID)
|
||||
require.NoError(t, err, stage)
|
||||
require.Len(t, services, 1, "%s: exactly one synth service expected", stage)
|
||||
svc := services[0]
|
||||
|
||||
assert.True(t, svc.Private, "%s: synth service must be Private after store round-trip", stage)
|
||||
assert.Equal(t, []string{"grp-eng"}, svc.AccessGroups, "%s: AccessGroups must survive the round-trip", stage)
|
||||
|
||||
m := svc.ToProtoMapping(rpservice.Update, "", rpproxy.OIDCValidationConfig{})
|
||||
assert.True(t, m.GetPrivate(), "%s: proto mapping Private must be true (proxy gates tunnel-peer auth on it)", stage)
|
||||
|
||||
cfg := decodeServiceRouterConfig(t, svc)
|
||||
require.Len(t, cfg.Providers, 1, "%s: the enabled+linked provider must appear in the router config", stage)
|
||||
assert.Equal(t, []string{"gpt-5.4"}, cfg.Providers[0].Models, "%s: provider models must reach the route", stage)
|
||||
assert.Equal(t, []string{"grp-eng"}, cfg.Providers[0].AllowedGroupIDs, "%s: policy source groups must reach the route", stage)
|
||||
}
|
||||
|
||||
assertRoutable(t, "initial")
|
||||
|
||||
provider.Enabled = false
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
disabled, err := SynthesizeServices(ctx, s, testAccountID)
|
||||
require.NoError(t, err, "synthesis must not error with a disabled provider")
|
||||
for _, svc := range disabled {
|
||||
assert.Empty(t, decodeServiceRouterConfig(t, svc).Providers,
|
||||
"a disabled provider must not appear in the router config (otherwise it would route while off)")
|
||||
}
|
||||
|
||||
provider.Enabled = true
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
assertRoutable(t, "after disable->enable")
|
||||
}
|
||||
|
||||
// captureController is a proxy.Controller that records the mappings reconcile
|
||||
// pushes, so the test can inspect the exact wire payload — Private flag and
|
||||
// router config included.
|
||||
type captureController struct {
|
||||
rpproxy.Controller
|
||||
pushed []*proto.ProxyMapping
|
||||
}
|
||||
|
||||
func (c *captureController) GetOIDCValidationConfig() rpproxy.OIDCValidationConfig {
|
||||
return rpproxy.OIDCValidationConfig{}
|
||||
}
|
||||
|
||||
func (c *captureController) SendServiceUpdateToCluster(_ context.Context, _ string, update *proto.ProxyMapping, _ string) {
|
||||
c.pushed = append(c.pushed, update)
|
||||
}
|
||||
|
||||
// noopAccountManager satisfies the reconcile path's accountManager dependency.
|
||||
type noopAccountManager struct {
|
||||
account.Manager
|
||||
}
|
||||
|
||||
func (noopAccountManager) UpdateAccountPeers(context.Context, string, nbtypes.UpdateReason) {}
|
||||
|
||||
// TestReconcile_RealStore_PushesPrivateAfterStatusToggle reproduces the live
|
||||
// path end-to-end below the gRPC boundary: a real store + the real
|
||||
// managerImpl.reconcile + a capturing proxy controller. It runs the operation
|
||||
// that broke in production — provider disable then re-enable — and asserts the
|
||||
// mapping reconcile pushes to the cluster after re-enable is Private=true and
|
||||
// carries the routable provider. If reconcile ever pushes private=false (the
|
||||
// symptom that left UserGroups empty → no_authorised_provider), this fails.
|
||||
func TestReconcile_RealStore_PushesPrivateAfterStatusToggle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||
require.NoError(t, err)
|
||||
defer cleanup()
|
||||
|
||||
require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings()))
|
||||
provider := newSynthTestProvider()
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", "")))
|
||||
|
||||
ctrl := &captureController{}
|
||||
m := &managerImpl{
|
||||
store: s,
|
||||
accountManager: noopAccountManager{},
|
||||
proxyController: ctrl,
|
||||
reconcileCache: make(map[string]map[string]syntheticMapping),
|
||||
}
|
||||
|
||||
m.reconcile(ctx, testAccountID) // initial, provider enabled
|
||||
|
||||
provider.Enabled = false
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
m.reconcile(ctx, testAccountID) // disabled
|
||||
|
||||
provider.Enabled = true
|
||||
require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider))
|
||||
m.reconcile(ctx, testAccountID) // re-enabled — the reproduction step
|
||||
|
||||
require.NotEmpty(t, ctrl.pushed, "reconcile must push at least one mapping")
|
||||
last := ctrl.pushed[len(ctrl.pushed)-1]
|
||||
|
||||
assert.Equal(t, newSynthTestSettings().Endpoint(), last.GetDomain(), "synth domain on the wire")
|
||||
assert.True(t, last.GetPrivate(),
|
||||
"reconcile-pushed mapping after re-enable MUST be Private=true; a false here is the exact bug — the proxy skips ValidateTunnelPeer, UserGroups stays empty, and llm_router denies no_authorised_provider")
|
||||
|
||||
cfg := decodeMappingRouterConfig(t, last)
|
||||
require.Len(t, cfg.Providers, 1, "re-enabled provider must be back in the pushed router config")
|
||||
assert.Equal(t, []string{"gpt-5.4"}, cfg.Providers[0].Models, "model must be routable again after re-enable")
|
||||
assert.Equal(t, []string{"grp-eng"}, cfg.Providers[0].AllowedGroupIDs, "authorised groups must be present after re-enable")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,358 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
// AgentNetworkAccessLog is the dedicated, flattened agent-network access-log
|
||||
// row. Unlike the shared reverse-proxy AccessLogEntry (which kept LLM data in
|
||||
// an opaque metadata JSON blob), the LLM dimensions live in first-class,
|
||||
// indexed columns so the access-log surface can filter server-side by
|
||||
// user / group / provider / model / decision.
|
||||
type AgentNetworkAccessLog struct {
|
||||
// The composite index idx_anal_acct_session_ts backs the session-grouped
|
||||
// listing (GROUP BY session_id ORDER BY MAX(timestamp) within an account);
|
||||
// the single-column indexes still back the flat filters/sorts.
|
||||
ID string `gorm:"primaryKey"`
|
||||
AccountID string `gorm:"index;index:idx_anal_acct_session_ts,priority:1"`
|
||||
ServiceID string `gorm:"index"`
|
||||
Timestamp time.Time `gorm:"index;index:idx_anal_acct_session_ts,priority:3"`
|
||||
UserID string `gorm:"index"`
|
||||
SourceIP string
|
||||
Method string
|
||||
Host string
|
||||
Path string `gorm:"type:text"`
|
||||
Duration time.Duration
|
||||
StatusCode int `gorm:"index"`
|
||||
AuthMethod string
|
||||
BytesUpload int64
|
||||
BytesDownload int64
|
||||
|
||||
// Flattened LLM dimensions (queryable). Sourced from proxy metadata keys.
|
||||
Provider string `gorm:"index"` // vendor, e.g. "openai" (llm.provider)
|
||||
Model string `gorm:"index"` // llm.model
|
||||
SessionID string `gorm:"index;index:idx_anal_acct_session_ts,priority:2"` // llm.session_id — groups a conversation / coding session
|
||||
ResolvedProviderID string `gorm:"index"` // llm.resolved_provider_id
|
||||
SelectedPolicyID string `gorm:"index"` // llm.selected_policy_id
|
||||
Decision string `gorm:"index"` // llm_policy.decision (allow/deny)
|
||||
DenyReason string // llm_policy.reason (raw code, mapped in the UI)
|
||||
InputTokens int64
|
||||
OutputTokens int64
|
||||
TotalTokens int64
|
||||
// Prompt-cache buckets: read + write token counts.
|
||||
CachedInputTokens int64
|
||||
CacheCreationTokens int64
|
||||
// Per-bucket cost breakdown — one column per token bucket the provider
|
||||
// bills separately. These four are the only cost state stored: the total
|
||||
// and the cache portion are derived on read (TotalCostUSD / CacheCostUSD)
|
||||
// rather than stored alongside, so a stored aggregate can never drift out
|
||||
// of step with the components it summarises.
|
||||
//
|
||||
// default:0 matters on upgrade: these columns are ALTER TABLE ADD COLUMN
|
||||
// on an existing table, and without it every historical row holds NULL —
|
||||
// which a raw SUM()/scan into float64 can't read. The default backfills
|
||||
// them as 0, so pre-upgrade rows report an unknown split, not an error.
|
||||
InputCostUSD float64 `gorm:"not null;default:0"`
|
||||
CachedInputCostUSD float64 `gorm:"not null;default:0"`
|
||||
CacheCreationCostUSD float64 `gorm:"not null;default:0"`
|
||||
OutputCostUSD float64 `gorm:"not null;default:0"`
|
||||
Stream bool
|
||||
|
||||
// Prompt capture. Only populated when prompt collection is enabled
|
||||
// (account master switch AND policy guardrail). Heavy free text.
|
||||
RequestPrompt string `gorm:"type:text"`
|
||||
ResponseCompletion string `gorm:"type:text"`
|
||||
|
||||
CreatedAt time.Time
|
||||
|
||||
// GroupIDs is the authorising group ids for this entry, hydrated from the
|
||||
// group child table on read. Not a column.
|
||||
GroupIDs []string `gorm:"-"`
|
||||
}
|
||||
|
||||
// TableName keeps agent-network access logs in their own table, separate from
|
||||
// the reverse-proxy AccessLogEntry table.
|
||||
func (AgentNetworkAccessLog) TableName() string { return "agent_network_access_log" }
|
||||
|
||||
// CostUSDSQLExpr is the SQL sum of the per-bucket cost columns — the total cost
|
||||
// of a row. Used wherever a query has to sort or aggregate on total cost now
|
||||
// that no cost_usd column is stored. Plain arithmetic over NOT NULL columns, so
|
||||
// it stays portable across SQLite and Postgres.
|
||||
const CostUSDSQLExpr = "(input_cost_usd + cached_input_cost_usd + cache_creation_cost_usd + output_cost_usd)"
|
||||
|
||||
// TotalCostUSD is the request's total cost: the sum of the four per-bucket
|
||||
// costs. Derived rather than stored so it cannot disagree with the breakdown.
|
||||
func (a *AgentNetworkAccessLog) TotalCostUSD() float64 {
|
||||
return a.InputCostUSD + a.CachedInputCostUSD + a.CacheCreationCostUSD + a.OutputCostUSD
|
||||
}
|
||||
|
||||
// CacheCostUSD is the portion of the total billed for prompt-cache buckets:
|
||||
// cache reads plus cache writes.
|
||||
func (a *AgentNetworkAccessLog) CacheCostUSD() float64 {
|
||||
return a.CachedInputCostUSD + a.CacheCreationCostUSD
|
||||
}
|
||||
|
||||
// ToAPIResponse renders the flattened entry as the API representation.
|
||||
func (a *AgentNetworkAccessLog) ToAPIResponse() api.AgentNetworkAccessLog {
|
||||
out := api.AgentNetworkAccessLog{
|
||||
Id: a.ID,
|
||||
ServiceId: a.ServiceID,
|
||||
Timestamp: a.Timestamp,
|
||||
StatusCode: a.StatusCode,
|
||||
DurationMs: int(a.Duration.Milliseconds()),
|
||||
InputTokens: a.InputTokens,
|
||||
OutputTokens: a.OutputTokens,
|
||||
TotalTokens: a.TotalTokens,
|
||||
CachedInputTokens: a.CachedInputTokens,
|
||||
CacheCreationTokens: a.CacheCreationTokens,
|
||||
InputCostUsd: a.InputCostUSD,
|
||||
CachedInputCostUsd: a.CachedInputCostUSD,
|
||||
CacheCreationCostUsd: a.CacheCreationCostUSD,
|
||||
OutputCostUsd: a.OutputCostUSD,
|
||||
CostUsd: a.TotalCostUSD(),
|
||||
CacheCostUsd: a.CacheCostUSD(),
|
||||
Stream: &a.Stream,
|
||||
}
|
||||
|
||||
out.UserId = strPtr(a.UserID)
|
||||
out.SourceIp = strPtr(a.SourceIP)
|
||||
out.Method = strPtr(a.Method)
|
||||
out.Host = strPtr(a.Host)
|
||||
out.Path = strPtr(a.Path)
|
||||
out.Provider = strPtr(a.Provider)
|
||||
out.Model = strPtr(a.Model)
|
||||
out.SessionId = strPtr(a.SessionID)
|
||||
out.ResolvedProviderId = strPtr(a.ResolvedProviderID)
|
||||
out.SelectedPolicyId = strPtr(a.SelectedPolicyID)
|
||||
out.Decision = strPtr(a.Decision)
|
||||
out.DenyReason = strPtr(a.DenyReason)
|
||||
out.RequestPrompt = strPtr(a.RequestPrompt)
|
||||
out.ResponseCompletion = strPtr(a.ResponseCompletion)
|
||||
|
||||
if len(a.GroupIDs) > 0 {
|
||||
groups := a.GroupIDs
|
||||
out.GroupIds = &groups
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// strPtr returns a pointer to s, or nil when s is empty — so empty optional
|
||||
// fields are omitted from the JSON rather than serialised as "".
|
||||
func strPtr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
// AgentNetworkAccessLogSession is a session-grouped view of access-log entries:
|
||||
// all requests sharing a session id (or, for a request the client sent no
|
||||
// session id for, that single request keyed by its own row id) folded into one
|
||||
// summary plus its ordered entries. Assembled in Go from a page of entries — it
|
||||
// is not a stored table.
|
||||
type AgentNetworkAccessLogSession struct {
|
||||
SessionID string // empty for a session-less (singleton) request
|
||||
UserID string
|
||||
GroupIDs []string // union of the entries' authorising groups
|
||||
StartedAt time.Time
|
||||
EndedAt time.Time
|
||||
RequestCount int
|
||||
InputTokens int64
|
||||
OutputTokens int64
|
||||
TotalTokens int64
|
||||
CachedInputTokens int64
|
||||
CacheCreationTokens int64
|
||||
InputCostUSD float64
|
||||
CachedInputCostUSD float64
|
||||
CacheCreationCostUSD float64
|
||||
OutputCostUSD float64
|
||||
Providers []string // distinct vendors seen in the session
|
||||
Models []string // distinct models seen in the session
|
||||
Decision string // "deny" if any entry was denied, else "allow"
|
||||
Entries []*AgentNetworkAccessLog
|
||||
}
|
||||
|
||||
// TotalCostUSD is the session's total cost: the sum of the four per-bucket
|
||||
// costs accumulated across its entries.
|
||||
func (sess *AgentNetworkAccessLogSession) TotalCostUSD() float64 {
|
||||
return sess.InputCostUSD + sess.CachedInputCostUSD + sess.CacheCreationCostUSD + sess.OutputCostUSD
|
||||
}
|
||||
|
||||
// CacheCostUSD is the session's prompt-cache spend: cache reads plus writes.
|
||||
func (sess *AgentNetworkAccessLogSession) CacheCostUSD() float64 {
|
||||
return sess.CachedInputCostUSD + sess.CacheCreationCostUSD
|
||||
}
|
||||
|
||||
// sessionKey is the grouping key for an entry: its session id, or — when the
|
||||
// client sent none — its own row id, so session-less requests each form their
|
||||
// own singleton group. Must match the SQL group key
|
||||
// COALESCE(NULLIF(session_id, ”), id).
|
||||
func sessionKey(e *AgentNetworkAccessLog) string {
|
||||
if e.SessionID != "" {
|
||||
return e.SessionID
|
||||
}
|
||||
return e.ID
|
||||
}
|
||||
|
||||
// FoldAccessLogSessions folds a page of entries into per-session summaries,
|
||||
// preserving the order of orderedKeys (the already-sorted, already-paginated
|
||||
// session keys from the store). Entries are expected pre-sorted by timestamp
|
||||
// within each key. Aggregation (sums, distinct providers/models, deny rollup)
|
||||
// happens here in Go rather than in SQL so the query stays engine-portable.
|
||||
func FoldAccessLogSessions(orderedKeys []string, entries []*AgentNetworkAccessLog) []*AgentNetworkAccessLogSession {
|
||||
byKey := make(map[string]*AgentNetworkAccessLogSession, len(orderedKeys))
|
||||
order := make([]*AgentNetworkAccessLogSession, 0, len(orderedKeys))
|
||||
for _, k := range orderedKeys {
|
||||
if _, ok := byKey[k]; ok {
|
||||
continue
|
||||
}
|
||||
sess := &AgentNetworkAccessLogSession{Decision: "allow"}
|
||||
byKey[k] = sess
|
||||
order = append(order, sess)
|
||||
}
|
||||
|
||||
seenBy := make(map[string]*sessionSeen, len(orderedKeys))
|
||||
|
||||
for _, e := range entries {
|
||||
k := sessionKey(e)
|
||||
sess, ok := byKey[k]
|
||||
if !ok {
|
||||
continue // entry outside the paged set; defensive
|
||||
}
|
||||
sk := seenBy[k]
|
||||
if sk == nil {
|
||||
sk = newSessionSeen()
|
||||
seenBy[k] = sk
|
||||
sess.SessionID = e.SessionID
|
||||
sess.UserID = e.UserID
|
||||
sess.StartedAt = e.Timestamp
|
||||
sess.EndedAt = e.Timestamp
|
||||
}
|
||||
sess.foldEntry(sk, e)
|
||||
}
|
||||
|
||||
out := make([]*AgentNetworkAccessLogSession, 0, len(order))
|
||||
for _, sess := range order {
|
||||
if sess.RequestCount > 0 {
|
||||
out = append(out, sess)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sessionSeen tracks the distinct provider / model / group values already
|
||||
// recorded for a session so foldEntry can dedupe as it accumulates.
|
||||
type sessionSeen struct{ providers, models, groups map[string]struct{} }
|
||||
|
||||
func newSessionSeen() *sessionSeen {
|
||||
return &sessionSeen{
|
||||
providers: map[string]struct{}{},
|
||||
models: map[string]struct{}{},
|
||||
groups: map[string]struct{}{},
|
||||
}
|
||||
}
|
||||
|
||||
// foldEntry accumulates a single entry into the session summary: sums, time
|
||||
// bounds, first-seen user, deny rollup, distinct provider / model / group
|
||||
// lists, and the entry itself.
|
||||
func (sess *AgentNetworkAccessLogSession) foldEntry(sk *sessionSeen, e *AgentNetworkAccessLog) {
|
||||
sess.RequestCount++
|
||||
sess.InputTokens += e.InputTokens
|
||||
sess.OutputTokens += e.OutputTokens
|
||||
sess.TotalTokens += e.TotalTokens
|
||||
sess.CachedInputTokens += e.CachedInputTokens
|
||||
sess.CacheCreationTokens += e.CacheCreationTokens
|
||||
sess.InputCostUSD += e.InputCostUSD
|
||||
sess.CachedInputCostUSD += e.CachedInputCostUSD
|
||||
sess.CacheCreationCostUSD += e.CacheCreationCostUSD
|
||||
sess.OutputCostUSD += e.OutputCostUSD
|
||||
if e.Timestamp.Before(sess.StartedAt) {
|
||||
sess.StartedAt = e.Timestamp
|
||||
}
|
||||
if e.Timestamp.After(sess.EndedAt) {
|
||||
sess.EndedAt = e.Timestamp
|
||||
}
|
||||
if sess.UserID == "" {
|
||||
sess.UserID = e.UserID
|
||||
}
|
||||
if e.Decision == "deny" {
|
||||
sess.Decision = "deny"
|
||||
}
|
||||
sess.Providers = appendDistinct(sk.providers, sess.Providers, e.Provider)
|
||||
sess.Models = appendDistinct(sk.models, sess.Models, e.Model)
|
||||
for _, g := range e.GroupIDs {
|
||||
sess.GroupIDs = appendDistinct(sk.groups, sess.GroupIDs, g)
|
||||
}
|
||||
sess.Entries = append(sess.Entries, e)
|
||||
}
|
||||
|
||||
// appendDistinct appends v to list when v is non-empty and not already recorded
|
||||
// in seen, returning the possibly-extended list.
|
||||
func appendDistinct(seen map[string]struct{}, list []string, v string) []string {
|
||||
if v == "" {
|
||||
return list
|
||||
}
|
||||
if _, dup := seen[v]; dup {
|
||||
return list
|
||||
}
|
||||
seen[v] = struct{}{}
|
||||
return append(list, v)
|
||||
}
|
||||
|
||||
// ToAPIResponse renders the session summary (and its entries) as the API
|
||||
// representation.
|
||||
func (sess *AgentNetworkAccessLogSession) ToAPIResponse() api.AgentNetworkAccessLogSession {
|
||||
entries := make([]api.AgentNetworkAccessLog, 0, len(sess.Entries))
|
||||
for _, e := range sess.Entries {
|
||||
entries = append(entries, e.ToAPIResponse())
|
||||
}
|
||||
|
||||
out := api.AgentNetworkAccessLogSession{
|
||||
StartedAt: sess.StartedAt,
|
||||
EndedAt: sess.EndedAt,
|
||||
RequestCount: sess.RequestCount,
|
||||
InputTokens: sess.InputTokens,
|
||||
OutputTokens: sess.OutputTokens,
|
||||
TotalTokens: sess.TotalTokens,
|
||||
CachedInputTokens: sess.CachedInputTokens,
|
||||
CacheCreationTokens: sess.CacheCreationTokens,
|
||||
InputCostUsd: sess.InputCostUSD,
|
||||
CachedInputCostUsd: sess.CachedInputCostUSD,
|
||||
CacheCreationCostUsd: sess.CacheCreationCostUSD,
|
||||
OutputCostUsd: sess.OutputCostUSD,
|
||||
CostUsd: sess.TotalCostUSD(),
|
||||
CacheCostUsd: sess.CacheCostUSD(),
|
||||
Decision: sess.Decision,
|
||||
Entries: entries,
|
||||
}
|
||||
out.SessionId = strPtr(sess.SessionID)
|
||||
out.UserId = strPtr(sess.UserID)
|
||||
if len(sess.Providers) > 0 {
|
||||
providers := sess.Providers
|
||||
out.Providers = &providers
|
||||
}
|
||||
if len(sess.Models) > 0 {
|
||||
models := sess.Models
|
||||
out.Models = &models
|
||||
}
|
||||
if len(sess.GroupIDs) > 0 {
|
||||
groups := sess.GroupIDs
|
||||
out.GroupIds = &groups
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AgentNetworkAccessLogGroup is the normalised many-to-many row linking a log
|
||||
// entry to one authorising group, so the access-log endpoint can filter by
|
||||
// group with a simple `group_id IN (...)` join instead of substring-matching a
|
||||
// CSV column.
|
||||
type AgentNetworkAccessLogGroup struct {
|
||||
LogID string `gorm:"primaryKey"`
|
||||
GroupID string `gorm:"primaryKey;index"`
|
||||
AccountID string `gorm:"index"`
|
||||
}
|
||||
|
||||
// TableName names the access-log group child table.
|
||||
func (AgentNetworkAccessLogGroup) TableName() string { return "agent_network_access_log_group" }
|
||||
@@ -0,0 +1,249 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
const (
|
||||
// AccessLogDefaultPageSize is the default number of records per page.
|
||||
AccessLogDefaultPageSize = 50
|
||||
// AccessLogMaxPageSize is the maximum number of records allowed per page.
|
||||
AccessLogMaxPageSize = 100
|
||||
|
||||
accessLogDefaultSortBy = "timestamp"
|
||||
accessLogDefaultSortOrder = "desc"
|
||||
|
||||
// usageOverviewDefaultLookback bounds an unbounded usage-overview query so
|
||||
// it never aggregates an account's entire history into memory.
|
||||
usageOverviewDefaultLookback = 90 * 24 * time.Hour
|
||||
// usageOverviewMaxRange caps how far back an explicit range may reach.
|
||||
usageOverviewMaxRange = 366 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// ApplyUsageOverviewBounds bounds a missing or over-wide date range so the
|
||||
// in-memory usage aggregation can't load an account's full usage history. An
|
||||
// absent range defaults to the last usageOverviewDefaultLookback; a range wider
|
||||
// than usageOverviewMaxRange is clamped from the (possibly defaulted) end.
|
||||
func (f *AgentNetworkAccessLogFilter) ApplyUsageOverviewBounds(now time.Time) {
|
||||
end := now
|
||||
if f.EndDate != nil {
|
||||
end = *f.EndDate
|
||||
}
|
||||
f.EndDate = &end
|
||||
if f.StartDate == nil {
|
||||
start := end.Add(-usageOverviewDefaultLookback)
|
||||
f.StartDate = &start
|
||||
return
|
||||
}
|
||||
if end.Sub(*f.StartDate) > usageOverviewMaxRange {
|
||||
start := end.Add(-usageOverviewMaxRange)
|
||||
f.StartDate = &start
|
||||
}
|
||||
}
|
||||
|
||||
// accessLogSortFields maps the API sort_by values to their database columns.
|
||||
var accessLogSortFields = map[string]string{
|
||||
"timestamp": "timestamp",
|
||||
"model": "model",
|
||||
"provider": "provider",
|
||||
"status_code": "status_code",
|
||||
"duration": "duration",
|
||||
"cost_usd": CostUSDSQLExpr,
|
||||
"total_tokens": "total_tokens",
|
||||
"user_id": "user_id",
|
||||
"decision": "decision",
|
||||
}
|
||||
|
||||
// sessionSortExprs maps the API sort_by values to the aggregate expression a
|
||||
// session-grouped query sorts on. A session has no single row, so per-row
|
||||
// columns become aggregates: "timestamp" (the default) is the session's last
|
||||
// activity, "started_at" its first. Every expression is a plain SQL aggregate
|
||||
// over the GROUP BY, so the ordering stays portable across SQLite and Postgres.
|
||||
// Keys absent here (e.g. "model", "provider") fall back to the default — the
|
||||
// grouped UI only offers the session-level sorts below.
|
||||
var sessionSortExprs = map[string]string{ //nolint:gosec // G101 false positive: "total_tokens" sort key, not a credential
|
||||
"timestamp": "MAX(timestamp)",
|
||||
"started_at": "MIN(timestamp)",
|
||||
"cost_usd": "SUM" + CostUSDSQLExpr,
|
||||
"total_tokens": "SUM(total_tokens)",
|
||||
"duration": "SUM(duration)",
|
||||
"request_count": "COUNT(*)",
|
||||
"status_code": "MAX(status_code)",
|
||||
"user_id": "MIN(user_id)",
|
||||
"decision": "MAX(decision)", // "deny" > "allow": DESC surfaces denied sessions first
|
||||
}
|
||||
|
||||
// AgentNetworkAccessLogFilter holds pagination, filtering and sorting
|
||||
// parameters for the agent-network access-log listing. Group / provider /
|
||||
// model are multi-valued (the UI uses multi-select; an entry matches when it
|
||||
// matches any selected value).
|
||||
type AgentNetworkAccessLogFilter struct {
|
||||
Page int
|
||||
PageSize int
|
||||
|
||||
SortBy string
|
||||
SortOrder string
|
||||
|
||||
Search *string // log id, host, path, model, user email/name
|
||||
UserID *string // exact user id (the dashboard sends the picked user's id)
|
||||
SessionID *string // exact session id — groups one conversation / coding session
|
||||
GroupIDs []string // authorising group ids (match any)
|
||||
ProviderIDs []string // resolved provider ids (match any)
|
||||
Models []string // models (match any)
|
||||
Decision *string // policy decision (allow/deny)
|
||||
PathPrefix *string // request path prefix (path LIKE 'prefix%')
|
||||
StartDate *time.Time // timestamp >= start_date
|
||||
EndDate *time.Time // timestamp <= end_date
|
||||
}
|
||||
|
||||
// ParseFromRequest fills the filter from the request query parameters. It
|
||||
// returns a validation error when a supplied start_date / end_date is present
|
||||
// but not valid RFC3339: silently dropping a malformed date would broaden the
|
||||
// query (and, for the usage overview, fall back to the default window).
|
||||
func (f *AgentNetworkAccessLogFilter) ParseFromRequest(r *http.Request) error {
|
||||
q := r.URL.Query()
|
||||
|
||||
f.Page = parseAccessLogPositiveInt(q.Get("page"), 1)
|
||||
f.PageSize = min(parseAccessLogPositiveInt(q.Get("page_size"), AccessLogDefaultPageSize), AccessLogMaxPageSize)
|
||||
|
||||
f.SortBy = parseAccessLogSortField(q.Get("sort_by"))
|
||||
f.SortOrder = parseAccessLogSortOrder(q.Get("sort_order"))
|
||||
|
||||
f.Search = parseAccessLogOptionalString(q.Get("search"))
|
||||
f.UserID = parseAccessLogOptionalString(q.Get("user_id"))
|
||||
f.SessionID = parseAccessLogOptionalString(q.Get("session_id"))
|
||||
f.Decision = parseAccessLogOptionalString(q.Get("decision"))
|
||||
f.PathPrefix = parseAccessLogOptionalString(q.Get("path"))
|
||||
// Multi-value filters accept either repeated params (?group_id=a&group_id=b)
|
||||
// or a single comma-separated value (?group_id=a,b) so both the OpenAPI
|
||||
// array form and the dashboard's single-value query builder work.
|
||||
f.GroupIDs = splitMultiValue(q["group_id"])
|
||||
f.ProviderIDs = splitMultiValue(q["provider_id"])
|
||||
f.Models = splitMultiValue(q["model"])
|
||||
|
||||
var err error
|
||||
if f.StartDate, err = parseAccessLogOptionalRFC3339(q.Get("start_date")); err != nil {
|
||||
return status.Errorf(status.InvalidArgument, "invalid start_date: %v", err)
|
||||
}
|
||||
if f.EndDate, err = parseAccessLogOptionalRFC3339(q.Get("end_date")); err != nil {
|
||||
return status.Errorf(status.InvalidArgument, "invalid end_date: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSortColumn returns the database column for the active sort field.
|
||||
func (f *AgentNetworkAccessLogFilter) GetSortColumn() string {
|
||||
if col, ok := accessLogSortFields[f.SortBy]; ok {
|
||||
return col
|
||||
}
|
||||
return accessLogSortFields[accessLogDefaultSortBy]
|
||||
}
|
||||
|
||||
// GetSessionSortExpr returns the aggregate ORDER BY expression for the active
|
||||
// sort field when listing session-grouped logs. Unknown / non-session sort
|
||||
// fields fall back to the default (last activity).
|
||||
func (f *AgentNetworkAccessLogFilter) GetSessionSortExpr() string {
|
||||
if expr, ok := sessionSortExprs[f.SortBy]; ok {
|
||||
return expr
|
||||
}
|
||||
return sessionSortExprs[accessLogDefaultSortBy]
|
||||
}
|
||||
|
||||
// GetSortOrder returns the normalised sort order ("ASC"/"DESC").
|
||||
func (f *AgentNetworkAccessLogFilter) GetSortOrder() string {
|
||||
if strings.EqualFold(f.SortOrder, "asc") {
|
||||
return "ASC"
|
||||
}
|
||||
return "DESC"
|
||||
}
|
||||
|
||||
// GetLimit returns the page size, defaulting/clamping when unset.
|
||||
func (f *AgentNetworkAccessLogFilter) GetLimit() int {
|
||||
if f.PageSize <= 0 {
|
||||
return AccessLogDefaultPageSize
|
||||
}
|
||||
return min(f.PageSize, AccessLogMaxPageSize)
|
||||
}
|
||||
|
||||
// GetOffset returns the zero-based row offset for the active page. Page is
|
||||
// user-controlled, so the multiplication is guarded against int overflow.
|
||||
func (f *AgentNetworkAccessLogFilter) GetOffset() int {
|
||||
limit := f.GetLimit()
|
||||
if f.Page <= 1 || limit <= 0 {
|
||||
return 0
|
||||
}
|
||||
if f.Page-1 > math.MaxInt/limit {
|
||||
return math.MaxInt - (math.MaxInt % limit)
|
||||
}
|
||||
return (f.Page - 1) * limit
|
||||
}
|
||||
|
||||
func parseAccessLogPositiveInt(s string, def int) int {
|
||||
if v, err := strconv.Atoi(strings.TrimSpace(s)); err == nil && v > 0 {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func parseAccessLogSortField(s string) string {
|
||||
if _, ok := accessLogSortFields[s]; ok {
|
||||
return s
|
||||
}
|
||||
// Session-grouped listings sort on aggregates (e.g. request_count,
|
||||
// started_at) that aren't flat-row columns; accept those too. The flat
|
||||
// listing maps any unknown field back to the default, so this stays safe
|
||||
// for the non-grouped endpoint.
|
||||
if _, ok := sessionSortExprs[s]; ok {
|
||||
return s
|
||||
}
|
||||
return accessLogDefaultSortBy
|
||||
}
|
||||
|
||||
func parseAccessLogSortOrder(s string) string {
|
||||
if strings.EqualFold(s, "asc") {
|
||||
return "asc"
|
||||
}
|
||||
return accessLogDefaultSortOrder
|
||||
}
|
||||
|
||||
func parseAccessLogOptionalString(s string) *string {
|
||||
if s = strings.TrimSpace(s); s != "" {
|
||||
return &s
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseAccessLogOptionalRFC3339(s string) (*time.Time, error) {
|
||||
if s = strings.TrimSpace(s); s == "" {
|
||||
return nil, nil //nolint:nilnil // not provided: no value and no error
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// splitMultiValue flattens repeated query params and comma-separated values
|
||||
// into a single trimmed, blank-free list. Returns nil when nothing remains so
|
||||
// callers can skip the filter entirely.
|
||||
func splitMultiValue(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for _, raw := range values {
|
||||
for _, v := range strings.Split(raw, ",") {
|
||||
if v = strings.TrimSpace(v); v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package types
|
||||
|
||||
// AgentConfig is the caller-scoped answer to "what may this caller
|
||||
// use on the Agent Network?" — the account's proxy endpoint plus the
|
||||
// providers and models the caller's groups authorize. It intentionally
|
||||
// carries display metadata only: no keys, no upstream URLs, no policy or
|
||||
// guardrail structure, and no hint of providers the caller cannot reach.
|
||||
type AgentConfig struct {
|
||||
// Configured is false only when the account has no Agent Network set
|
||||
// up. A caller no policy covers yet still reads as configured, with an
|
||||
// empty Providers list: every member gets the same connection config,
|
||||
// and the empty list is what tells them to ask for access.
|
||||
Configured bool
|
||||
// Endpoint is the account's proxy base URL
|
||||
// ("https://<subdomain>.<cluster>"), reachable over the NetBird tunnel
|
||||
// only. Empty when Configured is false. Handing it to a member the
|
||||
// policies do not cover authorizes nothing on its own — the proxy
|
||||
// still refuses every request no policy permits.
|
||||
Endpoint string
|
||||
// Providers lists the providers at least one applicable policy
|
||||
// authorizes for the caller, in the account's created_at order.
|
||||
Providers []AgentConfigProvider
|
||||
}
|
||||
|
||||
// AgentConfigProvider is one authorized provider in an AgentConfig.
|
||||
type AgentConfigProvider struct {
|
||||
// Name is the operator-assigned label, e.g. "Bedrock prod".
|
||||
Name string
|
||||
// CatalogID names the catalog entry, e.g. "anthropic_api".
|
||||
CatalogID string
|
||||
// APIFlavor is the request-body shape the provider speaks — the
|
||||
// catalog entry's parser id ("anthropic", "openai"); empty when the
|
||||
// proxy dispatches the provider by URL path instead.
|
||||
APIFlavor string
|
||||
// AllModelsAllowed is true when no model allowlist restricts this
|
||||
// provider for the caller. Models then lists the declared/catalog
|
||||
// models as a courtesy (possibly none for gateway-style providers).
|
||||
AllModelsAllowed bool
|
||||
// Models is the effective model allowlist for the caller, or the
|
||||
// declared/catalog models when AllModelsAllowed is true.
|
||||
Models []string
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/rs/xid"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
// AccountBudgetRule is an account-level, limit-only rule bound to groups
|
||||
// and/or users. It mirrors the policy budget experience without any routing:
|
||||
// it carries the same cap shape as a policy (PolicyLimits) but never selects a
|
||||
// provider. Rules apply across policies as an always-on ceiling — every
|
||||
// applicable rule binds (min-wins), so a rule can only tighten a caller's
|
||||
// effective limit, never loosen it.
|
||||
//
|
||||
// TargetGroups matches when it intersects the caller's groups; TargetUsers
|
||||
// binds a specific user directly. Empty TargetGroups and TargetUsers means the
|
||||
// rule applies to every caller (the account-wide default).
|
||||
type AccountBudgetRule struct {
|
||||
ID string `gorm:"primaryKey"`
|
||||
AccountID string `gorm:"index"`
|
||||
Name string
|
||||
Enabled bool
|
||||
TargetGroups []string `gorm:"serializer:json;column:target_groups"`
|
||||
TargetUsers []string `gorm:"serializer:json;column:target_users"`
|
||||
Limits PolicyLimits `gorm:"serializer:json;column:limits"`
|
||||
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// TableName puts budget rules in their own table.
|
||||
func (AccountBudgetRule) TableName() string { return "agent_network_budget_rules" }
|
||||
|
||||
// NewAccountBudgetRule returns a new rule with a freshly minted ID.
|
||||
func NewAccountBudgetRule(accountID string) *AccountBudgetRule {
|
||||
now := time.Now().UTC()
|
||||
return &AccountBudgetRule{
|
||||
ID: "ainbud_" + xid.New().String(),
|
||||
AccountID: accountID,
|
||||
Enabled: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
// Copy returns a deep copy of the rule, including its target slices.
|
||||
func (r *AccountBudgetRule) Copy() *AccountBudgetRule {
|
||||
c := *r
|
||||
c.TargetGroups = append([]string(nil), r.TargetGroups...)
|
||||
c.TargetUsers = append([]string(nil), r.TargetUsers...)
|
||||
return &c
|
||||
}
|
||||
|
||||
// EventMeta renders the rule for the activity log.
|
||||
func (r *AccountBudgetRule) EventMeta() map[string]any {
|
||||
return map[string]any{
|
||||
"name": r.Name,
|
||||
"enabled": r.Enabled,
|
||||
}
|
||||
}
|
||||
|
||||
// FromAPIRequest applies the request payload onto the receiver.
|
||||
func (r *AccountBudgetRule) FromAPIRequest(req *api.AgentNetworkBudgetRuleRequest) {
|
||||
r.Name = req.Name
|
||||
if req.Enabled != nil {
|
||||
r.Enabled = *req.Enabled
|
||||
}
|
||||
if req.TargetGroups != nil {
|
||||
r.TargetGroups = append([]string(nil), (*req.TargetGroups)...)
|
||||
} else {
|
||||
r.TargetGroups = []string{}
|
||||
}
|
||||
if req.TargetUsers != nil {
|
||||
r.TargetUsers = append([]string(nil), (*req.TargetUsers)...)
|
||||
} else {
|
||||
r.TargetUsers = []string{}
|
||||
}
|
||||
r.Limits = limitsFromAPI(req.Limits)
|
||||
}
|
||||
|
||||
// ToAPIResponse renders the rule as the API representation.
|
||||
func (r *AccountBudgetRule) ToAPIResponse() *api.AgentNetworkBudgetRule {
|
||||
groups := r.TargetGroups
|
||||
if groups == nil {
|
||||
groups = []string{}
|
||||
}
|
||||
users := r.TargetUsers
|
||||
if users == nil {
|
||||
users = []string{}
|
||||
}
|
||||
created := r.CreatedAt
|
||||
updated := r.UpdatedAt
|
||||
return &api.AgentNetworkBudgetRule{
|
||||
Id: r.ID,
|
||||
Name: r.Name,
|
||||
Enabled: r.Enabled,
|
||||
TargetGroups: groups,
|
||||
TargetUsers: users,
|
||||
Limits: limitsToAPI(r.Limits),
|
||||
CreatedAt: &created,
|
||||
UpdatedAt: &updated,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package types
|
||||
|
||||
import "time"
|
||||
|
||||
// ConsumptionDimension classifies which kind of identity a consumption
|
||||
// row counts against. The proxy-side enforcement layer ticks one row
|
||||
// per dimension per request — typically one user row plus one group
|
||||
// row.
|
||||
type ConsumptionDimension string
|
||||
|
||||
const (
|
||||
// DimensionUser counts tokens / spend for a single end user. The
|
||||
// dim_id column carries the netbird user id (or peer.ID when the
|
||||
// caller is a tunnel-peer principal).
|
||||
DimensionUser ConsumptionDimension = "user"
|
||||
// DimensionGroup counts tokens / spend for a single source group
|
||||
// across every member of that group. The dim_id column carries
|
||||
// the netbird group id.
|
||||
DimensionGroup ConsumptionDimension = "group"
|
||||
)
|
||||
|
||||
// Consumption is a per-dimension token + USD counter for a fixed
|
||||
// aligned window. The (account, dim_kind, dim_id, window_seconds,
|
||||
// window_start) tuple is the primary key; rows are rolled forward by
|
||||
// the proxy's post-flight RecordLLMUsage path on every request.
|
||||
//
|
||||
// The same dim_id (e.g. a group id) gets one row per distinct
|
||||
// window_seconds length in scope across the account's policies,
|
||||
// because two policies with different window lengths read independent
|
||||
// counters even though they share the dimension. Two policies with
|
||||
// identical window_seconds on the same dimension share one counter
|
||||
// (correct: their caps are checked against the same shared bucket).
|
||||
type Consumption struct {
|
||||
AccountID string `gorm:"primaryKey;type:varchar(255)"`
|
||||
DimensionKind ConsumptionDimension `gorm:"primaryKey;type:varchar(16);column:dim_kind"`
|
||||
DimensionID string `gorm:"primaryKey;type:varchar(255);column:dim_id"`
|
||||
WindowSeconds int64 `gorm:"primaryKey;column:window_seconds"`
|
||||
WindowStartUTC time.Time `gorm:"primaryKey;column:window_start_utc"`
|
||||
TokensInput int64 `gorm:"column:tokens_input"`
|
||||
TokensOutput int64 `gorm:"column:tokens_output"`
|
||||
CostUSD float64 `gorm:"column:cost_usd"`
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// TableName forces a stable name independent of GORM's pluraliser.
|
||||
func (Consumption) TableName() string { return "agent_network_consumption" }
|
||||
|
||||
// ConsumptionKey identifies a single consumption counter within an account:
|
||||
// the (dim_kind, dim_id, window_seconds, window_start) part of the row's
|
||||
// primary key. Used to batch-read and batch-increment many counters for one
|
||||
// request in a single store round-trip / transaction.
|
||||
type ConsumptionKey struct {
|
||||
Kind ConsumptionDimension
|
||||
DimID string
|
||||
WindowSeconds int64
|
||||
WindowStartUTC time.Time
|
||||
}
|
||||
|
||||
// WindowStart returns the aligned UTC start of the window of length
|
||||
// windowSeconds that contains t. Aligned to the unix epoch so the
|
||||
// same bucket boundary is computed deterministically across processes.
|
||||
func WindowStart(t time.Time, windowSeconds int64) time.Time {
|
||||
if windowSeconds <= 0 {
|
||||
return t.UTC()
|
||||
}
|
||||
step := windowSeconds * int64(time.Second)
|
||||
bucketed := t.UTC().UnixNano() / step * step
|
||||
return time.Unix(0, bucketed).UTC()
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestWindowStart_AlignedToUnixEpoch is the multi-node-convergence
|
||||
// guarantee: any two proxies computing WindowStart(now, s) for the
|
||||
// same s must land on the same boundary. The implementation aligns
|
||||
// to the unix epoch (UTC) rather than local time, calendar weeks, or
|
||||
// process start time — none of which are shared across nodes.
|
||||
//
|
||||
// Table covers the load-bearing window lengths (5m, 1h, 24h, 30d)
|
||||
// plus a few odd values that still need to align cleanly.
|
||||
func TestWindowStart_AlignedToUnixEpoch(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
instant time.Time
|
||||
windowSeconds int64
|
||||
want time.Time
|
||||
}{
|
||||
{
|
||||
name: "5m window — drops seconds inside the bucket",
|
||||
instant: time.Date(2026, 5, 6, 13, 47, 23, 0, time.UTC),
|
||||
windowSeconds: 300,
|
||||
want: time.Date(2026, 5, 6, 13, 45, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
name: "1h window — drops minutes / seconds, keeps the hour",
|
||||
instant: time.Date(2026, 5, 6, 13, 47, 23, 0, time.UTC),
|
||||
windowSeconds: 3600,
|
||||
want: time.Date(2026, 5, 6, 13, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
name: "24h window aligns to UTC midnight",
|
||||
instant: time.Date(2026, 5, 6, 13, 47, 23, 0, time.UTC),
|
||||
windowSeconds: 86_400,
|
||||
want: time.Date(2026, 5, 6, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
name: "30d (2_592_000s) window aligns to the 30d epoch grid, not month boundaries",
|
||||
instant: time.Date(2026, 5, 6, 0, 0, 0, 0, time.UTC),
|
||||
windowSeconds: 2_592_000,
|
||||
// 2026-05-06 UTC = 1778025600s; 1778025600 / 2592000 = 685
|
||||
// 685 * 2592000 = 1775520000s = 2026-04-07 00:00:00 UTC
|
||||
want: time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
name: "non-UTC input still anchors on UTC epoch boundaries",
|
||||
instant: time.Date(2026, 5, 6, 13, 47, 23, 0, time.FixedZone("CEST", 2*3600)),
|
||||
windowSeconds: 86_400,
|
||||
// 2026-05-06 13:47:23 CEST = 11:47:23 UTC → bucket 2026-05-06 00:00:00 UTC
|
||||
want: time.Date(2026, 5, 6, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := WindowStart(tc.instant, tc.windowSeconds)
|
||||
assert.True(t, got.Equal(tc.want),
|
||||
"WindowStart(%v, %ds) = %v, want %v", tc.instant, tc.windowSeconds, got, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWindowStart_WithinWindowConverges proves the determinism
|
||||
// contract: any two timestamps inside the same window land on the
|
||||
// exact same boundary. Two proxy nodes serving requests 7s apart
|
||||
// must agree on which counter row to upsert.
|
||||
func TestWindowStart_WithinWindowConverges(t *testing.T) {
|
||||
t1 := time.Date(2026, 5, 6, 14, 0, 0, 0, time.UTC)
|
||||
t2 := t1.Add(7 * time.Second)
|
||||
t3 := t1.Add(59*time.Minute + 59*time.Second)
|
||||
|
||||
a := WindowStart(t1, 3600)
|
||||
b := WindowStart(t2, 3600)
|
||||
c := WindowStart(t3, 3600)
|
||||
|
||||
assert.True(t, a.Equal(b), "two timestamps 7s apart in the same 1h window must align to the same boundary")
|
||||
assert.True(t, a.Equal(c), "the very last second of a 1h window still lands on the SAME bucket as the first second")
|
||||
}
|
||||
|
||||
// TestWindowStart_AcrossWindowsDiverges is the symmetric guarantee:
|
||||
// two timestamps separated by a window's worth of time MUST land on
|
||||
// different boundaries. Without this, a 24h window's "rollover"
|
||||
// would never reset the counter.
|
||||
func TestWindowStart_AcrossWindowsDiverges(t *testing.T) {
|
||||
t1 := time.Date(2026, 5, 6, 23, 59, 59, 0, time.UTC)
|
||||
t2 := t1.Add(2 * time.Second) // 2026-05-07 00:00:01
|
||||
|
||||
a := WindowStart(t1, 86_400)
|
||||
b := WindowStart(t2, 86_400)
|
||||
assert.False(t, a.Equal(b),
|
||||
"timestamps straddling a 24h-window boundary must land on different buckets — otherwise daily caps never reset")
|
||||
}
|
||||
|
||||
// TestWindowStart_DifferentWindowsHaveDifferentBuckets locks the
|
||||
// design fork "two policies with different window_seconds on the same
|
||||
// group produce independent counters". A 24h boundary at noon is NOT
|
||||
// the same as the 30d boundary that contains it.
|
||||
func TestWindowStart_DifferentWindowsHaveDifferentBuckets(t *testing.T) {
|
||||
now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
|
||||
short := WindowStart(now, 86_400)
|
||||
long := WindowStart(now, 2_592_000)
|
||||
assert.False(t, short.Equal(long),
|
||||
"the 24h bucket and 30d bucket containing the same instant must differ — independent counters require independent keys")
|
||||
}
|
||||
|
||||
// TestWindowStart_SubMinuteAndMinuteAlignment locks sub-hour windows.
|
||||
// A 5-minute window must align to multiples of 300s from the unix
|
||||
// epoch — minute marks 0/5/10/.../55 within an hour, deterministic
|
||||
// across nodes regardless of clock drift.
|
||||
func TestWindowStart_SubMinuteAndMinuteAlignment(t *testing.T) {
|
||||
t1 := time.Date(2026, 5, 6, 14, 12, 30, 0, time.UTC)
|
||||
t2 := time.Date(2026, 5, 6, 14, 14, 59, 0, time.UTC)
|
||||
t3 := time.Date(2026, 5, 6, 14, 15, 0, 0, time.UTC)
|
||||
|
||||
a := WindowStart(t1, 300)
|
||||
b := WindowStart(t2, 300)
|
||||
c := WindowStart(t3, 300)
|
||||
|
||||
assert.True(t, a.Equal(b),
|
||||
"14:12:30 and 14:14:59 fall in the same 5m bucket starting at 14:10:00")
|
||||
assert.True(t, a.Equal(time.Date(2026, 5, 6, 14, 10, 0, 0, time.UTC)),
|
||||
"5m bucket containing 14:12 starts at 14:10 — aligned to multiples of 300s from unix epoch")
|
||||
assert.False(t, a.Equal(c),
|
||||
"14:15:00 is the start of the next 5m bucket — must not fold into the previous one")
|
||||
}
|
||||
|
||||
// TestWindowStart_ZeroWindowReturnsInputUTC covers the defensive
|
||||
// path: caller hands a zero / negative window (shouldn't happen, but
|
||||
// might mid-refactor). The function returns the input as UTC rather
|
||||
// than dividing by zero.
|
||||
func TestWindowStart_ZeroWindowReturnsInputUTC(t *testing.T) {
|
||||
now := time.Date(2026, 5, 6, 12, 30, 45, 0, time.FixedZone("CEST", 2*3600))
|
||||
got := WindowStart(now, 0)
|
||||
assert.True(t, got.Equal(now.UTC()), "zero window must not panic — return input as UTC")
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// costRow builds an access-log entry carrying only a cost breakdown — the rest
|
||||
// of the row is irrelevant to the summation identities under test.
|
||||
func costRow(id, session string, ts time.Time, in, cachedIn, cacheCreate, out float64) *AgentNetworkAccessLog {
|
||||
return &AgentNetworkAccessLog{
|
||||
ID: id,
|
||||
SessionID: session,
|
||||
Timestamp: ts,
|
||||
InputCostUSD: in,
|
||||
CachedInputCostUSD: cachedIn,
|
||||
CacheCreationCostUSD: cacheCreate,
|
||||
OutputCostUSD: out,
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPIResponse_CostComponentsSumToAggregates is the contract a client adding
|
||||
// up an API response depends on: within a single rendered object, the four
|
||||
// per-bucket fields sum to cost_usd, and the two cache fields sum to
|
||||
// cache_cost_usd. Uses rates that are not exactly representable in binary
|
||||
// floating point, so the identity is checked against real arithmetic rather
|
||||
// than round numbers.
|
||||
func TestAPIResponse_CostComponentsSumToAggregates(t *testing.T) {
|
||||
row := costRow("r1", "s1", time.Now(), 0.000768, 0.0002304, 0.00192, 0.003)
|
||||
|
||||
api := row.ToAPIResponse()
|
||||
assert.InDelta(t, api.InputCostUsd+api.CachedInputCostUsd+api.CacheCreationCostUsd+api.OutputCostUsd,
|
||||
api.CostUsd, 1e-12, "rendered buckets must sum to the rendered cost_usd")
|
||||
assert.InDelta(t, api.CachedInputCostUsd+api.CacheCreationCostUsd, api.CacheCostUsd, 1e-12,
|
||||
"rendered cache buckets must sum to the rendered cache_cost_usd")
|
||||
assert.InDelta(t, 0.0059184, api.CostUsd, 1e-12, "total is the exact sum, not a separately rounded figure")
|
||||
assert.InDelta(t, 0.0021504, api.CacheCostUsd, 1e-12, "cache cost is the exact sum of the two cache buckets")
|
||||
}
|
||||
|
||||
// TestSessionSummary_SumsMatchSummedEntries proves a session summary equals the
|
||||
// sum of the entries it renders: a client that adds up the entries itself must
|
||||
// land on the same number the summary reports, per bucket and in total.
|
||||
func TestSessionSummary_SumsMatchSummedEntries(t *testing.T) {
|
||||
base := time.Date(2026, 5, 5, 10, 0, 0, 0, time.UTC)
|
||||
entries := []*AgentNetworkAccessLog{
|
||||
costRow("r1", "s1", base, 0.000768, 0.0002304, 0.00192, 0.003),
|
||||
costRow("r2", "s1", base.Add(time.Minute), 0.000625, 0.0009375, 0, 0.005),
|
||||
costRow("r3", "s1", base.Add(2*time.Minute), 0.0000016, 0, 0, 0.0000032),
|
||||
}
|
||||
|
||||
sessions := FoldAccessLogSessions([]string{"s1"}, entries)
|
||||
require.Len(t, sessions, 1)
|
||||
sess := sessions[0].ToAPIResponse()
|
||||
|
||||
var wantInput, wantCachedInput, wantCacheCreation, wantOutput float64
|
||||
for _, e := range entries {
|
||||
wantInput += e.InputCostUSD
|
||||
wantCachedInput += e.CachedInputCostUSD
|
||||
wantCacheCreation += e.CacheCreationCostUSD
|
||||
wantOutput += e.OutputCostUSD
|
||||
}
|
||||
|
||||
assert.InDelta(t, wantInput, sess.InputCostUsd, 1e-12, "session input cost is the sum of its entries")
|
||||
assert.InDelta(t, wantCachedInput, sess.CachedInputCostUsd, 1e-12, "session cache-read cost is the sum of its entries")
|
||||
assert.InDelta(t, wantCacheCreation, sess.CacheCreationCostUsd, 1e-12, "session cache-write cost is the sum of its entries")
|
||||
assert.InDelta(t, wantOutput, sess.OutputCostUsd, 1e-12, "session output cost is the sum of its entries")
|
||||
assert.InDelta(t, wantInput+wantCachedInput+wantCacheCreation+wantOutput, sess.CostUsd, 1e-12,
|
||||
"session total equals the summed entry buckets")
|
||||
|
||||
// Summing the rendered entries must give the same answer as reading the
|
||||
// summary — the property a UI relies on when it totals a table itself.
|
||||
var fromEntries float64
|
||||
for _, e := range sess.Entries {
|
||||
fromEntries += e.CostUsd
|
||||
}
|
||||
assert.InDelta(t, sess.CostUsd, fromEntries, 1e-12, "summary total must match the summed rendered entries")
|
||||
|
||||
// The sub-microdollar row must still contribute; it would vanish under
|
||||
// 6-decimal quantisation.
|
||||
assert.Greater(t, sess.InputCostUsd, 0.001393, "small-cost rows must not be quantised away")
|
||||
}
|
||||
|
||||
// TestUsageBuckets_SumsMatchSummedRows proves the same identity one level up:
|
||||
// a usage bucket equals the sum of the ledger rows folded into it, and the
|
||||
// buckets together equal the whole range.
|
||||
func TestUsageBuckets_SumsMatchSummedRows(t *testing.T) {
|
||||
day1 := time.Date(2026, 5, 5, 9, 0, 0, 0, time.UTC)
|
||||
day2 := time.Date(2026, 5, 6, 9, 0, 0, 0, time.UTC)
|
||||
rows := []*AgentNetworkUsage{
|
||||
{ID: "u1", Timestamp: day1, InputCostUSD: 0.000768, CachedInputCostUSD: 0.0002304, CacheCreationCostUSD: 0.00192, OutputCostUSD: 0.003},
|
||||
{ID: "u2", Timestamp: day1.Add(time.Hour), InputCostUSD: 0.000625, CachedInputCostUSD: 0.0009375, OutputCostUSD: 0.005},
|
||||
{ID: "u3", Timestamp: day2, InputCostUSD: 0.0000016, OutputCostUSD: 0.0000032},
|
||||
}
|
||||
|
||||
buckets := AggregateUsageByGranularity(rows, UsageGranularityDay)
|
||||
require.Len(t, buckets, 2, "two distinct days expected")
|
||||
|
||||
var total, cache float64
|
||||
for _, b := range buckets {
|
||||
api := b.ToAPIResponse()
|
||||
assert.InDelta(t, api.InputCostUsd+api.CachedInputCostUsd+api.CacheCreationCostUsd+api.OutputCostUsd,
|
||||
api.CostUsd, 1e-12, "each bucket's components must sum to its cost_usd")
|
||||
total += api.CostUsd
|
||||
cache += api.CacheCostUsd
|
||||
}
|
||||
|
||||
var wantTotal, wantCache float64
|
||||
for _, r := range rows {
|
||||
wantTotal += r.TotalCostUSD()
|
||||
wantCache += r.CacheCostUSD()
|
||||
}
|
||||
assert.InDelta(t, wantTotal, total, 1e-12, "buckets must sum to the total across all ledger rows")
|
||||
assert.InDelta(t, wantCache, cache, 1e-12, "buckets must sum to the cache spend across all ledger rows")
|
||||
|
||||
// A month bucket over the same rows must total identically — regrouping
|
||||
// changes the partition, never the sum.
|
||||
monthly := AggregateUsageByGranularity(rows, UsageGranularityMonth)
|
||||
require.Len(t, monthly, 1)
|
||||
assert.InDelta(t, wantTotal, monthly[0].ToAPIResponse().CostUsd, 1e-12,
|
||||
"re-bucketing at a different granularity must preserve the total")
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/rs/xid"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
// GuardrailChecks is the configurable parameter set persisted with each
|
||||
// guardrail. Stored as a JSON blob to keep the table flat.
|
||||
type GuardrailChecks struct {
|
||||
ModelAllowlist GuardrailModelAllowlist `json:"model_allowlist"`
|
||||
PromptCapture GuardrailPromptCapture `json:"prompt_capture"`
|
||||
}
|
||||
|
||||
type GuardrailModelAllowlist struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Models []string `json:"models"`
|
||||
}
|
||||
|
||||
type GuardrailPromptCapture struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
RedactPii bool `json:"redact_pii"`
|
||||
}
|
||||
|
||||
// Guardrail is an Agent Network reusable guardrail set persisted per account.
|
||||
type Guardrail struct {
|
||||
ID string `gorm:"primaryKey"`
|
||||
AccountID string `gorm:"index"`
|
||||
Name string
|
||||
Description string
|
||||
Checks GuardrailChecks `gorm:"serializer:json"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// TableName uses an explicit name so guardrail rows live in their own
|
||||
// table.
|
||||
func (Guardrail) TableName() string { return "agent_network_guardrails" }
|
||||
|
||||
// NewGuardrail returns a new Guardrail with a freshly minted ID.
|
||||
func NewGuardrail(accountID string) *Guardrail {
|
||||
now := time.Now().UTC()
|
||||
return &Guardrail{
|
||||
ID: "ainguard_" + xid.New().String(),
|
||||
AccountID: accountID,
|
||||
Checks: GuardrailChecks{ModelAllowlist: GuardrailModelAllowlist{Models: []string{}}},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
// FromAPIRequest applies the request payload onto the receiver.
|
||||
func (g *Guardrail) FromAPIRequest(req *api.AgentNetworkGuardrailRequest) {
|
||||
g.Name = req.Name
|
||||
if req.Description != nil {
|
||||
g.Description = *req.Description
|
||||
}
|
||||
g.Checks = checksFromAPI(req.Checks)
|
||||
}
|
||||
|
||||
// ToAPIResponse renders the guardrail as the API representation.
|
||||
func (g *Guardrail) ToAPIResponse() *api.AgentNetworkGuardrail {
|
||||
created := g.CreatedAt
|
||||
updated := g.UpdatedAt
|
||||
return &api.AgentNetworkGuardrail{
|
||||
Id: g.ID,
|
||||
Name: g.Name,
|
||||
Description: g.Description,
|
||||
Checks: checksToAPI(g.Checks),
|
||||
CreatedAt: &created,
|
||||
UpdatedAt: &updated,
|
||||
}
|
||||
}
|
||||
|
||||
// Copy returns a deep copy of the guardrail.
|
||||
func (g *Guardrail) Copy() *Guardrail {
|
||||
clone := *g
|
||||
if g.Checks.ModelAllowlist.Models != nil {
|
||||
clone.Checks.ModelAllowlist.Models = append([]string(nil), g.Checks.ModelAllowlist.Models...)
|
||||
}
|
||||
return &clone
|
||||
}
|
||||
|
||||
// EventMeta is the audit-log payload for activity events.
|
||||
func (g *Guardrail) EventMeta() map[string]any {
|
||||
return map[string]any{"name": g.Name}
|
||||
}
|
||||
|
||||
func checksFromAPI(c api.AgentNetworkGuardrailChecks) GuardrailChecks {
|
||||
models := append([]string(nil), c.ModelAllowlist.Models...)
|
||||
if models == nil {
|
||||
models = []string{}
|
||||
}
|
||||
return GuardrailChecks{
|
||||
ModelAllowlist: GuardrailModelAllowlist{
|
||||
Enabled: c.ModelAllowlist.Enabled,
|
||||
Models: models,
|
||||
},
|
||||
PromptCapture: GuardrailPromptCapture{
|
||||
Enabled: c.PromptCapture.Enabled,
|
||||
RedactPii: c.PromptCapture.RedactPii,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func checksToAPI(c GuardrailChecks) api.AgentNetworkGuardrailChecks {
|
||||
models := c.ModelAllowlist.Models
|
||||
if models == nil {
|
||||
models = []string{}
|
||||
}
|
||||
out := api.AgentNetworkGuardrailChecks{}
|
||||
out.ModelAllowlist.Enabled = c.ModelAllowlist.Enabled
|
||||
out.ModelAllowlist.Models = models
|
||||
out.PromptCapture.Enabled = c.PromptCapture.Enabled
|
||||
out.PromptCapture.RedactPii = c.PromptCapture.RedactPii
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/rs/xid"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
// Policy is an Agent Network policy persisted per account. A policy
|
||||
// authorises members of SourceGroups to reach the listed
|
||||
// DestinationProviderIDs under the attached GuardrailIDs and Limits.
|
||||
//
|
||||
// Token and budget limits live on the Policy itself (Limits field);
|
||||
// guardrails carry only model allowlist and prompt capture.
|
||||
type Policy struct {
|
||||
ID string `gorm:"primaryKey"`
|
||||
AccountID string `gorm:"index"`
|
||||
Name string
|
||||
Description string
|
||||
Enabled bool
|
||||
SourceGroups []string `gorm:"serializer:json;column:source_groups"`
|
||||
DestinationProviderIDs []string `gorm:"serializer:json;column:destination_provider_ids"`
|
||||
GuardrailIDs []string `gorm:"serializer:json;column:guardrail_ids"`
|
||||
Limits PolicyLimits `gorm:"serializer:json;column:limits"`
|
||||
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// PolicyLimits aggregates the token and budget caps attached directly
|
||||
// to a policy. Both halves are always present; their Enabled flags
|
||||
// control whether the proxy enforces them.
|
||||
type PolicyLimits struct {
|
||||
TokenLimit PolicyTokenLimit `json:"token_limit"`
|
||||
BudgetLimit PolicyBudgetLimit `json:"budget_limit"`
|
||||
}
|
||||
|
||||
// PolicyTokenLimit is a token-count cap evaluated over an aligned
|
||||
// window of WindowSeconds seconds. GroupCap is applied to each
|
||||
// source group independently — every group in the policy's
|
||||
// SourceGroups gets its own bucket of GroupCap tokens. UserCap
|
||||
// applies independently to each individual user. A zero cap means
|
||||
// uncapped. WindowSeconds must be at least 60 (one minute) when the
|
||||
// limit is enabled.
|
||||
type PolicyTokenLimit struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
GroupCap int64 `json:"group_cap"`
|
||||
UserCap int64 `json:"user_cap"`
|
||||
WindowSeconds int64 `json:"window_seconds"`
|
||||
}
|
||||
|
||||
// PolicyBudgetLimit is a USD spend cap evaluated over an aligned
|
||||
// window of WindowSeconds seconds. GroupCapUsd is applied to each
|
||||
// source group independently — every group in the policy's
|
||||
// SourceGroups gets its own bucket of GroupCapUsd USD. UserCapUsd
|
||||
// applies independently to each individual user. A zero cap means
|
||||
// uncapped. WindowSeconds must be at least 60 (one minute) when the
|
||||
// limit is enabled.
|
||||
type PolicyBudgetLimit struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
GroupCapUsd float64 `json:"group_cap_usd"`
|
||||
UserCapUsd float64 `json:"user_cap_usd"`
|
||||
WindowSeconds int64 `json:"window_seconds"`
|
||||
}
|
||||
|
||||
// TableName forces a unique GORM table to avoid collision with the access
|
||||
// control Policy type, which also resolves to "policies" by default.
|
||||
func (Policy) TableName() string { return "agent_network_policies" }
|
||||
|
||||
// NewPolicy returns a new Policy with a freshly minted ID.
|
||||
func NewPolicy(accountID string) *Policy {
|
||||
now := time.Now().UTC()
|
||||
return &Policy{
|
||||
ID: "ainpol_" + xid.New().String(),
|
||||
AccountID: accountID,
|
||||
Enabled: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
// FromAPIRequest applies the request payload onto the receiver.
|
||||
func (p *Policy) FromAPIRequest(req *api.AgentNetworkPolicyRequest) {
|
||||
p.Name = req.Name
|
||||
if req.Description != nil {
|
||||
p.Description = *req.Description
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
p.Enabled = *req.Enabled
|
||||
}
|
||||
p.SourceGroups = append([]string(nil), req.SourceGroups...)
|
||||
p.DestinationProviderIDs = append([]string(nil), req.DestinationProviderIds...)
|
||||
if req.GuardrailIds != nil {
|
||||
p.GuardrailIDs = append([]string(nil), (*req.GuardrailIds)...)
|
||||
} else {
|
||||
p.GuardrailIDs = []string{}
|
||||
}
|
||||
if req.Limits != nil {
|
||||
p.Limits = limitsFromAPI(*req.Limits)
|
||||
} else {
|
||||
p.Limits = PolicyLimits{}
|
||||
}
|
||||
}
|
||||
|
||||
// ToAPIResponse renders the policy as the API representation.
|
||||
func (p *Policy) ToAPIResponse() *api.AgentNetworkPolicy {
|
||||
src := p.SourceGroups
|
||||
if src == nil {
|
||||
src = []string{}
|
||||
}
|
||||
dst := p.DestinationProviderIDs
|
||||
if dst == nil {
|
||||
dst = []string{}
|
||||
}
|
||||
guardrails := p.GuardrailIDs
|
||||
if guardrails == nil {
|
||||
guardrails = []string{}
|
||||
}
|
||||
created := p.CreatedAt
|
||||
updated := p.UpdatedAt
|
||||
return &api.AgentNetworkPolicy{
|
||||
Id: p.ID,
|
||||
Name: p.Name,
|
||||
Description: p.Description,
|
||||
Enabled: p.Enabled,
|
||||
SourceGroups: src,
|
||||
DestinationProviderIds: dst,
|
||||
GuardrailIds: guardrails,
|
||||
Limits: limitsToAPI(p.Limits),
|
||||
CreatedAt: &created,
|
||||
UpdatedAt: &updated,
|
||||
}
|
||||
}
|
||||
|
||||
// Copy returns a deep copy of the policy.
|
||||
func (p *Policy) Copy() *Policy {
|
||||
clone := *p
|
||||
if p.SourceGroups != nil {
|
||||
clone.SourceGroups = append([]string(nil), p.SourceGroups...)
|
||||
}
|
||||
if p.DestinationProviderIDs != nil {
|
||||
clone.DestinationProviderIDs = append([]string(nil), p.DestinationProviderIDs...)
|
||||
}
|
||||
if p.GuardrailIDs != nil {
|
||||
clone.GuardrailIDs = append([]string(nil), p.GuardrailIDs...)
|
||||
}
|
||||
return &clone
|
||||
}
|
||||
|
||||
// EventMeta is the audit-log payload for activity events.
|
||||
func (p *Policy) EventMeta() map[string]any {
|
||||
return map[string]any{
|
||||
"name": p.Name,
|
||||
"enabled": p.Enabled,
|
||||
}
|
||||
}
|
||||
|
||||
func limitsFromAPI(in api.AgentNetworkPolicyLimits) PolicyLimits {
|
||||
return PolicyLimits{
|
||||
TokenLimit: PolicyTokenLimit{
|
||||
Enabled: in.TokenLimit.Enabled,
|
||||
GroupCap: in.TokenLimit.GroupCap,
|
||||
UserCap: in.TokenLimit.UserCap,
|
||||
WindowSeconds: in.TokenLimit.WindowSeconds,
|
||||
},
|
||||
BudgetLimit: PolicyBudgetLimit{
|
||||
Enabled: in.BudgetLimit.Enabled,
|
||||
GroupCapUsd: in.BudgetLimit.GroupCapUsd,
|
||||
UserCapUsd: in.BudgetLimit.UserCapUsd,
|
||||
WindowSeconds: in.BudgetLimit.WindowSeconds,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func limitsToAPI(in PolicyLimits) api.AgentNetworkPolicyLimits {
|
||||
return api.AgentNetworkPolicyLimits{
|
||||
TokenLimit: api.AgentNetworkPolicyTokenLimit{
|
||||
Enabled: in.TokenLimit.Enabled,
|
||||
GroupCap: in.TokenLimit.GroupCap,
|
||||
UserCap: in.TokenLimit.UserCap,
|
||||
WindowSeconds: in.TokenLimit.WindowSeconds,
|
||||
},
|
||||
BudgetLimit: api.AgentNetworkPolicyBudgetLimit{
|
||||
Enabled: in.BudgetLimit.Enabled,
|
||||
GroupCapUsd: in.BudgetLimit.GroupCapUsd,
|
||||
UserCapUsd: in.BudgetLimit.UserCapUsd,
|
||||
WindowSeconds: in.BudgetLimit.WindowSeconds,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/xid"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
"github.com/netbirdio/netbird/util/crypt"
|
||||
)
|
||||
|
||||
// ProviderModel is one row in the provider's models list. The operator
|
||||
// pins the per-1k input/output price for cost tracking; ID is the
|
||||
// model identifier the upstream provider expects on the wire.
|
||||
//
|
||||
// The three cache rates are pointers because absence is meaningful: nil
|
||||
// means "inherit NetBird's default rate for this model" (folded in at
|
||||
// synthesis time), while an explicit 0 means "no discount — bill this
|
||||
// cache bucket at the input rate".
|
||||
type ProviderModel struct {
|
||||
ID string `json:"id"`
|
||||
InputPer1k float64 `json:"input_per_1k"`
|
||||
OutputPer1k float64 `json:"output_per_1k"`
|
||||
// CachedInputPer1k is the OpenAI-shape rate for cached prompt tokens
|
||||
// (a subset of input tokens).
|
||||
CachedInputPer1k *float64 `json:"cached_input_per_1k,omitempty"`
|
||||
// CacheReadPer1k is the Anthropic-shape rate for cache-read tokens
|
||||
// (additive to input tokens).
|
||||
CacheReadPer1k *float64 `json:"cache_read_per_1k,omitempty"`
|
||||
// CacheCreationPer1k is the Anthropic-shape rate for cache-creation
|
||||
// tokens (additive to input tokens).
|
||||
CacheCreationPer1k *float64 `json:"cache_creation_per_1k,omitempty"`
|
||||
}
|
||||
|
||||
// Provider is an Agent Network AI provider record persisted per account.
|
||||
// The proxy cluster fronting the account lives on the per-account
|
||||
// agent-network Settings row, not on the Provider — every provider in
|
||||
// an account routes through the same cluster.
|
||||
type Provider struct {
|
||||
ID string `gorm:"primaryKey"`
|
||||
AccountID string `gorm:"index"`
|
||||
ProviderID string `gorm:"index:idx_agent_network_provider"`
|
||||
Name string
|
||||
// UpstreamURL is the full upstream URL (e.g. https://api.openai.com)
|
||||
// the operator selected.
|
||||
UpstreamURL string `gorm:"column:upstream_url"`
|
||||
APIKey string `gorm:"column:api_key"`
|
||||
// ExtraValues holds operator-typed values for catalog-declared
|
||||
// ExtraHeaders (see catalog.Provider.ExtraHeaders). Keyed by
|
||||
// header name (e.g. "x-portkey-config"); a non-empty value is
|
||||
// stamped on every upstream request to this provider via the
|
||||
// proxy's identity-inject middleware (anti-spoof Remove + Add).
|
||||
// Empty / missing keys = no header stamped. Stored as a JSON
|
||||
// blob so the schema doesn't grow per-catalog-entry.
|
||||
ExtraValues map[string]string `gorm:"serializer:json;column:extra_values"`
|
||||
// Models is the operator's curated list of models exposed by this
|
||||
// provider together with their per-1k input/output prices (USD).
|
||||
// Empty means all catalog models are allowed at catalog prices.
|
||||
Models []ProviderModel `gorm:"serializer:json"`
|
||||
Enabled bool
|
||||
// SkipTLSVerification disables upstream TLS certificate verification for
|
||||
// this provider's URL. For self-hosted / internal gateways fronted by a
|
||||
// private or self-signed certificate. The synthesiser propagates it into
|
||||
// the router route so the proxy dials that provider's upstream insecurely.
|
||||
SkipTLSVerification bool `gorm:"column:skip_tls_verification"`
|
||||
// MetadataDisabled suppresses identity metadata injection for this provider.
|
||||
// Metadata (the caller's user + authorizing group) is injected by default;
|
||||
// when true the synthesiser omits the provider's identity-inject shape, so no
|
||||
// user/group headers (e.g. Bedrock's X-Amzn-Bedrock-Request-Metadata) are
|
||||
// stamped. Catalog ExtraHeaders (routing config) are unaffected.
|
||||
MetadataDisabled bool `gorm:"column:metadata_disabled"`
|
||||
// SessionPrivateKey + SessionPublicKey are the ed25519 keypair the
|
||||
// synthesised reverse-proxy service uses to sign / verify session
|
||||
// JWTs after a successful OIDC handshake. Generated once on
|
||||
// provider create and never rotated by the manager so existing
|
||||
// session cookies survive provider edits. SessionPrivateKey is
|
||||
// encrypted at rest via EncryptSensitiveData /
|
||||
// DecryptSensitiveData; SessionPublicKey is plain.
|
||||
SessionPrivateKey string `gorm:"column:session_private_key"`
|
||||
SessionPublicKey string `gorm:"column:session_public_key"`
|
||||
// IdentityHeaderUserID + IdentityHeaderGroups are the operator-
|
||||
// chosen wire header names for HeaderPair-style identity
|
||||
// injection on catalog entries that flag the shape as
|
||||
// Customizable (e.g. Bifrost, where the operator picks between
|
||||
// the always-on x-bf-lh- log-metadata family and the
|
||||
// label-declared x-bf-dim- telemetry family). Empty value
|
||||
// disables stamping for that dimension; the inject middleware
|
||||
// already no-ops on empty header names. Catalog entries with
|
||||
// Customizable=false ignore these fields and use the static
|
||||
// header names defined in their HeaderPairInjection block.
|
||||
IdentityHeaderUserID string `gorm:"column:identity_header_user_id"`
|
||||
IdentityHeaderGroups string `gorm:"column:identity_header_groups"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// TableName uses an explicit name so the Agent Network provider rows live
|
||||
// in their own table, separate from any future "providers"-named entity.
|
||||
func (Provider) TableName() string { return "agent_network_providers" }
|
||||
|
||||
// NewProvider returns a new Provider with a freshly minted ID.
|
||||
func NewProvider(accountID string) *Provider {
|
||||
now := time.Now().UTC()
|
||||
return &Provider{
|
||||
ID: xid.New().String(),
|
||||
AccountID: accountID,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
// FromAPIRequest applies the request payload onto the receiver. The api_key
|
||||
// is only overwritten when the caller provided one — empty/nil leaves the
|
||||
// existing key intact, so updates can omit it.
|
||||
func (p *Provider) FromAPIRequest(req *api.AgentNetworkProviderRequest) {
|
||||
p.ProviderID = req.ProviderId
|
||||
p.Name = req.Name
|
||||
p.UpstreamURL = req.UpstreamUrl
|
||||
if req.ApiKey != nil && strings.TrimSpace(*req.ApiKey) != "" {
|
||||
p.APIKey = *req.ApiKey
|
||||
}
|
||||
if req.ExtraValues != nil {
|
||||
// Replace the whole map (rather than merge) so unsetting a
|
||||
// value on the dashboard actually clears it. Empty strings
|
||||
// are dropped so we don't waste a row on no-op values.
|
||||
next := make(map[string]string, len(*req.ExtraValues))
|
||||
for k, v := range *req.ExtraValues {
|
||||
v = strings.TrimSpace(v)
|
||||
if v != "" {
|
||||
next[k] = v
|
||||
}
|
||||
}
|
||||
if len(next) == 0 {
|
||||
p.ExtraValues = nil
|
||||
} else {
|
||||
p.ExtraValues = next
|
||||
}
|
||||
}
|
||||
p.Models = p.Models[:0]
|
||||
if req.Models != nil {
|
||||
for _, m := range *req.Models {
|
||||
p.Models = append(p.Models, ProviderModel{
|
||||
ID: m.Id,
|
||||
InputPer1k: m.InputPer1k,
|
||||
OutputPer1k: m.OutputPer1k,
|
||||
CachedInputPer1k: copyFloatPtr(m.CachedInputPer1k),
|
||||
CacheReadPer1k: copyFloatPtr(m.CacheReadPer1k),
|
||||
CacheCreationPer1k: copyFloatPtr(m.CacheCreationPer1k),
|
||||
})
|
||||
}
|
||||
}
|
||||
if p.Models == nil {
|
||||
p.Models = []ProviderModel{}
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
p.Enabled = *req.Enabled
|
||||
}
|
||||
if req.SkipTlsVerification != nil {
|
||||
p.SkipTLSVerification = *req.SkipTlsVerification
|
||||
}
|
||||
if req.MetadataDisabled != nil {
|
||||
p.MetadataDisabled = *req.MetadataDisabled
|
||||
}
|
||||
// Identity-header overrides for catalogs flagged Customizable.
|
||||
// Empty or omitted disables stamping for this dimension.
|
||||
if req.IdentityHeaderUserId != nil {
|
||||
p.IdentityHeaderUserID = strings.TrimSpace(*req.IdentityHeaderUserId)
|
||||
}
|
||||
if req.IdentityHeaderGroups != nil {
|
||||
p.IdentityHeaderGroups = strings.TrimSpace(*req.IdentityHeaderGroups)
|
||||
}
|
||||
}
|
||||
|
||||
// ToAPIResponse renders the provider as the API representation. The API
|
||||
// key is intentionally never surfaced.
|
||||
// RedactedForViewer returns a copy with the connection configuration
|
||||
// blanked: upstream URL, operator-typed extra header values, identity
|
||||
// header names, the TLS-verification override, and (defence in depth —
|
||||
// they never reach the wire anyway) the sealed credentials. Read-only
|
||||
// viewers such as usage_viewer only need the display surface — id,
|
||||
// catalog id, name, enabled state, and the model list the usage filters
|
||||
// resolve against — so their responses carry nothing about how the
|
||||
// operator connects to the vendor.
|
||||
func (p *Provider) RedactedForViewer() *Provider {
|
||||
c := *p
|
||||
c.UpstreamURL = ""
|
||||
c.APIKey = ""
|
||||
c.ExtraValues = nil
|
||||
c.IdentityHeaderUserID = ""
|
||||
c.IdentityHeaderGroups = ""
|
||||
c.SkipTLSVerification = false
|
||||
c.SessionPrivateKey = ""
|
||||
return &c
|
||||
}
|
||||
|
||||
func (p *Provider) ToAPIResponse() *api.AgentNetworkProvider {
|
||||
models := make([]api.AgentNetworkProviderModel, 0, len(p.Models))
|
||||
for _, m := range p.Models {
|
||||
models = append(models, api.AgentNetworkProviderModel{
|
||||
Id: m.ID,
|
||||
InputPer1k: m.InputPer1k,
|
||||
OutputPer1k: m.OutputPer1k,
|
||||
CachedInputPer1k: copyFloatPtr(m.CachedInputPer1k),
|
||||
CacheReadPer1k: copyFloatPtr(m.CacheReadPer1k),
|
||||
CacheCreationPer1k: copyFloatPtr(m.CacheCreationPer1k),
|
||||
})
|
||||
}
|
||||
created := p.CreatedAt
|
||||
updated := p.UpdatedAt
|
||||
resp := &api.AgentNetworkProvider{
|
||||
Id: p.ID,
|
||||
ProviderId: p.ProviderID,
|
||||
Name: p.Name,
|
||||
UpstreamUrl: p.UpstreamURL,
|
||||
Models: models,
|
||||
// Always present on the wire so an explicitly cleared header
|
||||
// round-trips as "" instead of vanishing from the response.
|
||||
IdentityHeaderUserId: p.IdentityHeaderUserID,
|
||||
IdentityHeaderGroups: p.IdentityHeaderGroups,
|
||||
Enabled: p.Enabled,
|
||||
SkipTlsVerification: p.SkipTLSVerification,
|
||||
MetadataDisabled: p.MetadataDisabled,
|
||||
CreatedAt: &created,
|
||||
UpdatedAt: &updated,
|
||||
}
|
||||
if len(p.ExtraValues) > 0 {
|
||||
out := make(map[string]string, len(p.ExtraValues))
|
||||
for k, v := range p.ExtraValues {
|
||||
out[k] = v
|
||||
}
|
||||
resp.ExtraValues = &out
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// copyFloatPtr returns a fresh pointer to the same value, or nil. Keeps
|
||||
// stored models and API payloads from aliasing each other's rate fields.
|
||||
func copyFloatPtr(v *float64) *float64 {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
out := *v
|
||||
return &out
|
||||
}
|
||||
|
||||
// Copy returns a deep copy of the provider.
|
||||
func (p *Provider) Copy() *Provider {
|
||||
clone := *p
|
||||
if p.Models != nil {
|
||||
clone.Models = make([]ProviderModel, len(p.Models))
|
||||
for i, m := range p.Models {
|
||||
m.CachedInputPer1k = copyFloatPtr(m.CachedInputPer1k)
|
||||
m.CacheReadPer1k = copyFloatPtr(m.CacheReadPer1k)
|
||||
m.CacheCreationPer1k = copyFloatPtr(m.CacheCreationPer1k)
|
||||
clone.Models[i] = m
|
||||
}
|
||||
}
|
||||
if p.ExtraValues != nil {
|
||||
clone.ExtraValues = make(map[string]string, len(p.ExtraValues))
|
||||
for k, v := range p.ExtraValues {
|
||||
clone.ExtraValues[k] = v
|
||||
}
|
||||
}
|
||||
return &clone
|
||||
}
|
||||
|
||||
// EventMeta is the audit-log payload for activity events.
|
||||
func (p *Provider) EventMeta() map[string]any {
|
||||
return map[string]any{
|
||||
"name": p.Name,
|
||||
"provider_id": p.ProviderID,
|
||||
}
|
||||
}
|
||||
|
||||
// EncryptSensitiveData encrypts the upstream API key and the session
|
||||
// signing key in place.
|
||||
func (p *Provider) EncryptSensitiveData(enc *crypt.FieldEncrypt) error {
|
||||
if enc == nil {
|
||||
return nil
|
||||
}
|
||||
if p.APIKey != "" {
|
||||
encrypted, err := enc.Encrypt(p.APIKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt agent network provider api key: %w", err)
|
||||
}
|
||||
p.APIKey = encrypted
|
||||
}
|
||||
if p.SessionPrivateKey != "" {
|
||||
encrypted, err := enc.Encrypt(p.SessionPrivateKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt agent network provider session key: %w", err)
|
||||
}
|
||||
p.SessionPrivateKey = encrypted
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DecryptSensitiveData decrypts the upstream API key and the session
|
||||
// signing key in place.
|
||||
func (p *Provider) DecryptSensitiveData(enc *crypt.FieldEncrypt) error {
|
||||
if enc == nil {
|
||||
return nil
|
||||
}
|
||||
if p.APIKey != "" {
|
||||
decrypted, err := enc.Decrypt(p.APIKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decrypt agent network provider api key: %w", err)
|
||||
}
|
||||
p.APIKey = decrypted
|
||||
}
|
||||
if p.SessionPrivateKey != "" {
|
||||
decrypted, err := enc.Decrypt(p.SessionPrivateKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decrypt agent network provider session key: %w", err)
|
||||
}
|
||||
p.SessionPrivateKey = decrypted
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
// TestProvider_SkipTLSVerification_RoundTrip covers the request→provider→
|
||||
// response mapping of skip_tls_verification, including the update semantics
|
||||
// (nil pointer preserves, explicit false clears).
|
||||
func TestProvider_SkipTLSVerification_RoundTrip(t *testing.T) {
|
||||
enable := true
|
||||
disable := false
|
||||
|
||||
base := func() *api.AgentNetworkProviderRequest {
|
||||
return &api.AgentNetworkProviderRequest{
|
||||
ProviderId: "openai_api",
|
||||
Name: "internal",
|
||||
UpstreamUrl: "https://gw.internal",
|
||||
}
|
||||
}
|
||||
|
||||
p := NewProvider("acc-1")
|
||||
|
||||
req := base()
|
||||
req.SkipTlsVerification = &enable
|
||||
p.FromAPIRequest(req)
|
||||
assert.True(t, p.SkipTLSVerification, "create with skip_tls_verification=true must set the field")
|
||||
assert.True(t, p.ToAPIResponse().SkipTlsVerification, "response must surface skip_tls_verification")
|
||||
|
||||
// Omitting the field on update leaves the stored value untouched.
|
||||
p.FromAPIRequest(base())
|
||||
assert.True(t, p.SkipTLSVerification, "omitting skip_tls_verification on update must preserve it")
|
||||
|
||||
// Explicit false clears it.
|
||||
req = base()
|
||||
req.SkipTlsVerification = &disable
|
||||
p.FromAPIRequest(req)
|
||||
assert.False(t, p.SkipTLSVerification, "explicit false must clear skip_tls_verification")
|
||||
assert.False(t, p.ToAPIResponse().SkipTlsVerification, "response must reflect the cleared value")
|
||||
}
|
||||
|
||||
// TestProvider_MetadataDisabled_RoundTrip covers the request→provider→response
|
||||
// mapping of metadata_disabled, with the same update semantics: nil preserves,
|
||||
// explicit false clears.
|
||||
func TestProvider_MetadataDisabled_RoundTrip(t *testing.T) {
|
||||
enable := true
|
||||
disable := false
|
||||
|
||||
base := func() *api.AgentNetworkProviderRequest {
|
||||
return &api.AgentNetworkProviderRequest{
|
||||
ProviderId: "bedrock_api",
|
||||
Name: "bedrock",
|
||||
UpstreamUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||
}
|
||||
}
|
||||
|
||||
p := NewProvider("acc-1")
|
||||
|
||||
req := base()
|
||||
req.MetadataDisabled = &enable
|
||||
p.FromAPIRequest(req)
|
||||
assert.True(t, p.MetadataDisabled, "create with metadata_disabled=true must set the field")
|
||||
assert.True(t, p.ToAPIResponse().MetadataDisabled, "response must surface metadata_disabled")
|
||||
|
||||
// Omitting the field on update leaves the stored value untouched.
|
||||
p.FromAPIRequest(base())
|
||||
assert.True(t, p.MetadataDisabled, "omitting metadata_disabled on update must preserve it")
|
||||
|
||||
// Explicit false clears it (re-enables metadata).
|
||||
req = base()
|
||||
req.MetadataDisabled = &disable
|
||||
p.FromAPIRequest(req)
|
||||
assert.False(t, p.MetadataDisabled, "explicit false must clear metadata_disabled")
|
||||
assert.False(t, p.ToAPIResponse().MetadataDisabled, "response must reflect the cleared value")
|
||||
}
|
||||
|
||||
// TestProvider_IdentityHeaders_AlwaysOnWire pins that the identity header
|
||||
// fields are always present in the API response — an explicitly cleared
|
||||
// ("") header must round-trip as "" rather than vanish, so API consumers
|
||||
// (e.g. the Terraform provider) never observe a value other than the one
|
||||
// they wrote.
|
||||
func TestProvider_IdentityHeaders_AlwaysOnWire(t *testing.T) {
|
||||
set := "x-bf-dim-netbird_user_id"
|
||||
empty := ""
|
||||
|
||||
base := func() *api.AgentNetworkProviderRequest {
|
||||
return &api.AgentNetworkProviderRequest{
|
||||
ProviderId: "custom",
|
||||
Name: "bifrost",
|
||||
UpstreamUrl: "https://bifrost.internal",
|
||||
}
|
||||
}
|
||||
|
||||
p := NewProvider("acc-1")
|
||||
resp := p.ToAPIResponse()
|
||||
assert.Equal(t, "", resp.IdentityHeaderUserId, "unset header must surface as empty string, not be omitted")
|
||||
assert.Equal(t, "", resp.IdentityHeaderGroups, "unset header must surface as empty string, not be omitted")
|
||||
|
||||
req := base()
|
||||
req.IdentityHeaderUserId = &set
|
||||
p.FromAPIRequest(req)
|
||||
assert.Equal(t, set, p.ToAPIResponse().IdentityHeaderUserId, "configured header must round-trip")
|
||||
|
||||
// Omitting the field preserves it.
|
||||
p.FromAPIRequest(base())
|
||||
assert.Equal(t, set, p.ToAPIResponse().IdentityHeaderUserId, "omitted header must preserve the stored value")
|
||||
|
||||
// An explicit "" clears it AND stays visible on the wire.
|
||||
req = base()
|
||||
req.IdentityHeaderUserId = &empty
|
||||
p.FromAPIRequest(req)
|
||||
assert.Equal(t, "", p.ToAPIResponse().IdentityHeaderUserId, "cleared header must round-trip as empty string")
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
// DefaultAccessLogRetentionDays is the retention applied to new accounts'
|
||||
// agent-network access logs. Usage records are not subject to this — they are
|
||||
// the long-term aggregate and are retained independently.
|
||||
const DefaultAccessLogRetentionDays = 30
|
||||
|
||||
// Settings is the per-account agent-network configuration row. One row per
|
||||
// account. Domain and ProxyAddress are assigned at bootstrap and immutable
|
||||
// thereafter; a persisted row is always fully allocated — there is no "row
|
||||
// exists, endpoint pending" state.
|
||||
type Settings struct {
|
||||
AccountID string `gorm:"primaryKey"`
|
||||
|
||||
// Domain is the gateway endpoint hostname agents call. Globally unique
|
||||
// across accounts. Sized explicitly because MySQL cannot index an
|
||||
// unbounded TEXT column; 255 covers the RFC 1035 253-octet bound.
|
||||
Domain string `gorm:"type:varchar(255);uniqueIndex:idx_agent_network_settings_domain"`
|
||||
|
||||
// ProxyAddress is the declared cluster address of the proxy serving this
|
||||
// account's gateway. Either equal to Domain — a proxy dedicated to this
|
||||
// account, declaring the tenant's own hostname — or Domain's immediate
|
||||
// parent, with the endpoint one label beneath it on a shared cluster.
|
||||
ProxyAddress string `gorm:"type:varchar(255);index:idx_agent_network_settings_proxy_address"`
|
||||
|
||||
// Account-level collection controls sourced by the synthesizer.
|
||||
// EnableLogCollection gates the per-request access-log trail and defaults
|
||||
// ON for new accounts. EnablePromptCollection is the master gate for
|
||||
// request/response prompt capture (AND-gated with the policy-level
|
||||
// guardrail). RedactPii enables PII redaction on captured prompts;
|
||||
// effective redaction is account OR policy.
|
||||
EnableLogCollection bool
|
||||
EnablePromptCollection bool
|
||||
RedactPii bool
|
||||
|
||||
// AccessLogRetentionDays bounds how long full access-log rows are kept; a
|
||||
// periodic sweep deletes older rows. <= 0 means keep indefinitely. Usage
|
||||
// records are unaffected.
|
||||
AccessLogRetentionDays int
|
||||
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// TableName puts the rows in their own table to keep the agent-network
|
||||
// schema cohesive.
|
||||
func (Settings) TableName() string { return "agent_network_settings" }
|
||||
|
||||
// DefaultSettings returns the settings an account observes before its row is
|
||||
// bootstrapped: log collection on with the default retention, everything else
|
||||
// off, and no domain or proxy address assigned yet. Bootstrap persists exactly
|
||||
// these values plus the assigned domain and proxy address, so the
|
||||
// pre-bootstrap read and the freshly bootstrapped row agree.
|
||||
func DefaultSettings(accountID string) *Settings {
|
||||
return &Settings{
|
||||
AccountID: accountID,
|
||||
EnableLogCollection: true,
|
||||
AccessLogRetentionDays: DefaultAccessLogRetentionDays,
|
||||
}
|
||||
}
|
||||
|
||||
// Endpoint returns the bare hostname agents reach this account at — the
|
||||
// Domain column. Empty until the row is bootstrapped.
|
||||
func (s *Settings) Endpoint() string { return s.Domain }
|
||||
|
||||
// Dedicated reports whether the account's gateway is served by a proxy
|
||||
// dedicated to it — the self-addressed shape, where the serving proxy declares
|
||||
// the endpoint hostname itself. The alternative (labeled) shape has the
|
||||
// endpoint one label beneath a shared cluster's address.
|
||||
func (s *Settings) Dedicated() bool { return s.Domain != "" && s.Domain == s.ProxyAddress }
|
||||
|
||||
// ToAPIResponse renders the settings as the API representation. The
|
||||
// timestamps are omitted while zero — a default (not yet bootstrapped) view
|
||||
// has no persisted row to date.
|
||||
func (s *Settings) ToAPIResponse() *api.AgentNetworkSettings {
|
||||
retention := s.AccessLogRetentionDays
|
||||
resp := &api.AgentNetworkSettings{
|
||||
Endpoint: s.Endpoint(),
|
||||
ProxyAddress: s.ProxyAddress,
|
||||
Dedicated: s.Dedicated(),
|
||||
EnableLogCollection: s.EnableLogCollection,
|
||||
EnablePromptCollection: s.EnablePromptCollection,
|
||||
RedactPii: s.RedactPii,
|
||||
AccessLogRetentionDays: &retention,
|
||||
}
|
||||
if !s.CreatedAt.IsZero() {
|
||||
created := s.CreatedAt
|
||||
resp.CreatedAt = &created
|
||||
}
|
||||
if !s.UpdatedAt.IsZero() {
|
||||
updated := s.UpdatedAt
|
||||
resp.UpdatedAt = &updated
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// FromAPIRequest applies the update request onto the receiver: every mutable
|
||||
// field is replaced with the request value, and the identity fields (Domain,
|
||||
// ProxyAddress) carry the request's echo of the assigned values. The identity
|
||||
// fields are never written to the stored row — UpdateSettings compares them
|
||||
// against it and rejects the request when they differ, so PUT keeps the
|
||||
// house convention of requiring every field while the endpoint and proxy
|
||||
// address stay immutable.
|
||||
//
|
||||
// Every field is required by the schema, so none is presence-sensitive.
|
||||
// AccessLogRetentionDays in particular must stay required: the caller receives
|
||||
// a zero-valued Settings, and UpdateSettings copies each field onto the stored
|
||||
// row unconditionally, so an omitted value would be written as 0 — which the
|
||||
// API documents as "keep indefinitely". Making retention optional would
|
||||
// therefore let a client silently maximise log retention by leaving it out.
|
||||
func (s *Settings) FromAPIRequest(req *api.AgentNetworkSettingsRequest) {
|
||||
s.Domain = req.Endpoint
|
||||
s.ProxyAddress = req.ProxyAddress
|
||||
s.EnableLogCollection = req.EnableLogCollection
|
||||
s.EnablePromptCollection = req.EnablePromptCollection
|
||||
s.RedactPii = req.RedactPii
|
||||
s.AccessLogRetentionDays = req.AccessLogRetentionDays
|
||||
}
|
||||
|
||||
// FromAPICreateRequest applies the optional collection toggles of a bootstrap
|
||||
// request onto the receiver (typically DefaultSettings), leaving defaults in
|
||||
// place for omitted fields. The identity fields are resolved by the manager
|
||||
// from the request's proxy_address / endpoint, not copied here.
|
||||
func (s *Settings) FromAPICreateRequest(req *api.AgentNetworkSettingsCreateRequest) {
|
||||
if req.EnableLogCollection != nil {
|
||||
s.EnableLogCollection = *req.EnableLogCollection
|
||||
}
|
||||
if req.EnablePromptCollection != nil {
|
||||
s.EnablePromptCollection = *req.EnablePromptCollection
|
||||
}
|
||||
if req.RedactPii != nil {
|
||||
s.RedactPii = *req.RedactPii
|
||||
}
|
||||
if req.AccessLogRetentionDays != nil {
|
||||
s.AccessLogRetentionDays = *req.AccessLogRetentionDays
|
||||
}
|
||||
}
|
||||
|
||||
// maxHostnameLength is the RFC 1035 bound on a full domain name.
|
||||
const maxHostnameLength = 253
|
||||
|
||||
// NormalizeHostname lowercases and trims a caller-supplied hostname and
|
||||
// validates its shape: non-empty DNS labels of letters, digits and inner
|
||||
// hyphens, joined by single dots, within length bounds. Shapes that
|
||||
// canonicalization cannot repair — leading/trailing dots, empty labels,
|
||||
// whitespace inside the name — are rejected rather than guessed at, because
|
||||
// the value lands in an immutable column.
|
||||
func NormalizeHostname(raw string) (string, error) {
|
||||
hostname := strings.ToLower(strings.TrimSpace(raw))
|
||||
if hostname == "" {
|
||||
return "", fmt.Errorf("hostname is empty")
|
||||
}
|
||||
if len(hostname) > maxHostnameLength {
|
||||
return "", fmt.Errorf("hostname exceeds %d characters", maxHostnameLength)
|
||||
}
|
||||
for _, label := range strings.Split(hostname, ".") {
|
||||
if err := validateHostnameLabel(label); err != nil {
|
||||
return "", fmt.Errorf("invalid hostname %q: %w", hostname, err)
|
||||
}
|
||||
}
|
||||
return hostname, nil
|
||||
}
|
||||
|
||||
func validateHostnameLabel(label string) error {
|
||||
if label == "" {
|
||||
return fmt.Errorf("empty label (leading, trailing or doubled dot)")
|
||||
}
|
||||
if len(label) > 63 {
|
||||
return fmt.Errorf("label %q exceeds 63 characters", label)
|
||||
}
|
||||
if label[0] == '-' || label[len(label)-1] == '-' {
|
||||
return fmt.Errorf("label %q must not start or end with a hyphen", label)
|
||||
}
|
||||
for _, r := range label {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
case r >= '0' && r <= '9':
|
||||
case r == '-':
|
||||
default:
|
||||
return fmt.Errorf("label %q contains invalid character %q", label, r)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// AgentNetworkUsage is the stripped, always-collected per-request usage record
|
||||
// powering the Usage overview. Unlike AgentNetworkAccessLog it carries no
|
||||
// request detail (host/path/source IP/prompt) — only the dimensions needed to
|
||||
// aggregate and filter spend by user / group / provider / model over time.
|
||||
//
|
||||
// It is written unconditionally on every served agent-network request,
|
||||
// independent of the account's EnableLogCollection toggle: when log collection
|
||||
// is off the proxy ships a stripped, usage-only entry and management still
|
||||
// records the usage row (but skips the full AgentNetworkAccessLog row).
|
||||
type AgentNetworkUsage struct {
|
||||
ID string `gorm:"primaryKey"`
|
||||
AccountID string `gorm:"index"`
|
||||
Timestamp time.Time `gorm:"index"`
|
||||
UserID string `gorm:"index"`
|
||||
ResolvedProviderID string `gorm:"index"`
|
||||
Provider string // vendor, e.g. "openai"
|
||||
Model string `gorm:"index"`
|
||||
SessionID string `gorm:"index"` // llm.session_id — groups a conversation / coding session
|
||||
InputTokens int64
|
||||
OutputTokens int64
|
||||
TotalTokens int64
|
||||
// Prompt-cache buckets: read + write token counts.
|
||||
CachedInputTokens int64
|
||||
CacheCreationTokens int64
|
||||
// Per-bucket cost breakdown, mirroring AgentNetworkAccessLog — the only
|
||||
// cost state stored; total and cache portion are derived on read. Kept on
|
||||
// the usage ledger too so spend can be attributed per bucket even for
|
||||
// accounts with log collection turned off. See AgentNetworkAccessLog for
|
||||
// why the columns carry a zero default.
|
||||
InputCostUSD float64 `gorm:"not null;default:0"`
|
||||
CachedInputCostUSD float64 `gorm:"not null;default:0"`
|
||||
CacheCreationCostUSD float64 `gorm:"not null;default:0"`
|
||||
OutputCostUSD float64 `gorm:"not null;default:0"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// TableName keeps usage records in their own stripped table. Named
|
||||
// distinctly (…_request_usage) to avoid colliding with any pre-existing
|
||||
// agent_network_usage table in a shared database.
|
||||
func (AgentNetworkUsage) TableName() string { return "agent_network_request_usage" }
|
||||
|
||||
// TotalCostUSD is the request's total cost: the sum of the four per-bucket
|
||||
// costs. Derived rather than stored so it cannot disagree with the breakdown.
|
||||
func (u *AgentNetworkUsage) TotalCostUSD() float64 {
|
||||
return u.InputCostUSD + u.CachedInputCostUSD + u.CacheCreationCostUSD + u.OutputCostUSD
|
||||
}
|
||||
|
||||
// CacheCostUSD is the portion of the total billed for prompt-cache buckets.
|
||||
func (u *AgentNetworkUsage) CacheCostUSD() float64 {
|
||||
return u.CachedInputCostUSD + u.CacheCreationCostUSD
|
||||
}
|
||||
|
||||
// AgentNetworkUsageGroup is the normalised many-to-many row linking a usage
|
||||
// record to one authorising group, mirroring AgentNetworkAccessLogGroup so the
|
||||
// usage overview can filter by group with a `group_id IN (...)` join.
|
||||
type AgentNetworkUsageGroup struct {
|
||||
UsageID string `gorm:"primaryKey"`
|
||||
GroupID string `gorm:"primaryKey;index"`
|
||||
AccountID string `gorm:"index"`
|
||||
}
|
||||
|
||||
// TableName names the usage group child table.
|
||||
func (AgentNetworkUsageGroup) TableName() string { return "agent_network_request_usage_group" }
|
||||
@@ -0,0 +1,125 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
// UsageGranularity is the time-bucket width for the usage overview. New values
|
||||
// can be added here and handled in bucketStart without touching the store.
|
||||
type UsageGranularity string
|
||||
|
||||
const (
|
||||
UsageGranularityDay UsageGranularity = "day"
|
||||
UsageGranularityWeek UsageGranularity = "week"
|
||||
UsageGranularityMonth UsageGranularity = "month"
|
||||
)
|
||||
|
||||
// ParseUsageGranularity maps the API query value to a granularity, defaulting
|
||||
// to day for empty/unknown input.
|
||||
func ParseUsageGranularity(s string) UsageGranularity {
|
||||
switch UsageGranularity(s) {
|
||||
case UsageGranularityWeek:
|
||||
return UsageGranularityWeek
|
||||
case UsageGranularityMonth:
|
||||
return UsageGranularityMonth
|
||||
default:
|
||||
return UsageGranularityDay
|
||||
}
|
||||
}
|
||||
|
||||
// AgentNetworkUsageBucket is one aggregated usage time bucket. PeriodStart is
|
||||
// the UTC start of the bucket as YYYY-MM-DD.
|
||||
type AgentNetworkUsageBucket struct {
|
||||
PeriodStart string
|
||||
InputTokens int64
|
||||
OutputTokens int64
|
||||
TotalTokens int64
|
||||
CachedInputTokens int64
|
||||
CacheCreationTokens int64
|
||||
InputCostUSD float64
|
||||
CachedInputCostUSD float64
|
||||
CacheCreationCostUSD float64
|
||||
OutputCostUSD float64
|
||||
}
|
||||
|
||||
// TotalCostUSD is the bucket's total spend: the sum of the four per-bucket
|
||||
// costs. Derived rather than accumulated separately so it cannot disagree with
|
||||
// the components.
|
||||
func (b *AgentNetworkUsageBucket) TotalCostUSD() float64 {
|
||||
return b.InputCostUSD + b.CachedInputCostUSD + b.CacheCreationCostUSD + b.OutputCostUSD
|
||||
}
|
||||
|
||||
// CacheCostUSD is the bucket's prompt-cache spend: cache reads plus writes.
|
||||
func (b *AgentNetworkUsageBucket) CacheCostUSD() float64 {
|
||||
return b.CachedInputCostUSD + b.CacheCreationCostUSD
|
||||
}
|
||||
|
||||
// ToAPIResponse renders the bucket as the API representation.
|
||||
func (b *AgentNetworkUsageBucket) ToAPIResponse() api.AgentNetworkUsageBucket {
|
||||
return api.AgentNetworkUsageBucket{
|
||||
PeriodStart: b.PeriodStart,
|
||||
InputTokens: b.InputTokens,
|
||||
OutputTokens: b.OutputTokens,
|
||||
TotalTokens: b.TotalTokens,
|
||||
CachedInputTokens: b.CachedInputTokens,
|
||||
CacheCreationTokens: b.CacheCreationTokens,
|
||||
InputCostUsd: b.InputCostUSD,
|
||||
CachedInputCostUsd: b.CachedInputCostUSD,
|
||||
CacheCreationCostUsd: b.CacheCreationCostUSD,
|
||||
OutputCostUsd: b.OutputCostUSD,
|
||||
CostUsd: b.TotalCostUSD(),
|
||||
CacheCostUsd: b.CacheCostUSD(),
|
||||
}
|
||||
}
|
||||
|
||||
// bucketStart truncates t (in UTC) to the start of its bucket for the given
|
||||
// granularity. Week buckets start on Monday (ISO week).
|
||||
func bucketStart(t time.Time, g UsageGranularity) time.Time {
|
||||
t = t.UTC()
|
||||
switch g {
|
||||
case UsageGranularityWeek:
|
||||
// Monday-start week. time.Weekday: Sunday=0..Saturday=6.
|
||||
offset := (int(t.Weekday()) + 6) % 7
|
||||
day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
|
||||
return day.AddDate(0, 0, -offset)
|
||||
case UsageGranularityMonth:
|
||||
return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||
default: // day
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
}
|
||||
|
||||
// AggregateUsageByGranularity buckets the usage rows by the requested
|
||||
// granularity and returns the buckets ordered oldest-first. Aggregation is done
|
||||
// in Go (rather than per-engine SQL date_trunc) so granularities stay portable
|
||||
// across SQLite/Postgres/MySQL and easy to extend.
|
||||
func AggregateUsageByGranularity(rows []*AgentNetworkUsage, g UsageGranularity) []*AgentNetworkUsageBucket {
|
||||
byPeriod := make(map[string]*AgentNetworkUsageBucket)
|
||||
for _, r := range rows {
|
||||
key := bucketStart(r.Timestamp, g).Format("2006-01-02")
|
||||
b := byPeriod[key]
|
||||
if b == nil {
|
||||
b = &AgentNetworkUsageBucket{PeriodStart: key}
|
||||
byPeriod[key] = b
|
||||
}
|
||||
b.InputTokens += r.InputTokens
|
||||
b.OutputTokens += r.OutputTokens
|
||||
b.TotalTokens += r.TotalTokens
|
||||
b.CachedInputTokens += r.CachedInputTokens
|
||||
b.CacheCreationTokens += r.CacheCreationTokens
|
||||
b.InputCostUSD += r.InputCostUSD
|
||||
b.CachedInputCostUSD += r.CachedInputCostUSD
|
||||
b.CacheCreationCostUSD += r.CacheCreationCostUSD
|
||||
b.OutputCostUSD += r.OutputCostUSD
|
||||
}
|
||||
|
||||
out := make([]*AgentNetworkUsageBucket, 0, len(byPeriod))
|
||||
for _, b := range byPeriod {
|
||||
out = append(out, b)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].PeriodStart < out[j].PeriodStart })
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package agentnetwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
||||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
// TestSynthesizedService_WireShape locks down the proto shape that
|
||||
// flows from the synthesizer through ToProtoMapping to the proxy.
|
||||
// Drift between this test and what the proxy expects manifests as
|
||||
// "service not matching" — the proxy receives a mapping but can't
|
||||
// register an SNI/HTTP route from it.
|
||||
func TestSynthesizedService_WireShape(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
mockStore := store.NewMockStore(ctrl)
|
||||
|
||||
provider := newSynthTestProvider()
|
||||
policy := newSynthTestPolicy(provider.ID, "grp-eng", "")
|
||||
|
||||
expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(),
|
||||
[]*types.Provider{provider},
|
||||
[]*types.Policy{policy},
|
||||
[]*types.Guardrail{})
|
||||
|
||||
services, err := SynthesizeServices(ctx, mockStore, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, services, 1)
|
||||
|
||||
svc := services[0]
|
||||
mapping := svc.ToProtoMapping(rpservice.Create, "test-token", proxy.OIDCValidationConfig{})
|
||||
|
||||
// Identifiers — account-scoped service ID, settings-derived domain.
|
||||
assert.Equal(t, "agent-net-svc-acct-1", mapping.GetId(), "stable account-scoped virtual service ID")
|
||||
assert.Equal(t, testAccountID, mapping.GetAccountId(), "account id round-trips")
|
||||
assert.Equal(t, testEndpoint, mapping.GetDomain(), "domain matches settings.Endpoint() output")
|
||||
|
||||
// Mode + listen port — addMapping at proxy/server.go switches on Mode.
|
||||
assert.Equal(t, "http", mapping.GetMode(), "synthesised services are HTTP mode")
|
||||
assert.Equal(t, int32(0), mapping.GetListenPort(), "no custom listen port for HTTP services")
|
||||
|
||||
// Auth token + private/tunnel shape: agent-network endpoints authenticate
|
||||
// inbound agents via ValidateTunnelPeer against AccessGroups, not OIDC.
|
||||
assert.Equal(t, "test-token", mapping.GetAuthToken(), "auth token round-trips for proxy CreateProxyPeer")
|
||||
assert.True(t, mapping.GetPrivate(), "synthesised services are private (tunnel-peer auth via AccessGroups)")
|
||||
require.NotNil(t, mapping.GetAuth(), "auth payload carries the session key")
|
||||
assert.False(t, mapping.GetAuth().GetOidc(), "OIDC is off for tunnel-auth agent-network services")
|
||||
|
||||
// Path mappings — proxy/server.go::setupHTTPMapping early-returns when
|
||||
// len(mapping.GetPath()) == 0, so this is a critical assertion.
|
||||
require.Len(t, mapping.GetPath(), 1, "exactly one path mapping for the cluster target")
|
||||
pm := mapping.GetPath()[0]
|
||||
assert.Equal(t, "/", pm.GetPath(), "default path is '/'")
|
||||
assert.Equal(t, "https://noop.invalid/", pm.GetTarget(),
|
||||
"target URL is the placeholder; the router middleware rewrites it per request")
|
||||
require.NotNil(t, pm.GetOptions(), "target options must be populated so direct_upstream + middleware chain reach the proxy")
|
||||
assert.True(t, pm.GetOptions().GetDirectUpstream(), "synth targets imply direct_upstream so the proxy dials via the host stack")
|
||||
assert.True(t, pm.GetOptions().GetAgentNetwork(), "agent_network flag must travel on the wire so the proxy can tag access logs")
|
||||
|
||||
mws := pm.GetOptions().GetMiddlewares()
|
||||
require.Len(t, mws, 8, "eight middlewares reach the proxy: request_parser, router, limit_check, identity_inject, guardrail, limit_record, cost_meter, response_parser")
|
||||
|
||||
assert.Equal(t, middlewareIDLLMRequestParser, mws[0].GetId(), "first middleware id")
|
||||
assert.Equal(t, proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, mws[0].GetSlot(), "request parser slot")
|
||||
|
||||
assert.Equal(t, middlewareIDLLMRouter, mws[1].GetId(), "second middleware id")
|
||||
assert.Equal(t, proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, mws[1].GetSlot(), "router slot")
|
||||
require.NotEmpty(t, mws[1].GetConfigJson(), "router config must travel on the wire")
|
||||
var routerCfg routerConfig
|
||||
require.NoError(t, json.Unmarshal(mws[1].GetConfigJson(), &routerCfg), "router config decodes")
|
||||
require.Len(t, routerCfg.Providers, 1, "the only enabled provider reaches the router")
|
||||
assert.Equal(t, provider.ID, routerCfg.Providers[0].ID, "router provider id matches synth provider")
|
||||
assert.Equal(t, "Bearer sk-test-key", routerCfg.Providers[0].AuthHeaderValue,
|
||||
"openai catalog template substitutes the API key on the wire")
|
||||
|
||||
assert.Equal(t, middlewareIDLLMLimitCheck, mws[2].GetId(),
|
||||
"limit_check runs after the router so the resolved provider id is available, before identity_inject so a deny doesn't pay the header-stamp cost")
|
||||
assert.Equal(t, proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, mws[2].GetSlot())
|
||||
|
||||
assert.Equal(t, middlewareIDLLMIdentityInject, mws[3].GetId(), "fourth middleware id")
|
||||
assert.Equal(t, proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, mws[3].GetSlot(), "identity inject slot")
|
||||
require.NotEmpty(t, mws[3].GetConfigJson(), "identity inject config JSON must travel on the wire")
|
||||
|
||||
assert.Equal(t, middlewareIDLLMGuardrail, mws[4].GetId(), "fifth middleware id")
|
||||
assert.Equal(t, proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, mws[4].GetSlot(), "guardrail slot")
|
||||
require.NotEmpty(t, mws[4].GetConfigJson(), "guardrail middleware config JSON must travel on the wire")
|
||||
|
||||
assert.Equal(t, middlewareIDLLMLimitRecord, mws[5].GetId(),
|
||||
"limit_record sits FIRST in the response section so it RUNS LAST at runtime — slot order on the response leg is reverse-of-slice")
|
||||
assert.Equal(t, proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_RESPONSE, mws[5].GetSlot())
|
||||
|
||||
assert.Equal(t, middlewareIDCostMeter, mws[6].GetId(), "seventh middleware id")
|
||||
assert.Equal(t, proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_RESPONSE, mws[6].GetSlot(), "cost meter slot")
|
||||
var costCfg costMeterConfig
|
||||
require.NoError(t, json.Unmarshal(mws[6].GetConfigJson(), &costCfg), "cost meter config JSON must decode from the wire")
|
||||
require.NotNil(t, costCfg.Pricing, "the pricing table must travel on the wire — the proxy has no embedded price list to fall back to")
|
||||
assert.NotEmpty(t, costCfg.Pricing.Defaults["openai"], "default table rides in every mapping")
|
||||
assert.NotEmpty(t, costCfg.Pricing.Defaults["anthropic"], "default table covers all surfaces")
|
||||
assert.NotEmpty(t, costCfg.Pricing.Defaults["bedrock"], "default table covers all surfaces")
|
||||
|
||||
assert.Equal(t, middlewareIDLLMResponseParser, mws[7].GetId(), "eighth middleware id")
|
||||
assert.Equal(t, proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_RESPONSE, mws[7].GetSlot(), "response parser slot")
|
||||
}
|
||||
Reference in New Issue
Block a user