mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-12 17:59:06 +02:00
* [management] Add agentgateway provider catalog entry Allow Agent Network providers to target an operator-supplied agentgateway proxy while stamping trusted NetBird identity headers. Signed-off-by: Daneyon Hansen <daneyon.hansen@solo.io> * [proxy] Allow trusted Agent Network identity headers Permit only the built-in identity injector to replace the two reserved agentgateway attribution headers while keeping them blocked for every other middleware. Signed-off-by: Daneyon Hansen <daneyon.hansen@solo.io> * [management,proxy] Add multi-vendor gateway routing Let one Agent Network route declare multiple parser surfaces while preserving the existing singular vendor wire field. Signed-off-by: Daneyon Hansen <daneyon.hansen@solo.io> * [management] Update router test for model policies Signed-off-by: Daneyon Hansen <daneyon.hansen@solo.io> * [proxy] Cover reserved header policy Signed-off-by: Daneyon Hansen <daneyon.hansen@solo.io> * [management] Add agentgateway model discovery Use agentgateway's OpenAI-compatible models endpoint and omit wildcard patterns until NetBird can authorize and price them consistently. Signed-off-by: Daneyon Hansen <daneyon.hansen@solo.io> --------- Signed-off-by: Daneyon Hansen <daneyon.hansen@solo.io>
1350 lines
56 KiB
Go
1350 lines
56 KiB
Go
package llm_router
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/netbirdio/netbird/proxy/internal/middleware"
|
|
)
|
|
|
|
// metaValue returns the value for the first KV with the given key.
|
|
func metaValue(t *testing.T, kvs []middleware.KV, key string) (string, bool) {
|
|
t.Helper()
|
|
for _, kv := range kvs {
|
|
if kv.Key == key {
|
|
return kv.Value, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// defaultTestGroup is the group id used by routes and inputs in tests
|
|
// that don't specifically exercise the group-filter logic. Pairing it
|
|
// with the same id on every test route keeps the legacy assertions
|
|
// focused on routing/path behaviour without each one having to bake in
|
|
// its own ACL.
|
|
const defaultTestGroup = "grp-test"
|
|
|
|
// newInputWithModel returns an Input carrying llm.model in its metadata
|
|
// bag, mimicking the post-llm_request_parser state the router observes
|
|
// in production. UserGroups is populated with defaultTestGroup so the
|
|
// router's group-filter pass authorises any test route whose
|
|
// AllowedGroupIDs contains the same id.
|
|
func newInputWithModel(model string) *middleware.Input {
|
|
return &middleware.Input{
|
|
Slot: middleware.SlotOnRequest,
|
|
Metadata: []middleware.KV{{Key: middleware.KeyLLMModel, Value: model}},
|
|
UserGroups: []string{defaultTestGroup},
|
|
}
|
|
}
|
|
|
|
// newInputWithModelAndURL returns an Input carrying both llm.model and
|
|
// a request URL so router tests can exercise path-based disambiguation.
|
|
func newInputWithModelAndURL(model, reqURL string) *middleware.Input {
|
|
in := newInputWithModel(model)
|
|
in.URL = reqURL
|
|
return in
|
|
}
|
|
|
|
func TestMiddlewareIdentity(t *testing.T) {
|
|
mw := New(Config{})
|
|
assert.Equal(t, ID, mw.ID(), "middleware ID must be llm_router")
|
|
assert.Equal(t, Version, mw.Version(), "version must match the constant")
|
|
assert.Equal(t, middleware.SlotOnRequest, mw.Slot(), "router must run in SlotOnRequest")
|
|
assert.True(t, mw.MutationsSupported(), "router must declare mutations support")
|
|
assert.Nil(t, mw.AcceptedContentTypes(), "router does not inspect bodies")
|
|
assert.ElementsMatch(t,
|
|
[]string{
|
|
middleware.KeyLLMResolvedProviderID,
|
|
middleware.KeyLLMAuthorisingGroups,
|
|
middleware.KeyLLMNonInference,
|
|
middleware.KeyLLMModel,
|
|
middleware.KeyLLMPolicyDecision,
|
|
middleware.KeyLLMPolicyReason,
|
|
},
|
|
mw.MetadataKeys(),
|
|
"metadata key allowlist must match the spec",
|
|
)
|
|
require.NoError(t, mw.Close())
|
|
}
|
|
|
|
func TestRouter_HappyPath(t *testing.T) {
|
|
route := ProviderRoute{
|
|
ID: "openai-prod",
|
|
Models: []string{"gpt-4o"},
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "api.openai.com",
|
|
AuthHeaderName: "Authorization",
|
|
AuthHeaderValue: "Bearer sk-test-123",
|
|
}
|
|
mw := New(Config{Providers: []ProviderRoute{route}})
|
|
|
|
out, err := mw.Invoke(context.Background(), newInputWithModel("gpt-4o"))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out)
|
|
assert.Equal(t, middleware.DecisionAllow, out.Decision, "matched model must allow")
|
|
|
|
require.NotNil(t, out.Mutations, "matched route must emit mutations")
|
|
rewrite := out.Mutations.RewriteUpstream
|
|
require.NotNil(t, rewrite, "matched route must emit upstream rewrite")
|
|
assert.Equal(t, "https", rewrite.Scheme, "rewrite scheme must come from the matched route")
|
|
assert.Equal(t, "api.openai.com", rewrite.Host, "rewrite host must come from the matched route")
|
|
|
|
assert.ElementsMatch(t, strippedAuthHeaders, rewrite.StripHeaders,
|
|
"strip list rides on UpstreamRewrite (bypasses framework denylist) and must cover every known vendor auth header")
|
|
require.NotNil(t, rewrite.AuthHeader, "router must inject the auth header via the rewrite (not HeadersAdd) so the proxy bypasses the denylist")
|
|
assert.Equal(t, "Authorization", rewrite.AuthHeader.Name, "injected header name must come from the route")
|
|
assert.Equal(t, "Bearer sk-test-123", rewrite.AuthHeader.Value, "injected header value must come from the route")
|
|
assert.Empty(t, out.Mutations.HeadersAdd, "router must not use HeadersAdd; auth flows through UpstreamRewrite.AuthHeader")
|
|
assert.Empty(t, out.Mutations.HeadersRemove, "router must not use HeadersRemove; strip flows through UpstreamRewrite.StripHeaders")
|
|
|
|
resolved, ok := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID)
|
|
require.True(t, ok, "router must emit llm.resolved_provider_id on a match")
|
|
assert.Equal(t, "openai-prod", resolved, "resolved provider id must be the matched route's ID")
|
|
dec, _ := metaValue(t, out.Metadata, middleware.KeyLLMPolicyDecision)
|
|
assert.Equal(t, "allow", dec, "decision metadata must be allow on a match")
|
|
}
|
|
|
|
func TestRouter_SkipTLSVerifyPropagates(t *testing.T) {
|
|
base := ProviderRoute{
|
|
ID: "internal-gw",
|
|
Models: []string{"gpt-4o"},
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "gateway.internal",
|
|
AuthHeaderName: "Authorization",
|
|
AuthHeaderValue: "Bearer sk-test-123",
|
|
}
|
|
|
|
t.Run("enabled", func(t *testing.T) {
|
|
route := base
|
|
route.SkipTLSVerify = true
|
|
mw := New(Config{Providers: []ProviderRoute{route}})
|
|
|
|
out, err := mw.Invoke(context.Background(), newInputWithModel("gpt-4o"))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out.Mutations, "matched route must emit mutations")
|
|
require.NotNil(t, out.Mutations.RewriteUpstream, "matched route must emit upstream rewrite")
|
|
assert.True(t, out.Mutations.RewriteUpstream.SkipTLSVerify,
|
|
"skip_tls_verify on the route must ride on the upstream rewrite")
|
|
})
|
|
|
|
t.Run("default off", func(t *testing.T) {
|
|
mw := New(Config{Providers: []ProviderRoute{base}})
|
|
|
|
out, err := mw.Invoke(context.Background(), newInputWithModel("gpt-4o"))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out.Mutations.RewriteUpstream, "matched route must emit upstream rewrite")
|
|
assert.False(t, out.Mutations.RewriteUpstream.SkipTLSVerify,
|
|
"skip_tls_verify must default to false when the route does not set it")
|
|
})
|
|
}
|
|
|
|
func TestRouter_MissingModel(t *testing.T) {
|
|
mw := New(Config{Providers: []ProviderRoute{{
|
|
ID: "openai-prod",
|
|
Models: []string{"gpt-4o"},
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "api.openai.com",
|
|
}}})
|
|
|
|
out, err := mw.Invoke(context.Background(), &middleware.Input{Slot: middleware.SlotOnRequest})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out)
|
|
assert.Equal(t, middleware.DecisionDeny, out.Decision, "missing llm.model must deny")
|
|
assert.Equal(t, 403, out.DenyStatus, "deny status must be 403")
|
|
require.NotNil(t, out.DenyReason, "deny reason must be populated")
|
|
assert.Equal(t, "llm_policy.model_not_routable", out.DenyReason.Code, "deny code must be model_not_routable")
|
|
assert.Equal(t, "missing llm.model on request envelope", out.DenyReason.Message, "deny message must match spec")
|
|
|
|
dec, _ := metaValue(t, out.Metadata, middleware.KeyLLMPolicyDecision)
|
|
assert.Equal(t, "deny", dec, "decision metadata must be deny")
|
|
reason, _ := metaValue(t, out.Metadata, middleware.KeyLLMPolicyReason)
|
|
assert.Equal(t, "model_not_routable", reason, "reason metadata must be model_not_routable")
|
|
}
|
|
|
|
// newModellessInput returns an Input with no llm.model and the given
|
|
// request path, mimicking a GET /v1/models call (which carries no body
|
|
// from which a model could be parsed). UserGroups matches defaultTestGroup.
|
|
func newModellessInput(reqURL string) *middleware.Input {
|
|
return &middleware.Input{
|
|
Slot: middleware.SlotOnRequest,
|
|
URL: reqURL,
|
|
// The non-inference endpoints are read requests; the method is what
|
|
// separates them from an inference body posted to the same path, so
|
|
// state it rather than leaning on the zero value.
|
|
Method: http.MethodGet,
|
|
UserGroups: []string{defaultTestGroup},
|
|
}
|
|
}
|
|
|
|
func TestRouter_ModelLessPath_RoutesToAuthorisedProvider(t *testing.T) {
|
|
route := ProviderRoute{
|
|
ID: "openai-prod",
|
|
Models: []string{"gpt-4o"},
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "api.openai.com",
|
|
}
|
|
mw := New(Config{Providers: []ProviderRoute{route}})
|
|
|
|
out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models?client_version=1"))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out)
|
|
assert.Equal(t, middleware.DecisionAllow, out.Decision, "GET /v1/models must pass through, not deny")
|
|
require.NotNil(t, out.Mutations, "a pass-through must rewrite the upstream")
|
|
require.NotNil(t, out.Mutations.RewriteUpstream, "model-less route must still rewrite to the real upstream")
|
|
assert.Equal(t, "api.openai.com", out.Mutations.RewriteUpstream.Host, "must target the authorised provider's host")
|
|
|
|
provider, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID)
|
|
assert.Equal(t, "openai-prod", provider, "resolved provider must be the authorised route")
|
|
|
|
// The limits gate reads this to tell "no model applies here" from
|
|
// "the model could not be determined", which fails closed.
|
|
nonInference, ok := metaValue(t, out.Metadata, middleware.KeyLLMNonInference)
|
|
require.True(t, ok, "model-less allow must mark the request non-inference")
|
|
assert.Equal(t, "true", nonInference)
|
|
}
|
|
|
|
func TestRouter_ModelLessPath_MultiProviderDeclarationOrder(t *testing.T) {
|
|
first := ProviderRoute{
|
|
ID: "openai-a",
|
|
Models: []string{"gpt-4o"},
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "a.example.com",
|
|
}
|
|
second := ProviderRoute{
|
|
ID: "openai-b",
|
|
Models: []string{"gpt-4o-mini"},
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "b.example.com",
|
|
}
|
|
mw := New(Config{Providers: []ProviderRoute{first, second}})
|
|
|
|
out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models"))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out)
|
|
assert.Equal(t, middleware.DecisionAllow, out.Decision, "model-less path must pass through with multiple providers")
|
|
provider, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID)
|
|
assert.Equal(t, "openai-a", provider, "no path-prefix match falls back to declaration order")
|
|
}
|
|
|
|
func TestRouter_ModelLessPath_UnauthorisedDenies(t *testing.T) {
|
|
mw := New(Config{Providers: []ProviderRoute{{
|
|
ID: "openai-prod",
|
|
Models: []string{"gpt-4o"},
|
|
AllowedGroupIDs: []string{"some-other-group"},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "api.openai.com",
|
|
}}})
|
|
|
|
out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models"))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out)
|
|
assert.Equal(t, middleware.DecisionDeny, out.Decision, "no provider authorising the caller must still deny")
|
|
}
|
|
|
|
func TestRouter_NonModelLessBodilessStillDenies(t *testing.T) {
|
|
mw := New(Config{Providers: []ProviderRoute{{
|
|
ID: "openai-prod",
|
|
Models: []string{"gpt-4o"},
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "api.openai.com",
|
|
}}})
|
|
|
|
// A bodiless POST to an inference path has no model and is NOT a
|
|
// model-less endpoint, so it must keep denying.
|
|
out, err := mw.Invoke(context.Background(), newModellessInput("/v1/responses"))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out)
|
|
assert.Equal(t, middleware.DecisionDeny, out.Decision, "bodiless inference request must still deny")
|
|
assert.Equal(t, "llm_policy.model_not_routable", out.DenyReason.Code, "deny code stays model_not_routable")
|
|
}
|
|
|
|
// TestRouter_ExplicitModelBeatsCatchallGateway is the regression guard
|
|
// for multi-provider misrouting: a catch-all (empty Models) OpenAI-compat
|
|
// gateway declared first must NOT swallow a model an explicit provider
|
|
// claims. Anthropic's claude request must reach the Anthropic route even
|
|
// though the gateway claims every model and wins declaration order.
|
|
func TestRouter_ExplicitModelBeatsCatchallGateway(t *testing.T) {
|
|
gateway := ProviderRoute{
|
|
ID: "openai-gateway",
|
|
Models: nil, // catch-all: claims every model
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "api.openai.com",
|
|
}
|
|
anthropic := ProviderRoute{
|
|
ID: "anthropic-prod",
|
|
Models: []string{"claude-opus-4"},
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "api.anthropic.com",
|
|
}
|
|
// Gateway declared first to prove explicit claim beats declaration order.
|
|
mw := New(Config{Providers: []ProviderRoute{gateway, anthropic}})
|
|
|
|
out, err := mw.Invoke(context.Background(), newInputWithModelAndURL("claude-opus-4", "/v1/messages"))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out)
|
|
assert.Equal(t, middleware.DecisionAllow, out.Decision, "explicit-model request must route, not deny")
|
|
require.NotNil(t, out.Mutations)
|
|
require.NotNil(t, out.Mutations.RewriteUpstream)
|
|
assert.Equal(t, "api.anthropic.com", out.Mutations.RewriteUpstream.Host, "claude must reach the explicit Anthropic route, not the catch-all gateway")
|
|
|
|
provider, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID)
|
|
assert.Equal(t, "anthropic-prod", provider, "resolved provider must be the explicit Anthropic route")
|
|
}
|
|
|
|
// TestRouter_CatchallStillServesUnlistedModel confirms the catch-all
|
|
// gateway still wins models no explicit provider claims (its whole point).
|
|
func TestRouter_CatchallStillServesUnlistedModel(t *testing.T) {
|
|
gateway := ProviderRoute{
|
|
ID: "openai-gateway",
|
|
Models: nil,
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "gateway.example.com",
|
|
}
|
|
anthropic := ProviderRoute{
|
|
ID: "anthropic-prod",
|
|
Models: []string{"claude-opus-4"},
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "api.anthropic.com",
|
|
}
|
|
mw := New(Config{Providers: []ProviderRoute{gateway, anthropic}})
|
|
|
|
out, err := mw.Invoke(context.Background(), newInputWithModelAndURL("some-exotic-model", "/v1/chat/completions"))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out)
|
|
assert.Equal(t, middleware.DecisionAllow, out.Decision, "unlisted model must still route via the catch-all")
|
|
require.NotNil(t, out.Mutations.RewriteUpstream)
|
|
assert.Equal(t, "gateway.example.com", out.Mutations.RewriteUpstream.Host, "unlisted model falls to the catch-all gateway")
|
|
}
|
|
|
|
// newInputVendorModelURL returns an Input carrying both the detected
|
|
// vendor (llm.provider) and the model, plus a request URL — mimicking the
|
|
// post-llm_request_parser state for a real inference call.
|
|
func newInputVendorModelURL(vendor, model, reqURL string) *middleware.Input {
|
|
return &middleware.Input{
|
|
Slot: middleware.SlotOnRequest,
|
|
URL: reqURL,
|
|
Metadata: []middleware.KV{
|
|
{Key: middleware.KeyLLMProvider, Value: vendor},
|
|
{Key: middleware.KeyLLMModel, Value: model},
|
|
},
|
|
UserGroups: []string{defaultTestGroup},
|
|
}
|
|
}
|
|
|
|
// TestRouter_VendorKeepsAnthropicOffOpenAIGateway is the regression guard
|
|
// for the reported multi-provider break: two catch-all providers (neither
|
|
// enumerates models), the OpenAI one declared first. Without vendor
|
|
// awareness, a claude request matches both, no path prefixes, and
|
|
// declaration order sends it to OpenAI → 502. The detected vendor must
|
|
// pin it to the Anthropic route.
|
|
func TestRouter_VendorKeepsAnthropicOffOpenAIGateway(t *testing.T) {
|
|
openai := ProviderRoute{
|
|
ID: "openai-gw",
|
|
Vendor: "openai",
|
|
Models: nil, // catch-all
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "api.openai.com",
|
|
}
|
|
anthropic := ProviderRoute{
|
|
ID: "anthropic-gw",
|
|
Vendor: "anthropic",
|
|
Models: nil, // catch-all
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "api.anthropic.com",
|
|
}
|
|
mw := New(Config{Providers: []ProviderRoute{openai, anthropic}}) // openai first
|
|
|
|
out, err := mw.Invoke(context.Background(), newInputVendorModelURL("anthropic", "claude-opus-4-8", "/v1/messages"))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out)
|
|
assert.Equal(t, middleware.DecisionAllow, out.Decision, "claude request must route, not deny")
|
|
require.NotNil(t, out.Mutations.RewriteUpstream)
|
|
assert.Equal(t, "api.anthropic.com", out.Mutations.RewriteUpstream.Host, "anthropic vendor must pin to the anthropic route despite openai being declared first")
|
|
|
|
provider, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID)
|
|
assert.Equal(t, "anthropic-gw", provider)
|
|
}
|
|
|
|
// TestRouter_VendorKeepsOpenAIOffAnthropic is the reciprocal: an OpenAI
|
|
// request must stay on the OpenAI route even when the Anthropic catch-all
|
|
// is declared first.
|
|
func TestRouter_VendorKeepsOpenAIOffAnthropic(t *testing.T) {
|
|
anthropic := ProviderRoute{
|
|
ID: "anthropic-gw",
|
|
Vendor: "anthropic",
|
|
Models: nil,
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "api.anthropic.com",
|
|
}
|
|
openai := ProviderRoute{
|
|
ID: "openai-gw",
|
|
Vendor: "openai",
|
|
Models: nil,
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "api.openai.com",
|
|
}
|
|
mw := New(Config{Providers: []ProviderRoute{anthropic, openai}}) // anthropic first
|
|
|
|
out, err := mw.Invoke(context.Background(), newInputVendorModelURL("openai", "gpt-5.5", "/v1/responses"))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out)
|
|
require.NotNil(t, out.Mutations.RewriteUpstream)
|
|
assert.Equal(t, "api.openai.com", out.Mutations.RewriteUpstream.Host, "openai vendor must pin to the openai route despite anthropic being declared first")
|
|
}
|
|
|
|
func TestRouter_MultiVendorGatewayAcceptsBothSurfaces(t *testing.T) {
|
|
gateway := ProviderRoute{
|
|
ID: "agentgateway",
|
|
Vendors: []string{"openai", "anthropic"},
|
|
Models: nil,
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "gateway.example.com",
|
|
}
|
|
other := ProviderRoute{
|
|
ID: "other-vendor",
|
|
Vendor: "mistral",
|
|
Models: nil,
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "mistral.example.com",
|
|
}
|
|
mw := New(Config{Providers: []ProviderRoute{other, gateway}})
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
vendor string
|
|
model string
|
|
path string
|
|
}{
|
|
{name: "OpenAI", vendor: "openai", model: "gpt-4o-mini", path: "/v1/chat/completions"},
|
|
{name: "Anthropic", vendor: "anthropic", model: "claude-sonnet-4-5", path: "/v1/messages"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
out, err := mw.Invoke(context.Background(), newInputVendorModelURL(tc.vendor, tc.model, tc.path))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out)
|
|
assert.Equal(t, middleware.DecisionAllow, out.Decision,
|
|
"supported vendor must route through the multi-surface gateway")
|
|
require.NotNil(t, out.Mutations)
|
|
require.NotNil(t, out.Mutations.RewriteUpstream)
|
|
assert.Equal(t, "gateway.example.com", out.Mutations.RewriteUpstream.Host)
|
|
|
|
provider, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID)
|
|
assert.Equal(t, "agentgateway", provider)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRouter_VendorAbsentFallsBackToModelPath confirms vendor filtering is
|
|
// inert when the request carries no detected vendor: routing then relies on
|
|
// model/path as before.
|
|
func TestRouter_VendorAbsentFallsBackToModelPath(t *testing.T) {
|
|
openai := ProviderRoute{
|
|
ID: "openai-gw",
|
|
Vendor: "openai",
|
|
Models: []string{"gpt-5.5"},
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "api.openai.com",
|
|
}
|
|
mw := New(Config{Providers: []ProviderRoute{openai}})
|
|
|
|
// No llm.provider in metadata — only the model.
|
|
out, err := mw.Invoke(context.Background(), newInputWithModel("gpt-5.5"))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out)
|
|
assert.Equal(t, middleware.DecisionAllow, out.Decision, "explicit-model match must still route with no vendor present")
|
|
}
|
|
|
|
func TestRouter_UnknownModel(t *testing.T) {
|
|
mw := New(Config{Providers: []ProviderRoute{{
|
|
ID: "openai-prod",
|
|
Models: []string{"gpt-4o"},
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "api.openai.com",
|
|
}}})
|
|
|
|
out, err := mw.Invoke(context.Background(), newInputWithModel("claude-opus-4"))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out)
|
|
assert.Equal(t, middleware.DecisionDeny, out.Decision, "unrouted model must deny")
|
|
assert.Equal(t, 403, out.DenyStatus, "deny status must be 403")
|
|
require.NotNil(t, out.DenyReason, "deny reason must be populated")
|
|
assert.Equal(t, "llm_policy.model_not_routable", out.DenyReason.Code, "deny code must be model_not_routable")
|
|
assert.Equal(t, "no provider configured for model claude-opus-4", out.DenyReason.Message, "deny message must reference the offending model")
|
|
assert.Equal(t, "claude-opus-4", out.DenyReason.Details["model"], "deny details must include the offending model")
|
|
}
|
|
|
|
func TestRouter_HeaderStripList(t *testing.T) {
|
|
mw := New(Config{Providers: []ProviderRoute{{
|
|
ID: "openai-prod",
|
|
Models: []string{"gpt-4o"},
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "api.openai.com",
|
|
AuthHeaderName: "Authorization",
|
|
AuthHeaderValue: "Bearer sk-test-123",
|
|
}}})
|
|
|
|
out, err := mw.Invoke(context.Background(), newInputWithModel("gpt-4o"))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out)
|
|
require.NotNil(t, out.Mutations, "matched route must emit mutations")
|
|
|
|
expected := []string{
|
|
"Authorization",
|
|
"Proxy-Authorization",
|
|
"x-api-key",
|
|
"api-key",
|
|
}
|
|
require.NotNil(t, out.Mutations.RewriteUpstream, "matched route must emit upstream rewrite")
|
|
for _, header := range expected {
|
|
assert.Contains(t, out.Mutations.RewriteUpstream.StripHeaders, header,
|
|
"strip list (on UpstreamRewrite) must include the well-known vendor auth header %s", header)
|
|
}
|
|
|
|
// Vendor metadata headers MUST NOT be stripped: the client SDK sets them
|
|
// and the upstream requires them. Anthropic returns 400 "anthropic-version:
|
|
// header is required" if we drop it. Lock the regression.
|
|
preserved := []string{"anthropic-version", "openai-organization", "openai-project"}
|
|
for _, header := range preserved {
|
|
assert.NotContains(t, out.Mutations.RewriteUpstream.StripHeaders, header,
|
|
"vendor metadata header %s must NOT be stripped — upstreams require it", header)
|
|
}
|
|
}
|
|
|
|
func TestRouter_FirstMatchWins(t *testing.T) {
|
|
first := ProviderRoute{
|
|
ID: "first",
|
|
Models: []string{"gpt-4o"},
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "first.test",
|
|
AuthHeaderName: "Authorization",
|
|
AuthHeaderValue: "Bearer first",
|
|
}
|
|
second := ProviderRoute{
|
|
ID: "second",
|
|
Models: []string{"gpt-4o"},
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "second.test",
|
|
AuthHeaderName: "Authorization",
|
|
AuthHeaderValue: "Bearer second",
|
|
}
|
|
mw := New(Config{Providers: []ProviderRoute{first, second}})
|
|
|
|
out, err := mw.Invoke(context.Background(), newInputWithModel("gpt-4o"))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out)
|
|
assert.Equal(t, middleware.DecisionAllow, out.Decision, "duplicate-model match must still allow")
|
|
require.NotNil(t, out.Mutations, "matched route must emit mutations")
|
|
require.NotNil(t, out.Mutations.RewriteUpstream, "matched route must emit upstream rewrite")
|
|
assert.Equal(t, "first.test", out.Mutations.RewriteUpstream.Host, "first-match-wins must pick the earlier route")
|
|
|
|
resolved, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID)
|
|
assert.Equal(t, "first", resolved, "resolved provider id must be the earlier route's ID")
|
|
}
|
|
|
|
// TestRouter_PathDisambiguation_PrefixWinsOverCatchall locks in the
|
|
// rule the user nailed down: two providers claim the same model, one
|
|
// has an UpstreamPath that prefixes the incoming URL, the other has
|
|
// no path. The path-prefixed provider wins because the path is a
|
|
// strictly more specific match than the empty catchall.
|
|
func TestRouter_PathDisambiguation_PrefixWinsOverCatchall(t *testing.T) {
|
|
corp := ProviderRoute{
|
|
ID: "corp-openai-compat",
|
|
Models: []string{"gpt-5"},
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "corp.example.com",
|
|
UpstreamPath: "/openai",
|
|
AuthHeaderName: "Authorization",
|
|
AuthHeaderValue: "Bearer corp",
|
|
}
|
|
openai := ProviderRoute{
|
|
ID: "openai",
|
|
Models: []string{"gpt-5"},
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "api.openai.com",
|
|
AuthHeaderName: "Authorization",
|
|
AuthHeaderValue: "Bearer openai",
|
|
}
|
|
mw := New(Config{Providers: []ProviderRoute{openai, corp}}) // openai listed first to prove path beats declaration order
|
|
|
|
out, err := mw.Invoke(context.Background(), newInputWithModelAndURL("gpt-5", "/openai/v1/chat/completions"))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out)
|
|
require.Equal(t, middleware.DecisionAllow, out.Decision, "path-prefix match must allow")
|
|
require.NotNil(t, out.Mutations.RewriteUpstream)
|
|
assert.Equal(t, "corp.example.com", out.Mutations.RewriteUpstream.Host,
|
|
"path-prefixed provider must beat the catchall when its UpstreamPath is a prefix of the request path")
|
|
resolved, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID)
|
|
assert.Equal(t, "corp-openai-compat", resolved, "resolved provider id must reflect the path-prefix winner, not the first declared")
|
|
}
|
|
|
|
// TestRouter_PathDisambiguation_CatchallWhenNoPrefixMatches is the
|
|
// inverse: the path-prefixed provider does NOT match the incoming
|
|
// path, so the empty-path catchall takes the request.
|
|
func TestRouter_PathDisambiguation_CatchallWhenNoPrefixMatches(t *testing.T) {
|
|
corp := ProviderRoute{
|
|
ID: "corp-openai-compat",
|
|
Models: []string{"gpt-5"},
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "corp.example.com",
|
|
UpstreamPath: "/openai",
|
|
AuthHeaderName: "Authorization",
|
|
AuthHeaderValue: "Bearer corp",
|
|
}
|
|
openai := ProviderRoute{
|
|
ID: "openai",
|
|
Models: []string{"gpt-5"},
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "api.openai.com",
|
|
AuthHeaderName: "Authorization",
|
|
AuthHeaderValue: "Bearer openai",
|
|
}
|
|
mw := New(Config{Providers: []ProviderRoute{corp, openai}})
|
|
|
|
out, err := mw.Invoke(context.Background(), newInputWithModelAndURL("gpt-5", "/v1/chat/completions"))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out)
|
|
require.Equal(t, middleware.DecisionAllow, out.Decision, "catchall must allow when no path prefix matches")
|
|
require.NotNil(t, out.Mutations.RewriteUpstream)
|
|
assert.Equal(t, "api.openai.com", out.Mutations.RewriteUpstream.Host,
|
|
"empty-path catchall must win when the path-prefixed provider's UpstreamPath does not match the request")
|
|
resolved, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID)
|
|
assert.Equal(t, "openai", resolved, "resolved provider id must be the catchall")
|
|
}
|
|
|
|
// TestRouter_PathDisambiguation_LongestPrefixWins covers the case
|
|
// where multiple providers have non-empty UpstreamPath values that
|
|
// both prefix the request — the longer (more specific) one wins.
|
|
func TestRouter_PathDisambiguation_LongestPrefixWins(t *testing.T) {
|
|
short := ProviderRoute{
|
|
ID: "short-prefix",
|
|
Models: []string{"gpt-5"},
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "short.example.com",
|
|
UpstreamPath: "/openai",
|
|
}
|
|
long := ProviderRoute{
|
|
ID: "long-prefix",
|
|
Models: []string{"gpt-5"},
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "long.example.com",
|
|
UpstreamPath: "/openai/v1",
|
|
}
|
|
mw := New(Config{Providers: []ProviderRoute{short, long}})
|
|
|
|
out, err := mw.Invoke(context.Background(), newInputWithModelAndURL("gpt-5", "/openai/v1/chat/completions"))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out)
|
|
require.NotNil(t, out.Mutations.RewriteUpstream)
|
|
assert.Equal(t, "long.example.com", out.Mutations.RewriteUpstream.Host,
|
|
"longest matching UpstreamPath must win — most specific match")
|
|
}
|
|
|
|
// TestRouter_SingleMatchIgnoresPath proves the path-prefix rule is a
|
|
// disambiguation pass, not a gate: when only one provider claims the
|
|
// model, it wins regardless of UpstreamPath. Otherwise a path-scoped
|
|
// provider would 403 every request whose URL doesn't include the
|
|
// path, which would break SDKs configured to hit the gateway root.
|
|
func TestRouter_SingleMatchIgnoresPath(t *testing.T) {
|
|
only := ProviderRoute{
|
|
ID: "only",
|
|
Models: []string{"gpt-5"},
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "only.example.com",
|
|
UpstreamPath: "/openai",
|
|
AuthHeaderName: "Authorization",
|
|
AuthHeaderValue: "Bearer only",
|
|
}
|
|
mw := New(Config{Providers: []ProviderRoute{only}})
|
|
|
|
out, err := mw.Invoke(context.Background(), newInputWithModelAndURL("gpt-5", "/v1/chat/completions"))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out)
|
|
assert.Equal(t, middleware.DecisionAllow, out.Decision,
|
|
"single model-matching provider must serve the request even when UpstreamPath doesn't prefix the URL — path is a tiebreaker, not a gate")
|
|
require.NotNil(t, out.Mutations.RewriteUpstream)
|
|
assert.Equal(t, "only.example.com", out.Mutations.RewriteUpstream.Host, "the only model-matching provider should be selected")
|
|
}
|
|
|
|
// TestRouter_PathDisambiguation_FallbackWhenNoPrefixMatches covers
|
|
// the multi-candidate edge case where every candidate has a
|
|
// non-matching non-empty UpstreamPath. The router falls back to
|
|
// declaration order so the model is still routable rather than 403'd.
|
|
func TestRouter_PathDisambiguation_FallbackWhenNoPrefixMatches(t *testing.T) {
|
|
first := ProviderRoute{
|
|
ID: "first",
|
|
Models: []string{"gpt-5"},
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "first.example.com",
|
|
UpstreamPath: "/openai",
|
|
}
|
|
second := ProviderRoute{
|
|
ID: "second",
|
|
Models: []string{"gpt-5"},
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "second.example.com",
|
|
UpstreamPath: "/anthropic",
|
|
}
|
|
mw := New(Config{Providers: []ProviderRoute{first, second}})
|
|
|
|
out, err := mw.Invoke(context.Background(), newInputWithModelAndURL("gpt-5", "/v1/chat/completions"))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out)
|
|
assert.Equal(t, middleware.DecisionAllow, out.Decision, "no path match among multi-candidates must still allow")
|
|
require.NotNil(t, out.Mutations.RewriteUpstream)
|
|
assert.Equal(t, "first.example.com", out.Mutations.RewriteUpstream.Host,
|
|
"when no candidate's UpstreamPath prefix-matches the request, fall back to declaration order")
|
|
}
|
|
|
|
func TestRouter_FactoryRejectsBadJSON(t *testing.T) {
|
|
_, err := Factory{}.New([]byte("{not json"))
|
|
require.Error(t, err, "malformed JSON config must be rejected at chain build time")
|
|
}
|
|
|
|
func TestRouter_FactoryDecodesLegacyAndMultiVendorFields(t *testing.T) {
|
|
raw := []byte(`{"providers":[` +
|
|
`{"id":"legacy","vendor":"openai","models":[],"upstream_scheme":"https","upstream_host":"openai.example.com","auth_header_name":"Authorization","auth_header_value":"Bearer legacy","allowed_group_ids":["group"]},` +
|
|
`{"id":"multi","vendors":["openai","anthropic"],"models":[],"upstream_scheme":"https","upstream_host":"gateway.example.com","auth_header_name":"Authorization","auth_header_value":"Bearer multi","allowed_group_ids":["group"]}` +
|
|
`]}`)
|
|
|
|
resolved, err := Factory{}.New(raw)
|
|
require.NoError(t, err)
|
|
router, ok := resolved.(*Middleware)
|
|
require.True(t, ok, "factory must return the concrete router middleware")
|
|
require.Len(t, router.cfg.Providers, 2)
|
|
assert.Equal(t, "openai", router.cfg.Providers[0].Vendor,
|
|
"the legacy singular field must keep decoding")
|
|
assert.Equal(t, []string{"openai", "anthropic"}, router.cfg.Providers[1].Vendors,
|
|
"the multi-vendor field must decode both supported surfaces")
|
|
}
|
|
|
|
func TestRouter_FactoryAcceptsEmptyShapes(t *testing.T) {
|
|
cases := [][]byte{nil, []byte(""), []byte(" "), []byte("null"), []byte("{}"), []byte("[]")}
|
|
for _, raw := range cases {
|
|
mw, err := Factory{}.New(raw)
|
|
require.NoError(t, err, "empty-shaped config must yield a router with an empty Providers slice")
|
|
require.NotNil(t, mw, "factory must return a non-nil middleware on empty config")
|
|
|
|
out, invErr := mw.Invoke(context.Background(), newInputWithModel("gpt-4o"))
|
|
require.NoError(t, invErr)
|
|
assert.Equal(t, middleware.DecisionDeny, out.Decision,
|
|
"router with no providers must deny every model as not-routable")
|
|
}
|
|
}
|
|
|
|
// newInputWithModelAndGroups returns an Input carrying llm.model + the
|
|
// caller's UserGroups, mimicking the post-auth, post-llm_request_parser
|
|
// state the router observes.
|
|
func newInputWithModelAndGroups(model string, groups []string) *middleware.Input {
|
|
in := newInputWithModel(model)
|
|
in.UserGroups = append([]string(nil), groups...)
|
|
return in
|
|
}
|
|
|
|
// TestRouter_GroupFilter_PicksAuthorisedAmongDuplicates pins the Fix A
|
|
// behaviour: when two providers claim the same model but each
|
|
// authorises a different group, the router must pick the route the
|
|
// caller's groups intersect, regardless of declaration order.
|
|
func TestRouter_GroupFilter_PicksAuthorisedAmongDuplicates(t *testing.T) {
|
|
mw := New(Config{Providers: []ProviderRoute{
|
|
{
|
|
ID: "openai-marketing",
|
|
Models: []string{"gpt-4o-mini"},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "mkt-openai.example.com",
|
|
AllowedGroupIDs: []string{"grp-mkt"},
|
|
},
|
|
{
|
|
ID: "openai-engineering",
|
|
Models: []string{"gpt-4o-mini"},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "eng-openai.example.com",
|
|
AllowedGroupIDs: []string{"grp-eng"},
|
|
},
|
|
}})
|
|
|
|
out, err := mw.Invoke(context.Background(),
|
|
newInputWithModelAndGroups("gpt-4o-mini", []string{"grp-eng"}))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out)
|
|
assert.Equal(t, middleware.DecisionAllow, out.Decision,
|
|
"authorised candidate exists; must allow")
|
|
|
|
resolved, ok := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID)
|
|
require.True(t, ok)
|
|
assert.Equal(t, "openai-engineering", resolved,
|
|
"router must pick the route whose AllowedGroupIDs intersects the caller's groups, ignoring declaration order")
|
|
}
|
|
|
|
// TestRouter_GroupFilter_NoIntersection_DeniesNoAuthorisedRoute pins
|
|
// the dedicated deny code that fires when the model is known to a
|
|
// provider but no candidate is authorised for the caller's groups.
|
|
func TestRouter_GroupFilter_NoIntersection_DeniesNoAuthorisedRoute(t *testing.T) {
|
|
mw := New(Config{Providers: []ProviderRoute{{
|
|
ID: "openai-marketing",
|
|
Models: []string{"gpt-4o-mini"},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "mkt-openai.example.com",
|
|
AllowedGroupIDs: []string{"grp-mkt"},
|
|
}}})
|
|
|
|
out, err := mw.Invoke(context.Background(),
|
|
newInputWithModelAndGroups("gpt-4o-mini", []string{"grp-eng"}))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out)
|
|
assert.Equal(t, middleware.DecisionDeny, out.Decision,
|
|
"model exists but no route authorises grp-eng; must deny")
|
|
require.NotNil(t, out.DenyReason)
|
|
assert.Equal(t, "llm_policy.no_authorised_provider", out.DenyReason.Code,
|
|
"deny code must be no_authorised_provider, not model_not_routable")
|
|
assert.Equal(t, "gpt-4o-mini", out.DenyReason.Details["model"],
|
|
"deny details must reference the offending model")
|
|
|
|
dec, _ := metaValue(t, out.Metadata, middleware.KeyLLMPolicyDecision)
|
|
assert.Equal(t, "deny", dec)
|
|
reason, _ := metaValue(t, out.Metadata, middleware.KeyLLMPolicyReason)
|
|
assert.Equal(t, "no_authorised_provider", reason)
|
|
}
|
|
|
|
// TestRouter_GroupFilter_EmptyAllowedGroupsIsUnreachable pins the
|
|
// strict semantics: a route with no AllowedGroupIDs is unreachable.
|
|
// The synthesiser only emits policy-bound routes, so an empty ACL
|
|
// signals a misconfiguration that must not silently fall through.
|
|
func TestRouter_GroupFilter_EmptyAllowedGroupsIsUnreachable(t *testing.T) {
|
|
mw := New(Config{Providers: []ProviderRoute{{
|
|
ID: "openai-shared",
|
|
Models: []string{"gpt-4o"},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "api.openai.com",
|
|
// AllowedGroupIDs intentionally left empty.
|
|
}}})
|
|
|
|
out, err := mw.Invoke(context.Background(), newInputWithModel("gpt-4o"))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out)
|
|
assert.Equal(t, middleware.DecisionDeny, out.Decision,
|
|
"empty AllowedGroupIDs must deny — there is no catch-all for routes without an authorising policy")
|
|
require.NotNil(t, out.DenyReason)
|
|
assert.Equal(t, "llm_policy.no_authorised_provider", out.DenyReason.Code,
|
|
"empty ACL fails the group-filter pass; deny code must reflect that")
|
|
}
|
|
|
|
// TestRouter_GroupFilter_OverlapTiebreakUnchanged pins that when more
|
|
// than one route is authorised for the caller's groups, the existing
|
|
// path-prefix tiebreak still decides. Group filtering is a hard gate
|
|
// before the tiebreak; it does not change the tiebreak semantics.
|
|
func TestRouter_GroupFilter_OverlapTiebreakUnchanged(t *testing.T) {
|
|
mw := New(Config{Providers: []ProviderRoute{
|
|
{
|
|
ID: "openai-a",
|
|
Models: []string{"gpt-4o-mini"},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "a.example.com",
|
|
UpstreamPath: "",
|
|
AllowedGroupIDs: []string{"grp-eng"},
|
|
},
|
|
{
|
|
ID: "openai-b",
|
|
Models: []string{"gpt-4o-mini"},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "b.example.com",
|
|
UpstreamPath: "/v1/chat",
|
|
AllowedGroupIDs: []string{"grp-eng"},
|
|
},
|
|
}})
|
|
|
|
in := newInputWithModelAndURL("gpt-4o-mini", "/v1/chat/completions")
|
|
in.UserGroups = []string{"grp-eng"}
|
|
out, err := mw.Invoke(context.Background(), in)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out)
|
|
assert.Equal(t, middleware.DecisionAllow, out.Decision)
|
|
|
|
resolved, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID)
|
|
assert.Equal(t, "openai-b", resolved,
|
|
"longest-prefix path tiebreak still wins among group-authorised candidates")
|
|
}
|
|
|
|
// TestRouter_AuthorisingGroups_EmitsIntersection pins that the router
|
|
// emits llm.authorising_groups containing only the intersection of the
|
|
// caller's UserGroups with the resolved route's AllowedGroupIDs — not
|
|
// every group the peer happens to be in.
|
|
func TestRouter_AuthorisingGroups_EmitsIntersection(t *testing.T) {
|
|
mw := New(Config{Providers: []ProviderRoute{{
|
|
ID: "openai-eng",
|
|
Models: []string{"gpt-4o-mini"},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "eng-openai.example.com",
|
|
AllowedGroupIDs: []string{"grp-eng", "grp-shared"},
|
|
}}})
|
|
|
|
in := newInputWithModelAndGroups("gpt-4o-mini",
|
|
[]string{"grp-eng", "grp-it", "grp-shared", "grp-oncall"})
|
|
|
|
out, err := mw.Invoke(context.Background(), in)
|
|
require.NoError(t, err)
|
|
require.Equal(t, middleware.DecisionAllow, out.Decision)
|
|
|
|
csv, ok := metaValue(t, out.Metadata, middleware.KeyLLMAuthorisingGroups)
|
|
require.True(t, ok, "router must emit llm.authorising_groups on a match")
|
|
assert.Equal(t, "grp-eng,grp-shared", csv,
|
|
"only groups in BOTH UserGroups AND AllowedGroupIDs may appear; result must be sorted and unique")
|
|
}
|
|
|
|
// TestRouter_EmptyModelsClaimsAnyModel pins that a route with no
|
|
// configured Models matches every model — used by gateway-style
|
|
// providers (LiteLLM, custom OpenAI-compatible endpoints) where the
|
|
// operator can't enumerate the upstream's model catalog in NetBird.
|
|
func TestRouter_EmptyModelsClaimsAnyModel(t *testing.T) {
|
|
mw := New(Config{Providers: []ProviderRoute{{
|
|
ID: "litellm",
|
|
Models: nil, // catch-all
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "litellm.example.com",
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
}}})
|
|
|
|
out, err := mw.Invoke(context.Background(), newInputWithModel("gpt-5.5"))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out)
|
|
assert.Equal(t, middleware.DecisionAllow, out.Decision,
|
|
"a route with empty Models must claim any model so gateway-style providers can route open-ended sets")
|
|
resolved, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID)
|
|
assert.Equal(t, "litellm", resolved)
|
|
}
|
|
|
|
// TestRouter_DatedAnthropicModelRoutes covers a client pinning a release
|
|
// date on a model the operator registered undated. Exact matches still win,
|
|
// so an operator who registers both dated releases keeps them distinct.
|
|
func TestRouter_DatedAnthropicModelRoutes(t *testing.T) {
|
|
mw := New(Config{Providers: []ProviderRoute{{
|
|
ID: "anthropic-prod",
|
|
Vendor: "anthropic",
|
|
Models: []string{"claude-sonnet-4-5"},
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "api.anthropic.com",
|
|
}}})
|
|
|
|
in := newInputWithModelAndURL("claude-sonnet-4-5-20250929", "/v1/messages")
|
|
in.Metadata = append(in.Metadata, middleware.KV{Key: middleware.KeyLLMProvider, Value: "anthropic"})
|
|
|
|
out, err := mw.Invoke(context.Background(), in)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out)
|
|
assert.Equal(t, middleware.DecisionAllow, out.Decision, "a dated id must route to the undated registration")
|
|
require.NotNil(t, out.Mutations)
|
|
require.NotNil(t, out.Mutations.RewriteUpstream)
|
|
assert.Equal(t, "api.anthropic.com", out.Mutations.RewriteUpstream.Host)
|
|
}
|
|
|
|
// TestRouter_ConnectionWarmProbeRoutes covers the HEAD /api/hello probe an
|
|
// Anthropic client sends before its first request. Forwarding it warms the
|
|
// connection that request will use; denying it only wrote a rejection into
|
|
// the access log at every session start.
|
|
func TestRouter_ConnectionWarmProbeRoutes(t *testing.T) {
|
|
mw := New(Config{Providers: []ProviderRoute{{
|
|
ID: "anthropic-prod",
|
|
Vendor: "anthropic",
|
|
Models: []string{"claude-sonnet-5"},
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "api.anthropic.com",
|
|
}}})
|
|
|
|
in := newModellessInput("/api/hello")
|
|
in.Method = http.MethodHead
|
|
|
|
out, err := mw.Invoke(context.Background(), in)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out)
|
|
assert.Equal(t, middleware.DecisionAllow, out.Decision, "the warm-up probe must reach the upstream")
|
|
require.NotNil(t, out.Mutations)
|
|
require.NotNil(t, out.Mutations.RewriteUpstream)
|
|
assert.Equal(t, "api.anthropic.com", out.Mutations.RewriteUpstream.Host)
|
|
|
|
nonInference, _ := metaValue(t, out.Metadata, middleware.KeyLLMNonInference)
|
|
assert.Equal(t, "true", nonInference, "the probe carries no model to gate on")
|
|
}
|
|
|
|
// TestRouter_ModelListingCarriesAuthorisedModels pins the list the proxy
|
|
// bounds the discovery response with. A catch-all route enumerates nothing,
|
|
// so it must not bound the upstream's list at all.
|
|
func TestRouter_ModelListingCarriesAuthorisedModels(t *testing.T) {
|
|
enumerated := ProviderRoute{
|
|
ID: "anthropic-prod",
|
|
Models: []string{"claude-sonnet-5", "claude-haiku-4-5"},
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "api.anthropic.com",
|
|
}
|
|
|
|
t.Run("enumerated route bounds the listing", func(t *testing.T) {
|
|
mw := New(Config{Providers: []ProviderRoute{enumerated}})
|
|
out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models"))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out.Mutations)
|
|
require.NotNil(t, out.Mutations.RewriteUpstream)
|
|
assert.Equal(t, []string{"claude-sonnet-5", "claude-haiku-4-5"},
|
|
out.Mutations.RewriteUpstream.DiscoveryModels,
|
|
"the picker must be bounded by what the route authorises")
|
|
})
|
|
|
|
t.Run("catch-all route leaves the listing alone", func(t *testing.T) {
|
|
catchAll := enumerated
|
|
catchAll.Models = nil
|
|
mw := New(Config{Providers: []ProviderRoute{catchAll}})
|
|
|
|
out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models"))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out.Mutations.RewriteUpstream)
|
|
assert.Empty(t, out.Mutations.RewriteUpstream.DiscoveryModels,
|
|
"a route that claims every model cannot bound the upstream's list")
|
|
})
|
|
|
|
t.Run("per-model lookup is not a listing", func(t *testing.T) {
|
|
mw := New(Config{Providers: []ProviderRoute{enumerated}})
|
|
out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models/claude-sonnet-5"))
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out.Mutations.RewriteUpstream)
|
|
assert.Empty(t, out.Mutations.RewriteUpstream.DiscoveryModels,
|
|
"the single-object lookup has no data array to filter")
|
|
})
|
|
}
|
|
|
|
// TestRouter_ModelDetailHonoursAllowlist pins that GET /v1/models/{id} is
|
|
// authorised against the model table. It carries no body model, so treating
|
|
// it as a model-less endpoint would let a caller confirm a model the route
|
|
// does not list — the listing itself is bounded to the allowlist, so the
|
|
// detail lookup must be too.
|
|
func TestRouter_ModelDetailHonoursAllowlist(t *testing.T) {
|
|
enumerated := ProviderRoute{
|
|
ID: "anthropic-prod",
|
|
Models: []string{"claude-sonnet-5"},
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "api.anthropic.com",
|
|
}
|
|
|
|
t.Run("allowlisted model routes and skips metering", func(t *testing.T) {
|
|
mw := New(Config{Providers: []ProviderRoute{enumerated}})
|
|
out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models/claude-sonnet-5"))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, middleware.DecisionAllow, out.Decision)
|
|
require.NotNil(t, out.Mutations)
|
|
require.NotNil(t, out.Mutations.RewriteUpstream)
|
|
assert.Equal(t, "api.anthropic.com", out.Mutations.RewriteUpstream.Host)
|
|
|
|
nonInference, _ := metaValue(t, out.Metadata, middleware.KeyLLMNonInference)
|
|
assert.Equal(t, "true", nonInference, "a detail lookup spends no tokens")
|
|
})
|
|
|
|
t.Run("model outside the allowlist denies", func(t *testing.T) {
|
|
mw := New(Config{Providers: []ProviderRoute{enumerated}})
|
|
out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models/claude-opus-5"))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, middleware.DecisionDeny, out.Decision,
|
|
"a model no route lists must not be confirmed by the detail lookup")
|
|
})
|
|
|
|
t.Run("dated id matches its undated registration", func(t *testing.T) {
|
|
mw := New(Config{Providers: []ProviderRoute{enumerated}})
|
|
out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models/claude-sonnet-5-20250929"))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, middleware.DecisionAllow, out.Decision,
|
|
"a pinned release of an allowlisted family stays reachable")
|
|
})
|
|
|
|
t.Run("catch-all route still answers every lookup", func(t *testing.T) {
|
|
catchAll := enumerated
|
|
catchAll.Models = nil
|
|
mw := New(Config{Providers: []ProviderRoute{catchAll}})
|
|
|
|
out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models/anything-at-all"))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, middleware.DecisionAllow, out.Decision,
|
|
"a gateway that enumerates nothing cannot refuse a lookup")
|
|
})
|
|
}
|
|
|
|
// TestRouter_NonInferenceRequiresReadMethod pins that the non-inference mark —
|
|
// which exempts a request from the token pre-flight — is reachable only by the
|
|
// read methods these endpoints actually use. A POST to the same path could
|
|
// carry an inference body, so it must not buy the exemption; it falls through
|
|
// to normal per-model routing instead, which denies when no model is named.
|
|
func TestRouter_NonInferenceRequiresReadMethod(t *testing.T) {
|
|
route := ProviderRoute{
|
|
ID: "gateway",
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "gateway.example.com",
|
|
}
|
|
|
|
for _, path := range []string{"/v1/models", "/v1/models/claude-sonnet-5", "/api/hello"} {
|
|
t.Run("POST "+path, func(t *testing.T) {
|
|
mw := New(Config{Providers: []ProviderRoute{route}})
|
|
|
|
in := newModellessInput(path)
|
|
in.Method = http.MethodPost
|
|
|
|
out, err := mw.Invoke(context.Background(), in)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, middleware.DecisionDeny, out.Decision,
|
|
"a write to a non-inference path must not route unmetered")
|
|
|
|
nonInference, _ := metaValue(t, out.Metadata, middleware.KeyLLMNonInference)
|
|
assert.NotEqual(t, "true", nonInference,
|
|
"only a read method may skip the token pre-flight")
|
|
})
|
|
}
|
|
|
|
t.Run("HEAD keeps the warm probe working", func(t *testing.T) {
|
|
mw := New(Config{Providers: []ProviderRoute{route}})
|
|
|
|
in := newModellessInput(connectionWarmPath)
|
|
in.Method = http.MethodHead
|
|
|
|
out, err := mw.Invoke(context.Background(), in)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, middleware.DecisionAllow, out.Decision,
|
|
"the HEAD warm probe must still reach the upstream")
|
|
|
|
nonInference, _ := metaValue(t, out.Metadata, middleware.KeyLLMNonInference)
|
|
assert.Equal(t, "true", nonInference,
|
|
"the HEAD warm probe carries no model to meter")
|
|
})
|
|
}
|
|
|
|
// TestRouter_PinnedDatedModelStaysDistinct pins that a route registered
|
|
// against one dated Anthropic release does not claim another. Normalising
|
|
// both sides of the comparison made every dated build of a family
|
|
// interchangeable, so an operator who deliberately pinned a build would have
|
|
// served a different one — and with several such routes, declaration or path
|
|
// order would have decided which.
|
|
func TestRouter_PinnedDatedModelStaysDistinct(t *testing.T) {
|
|
pinned := ProviderRoute{
|
|
ID: "anthropic-pinned",
|
|
Vendor: "anthropic",
|
|
Models: []string{"claude-sonnet-4-5-20250101"},
|
|
AllowedGroupIDs: []string{defaultTestGroup},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "pinned.example.com",
|
|
}
|
|
|
|
t.Run("a different dated release is not claimed", func(t *testing.T) {
|
|
mw := New(Config{Providers: []ProviderRoute{pinned}})
|
|
in := newInputWithModelAndURL("claude-sonnet-4-5-20250202", "/v1/messages")
|
|
in.Metadata = append(in.Metadata, middleware.KV{Key: middleware.KeyLLMProvider, Value: "anthropic"})
|
|
|
|
out, err := mw.Invoke(context.Background(), in)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, middleware.DecisionDeny, out.Decision,
|
|
"a route pinned to one dated build must not serve another")
|
|
})
|
|
|
|
t.Run("its own dated release still routes", func(t *testing.T) {
|
|
mw := New(Config{Providers: []ProviderRoute{pinned}})
|
|
in := newInputWithModelAndURL("claude-sonnet-4-5-20250101", "/v1/messages")
|
|
in.Metadata = append(in.Metadata, middleware.KV{Key: middleware.KeyLLMProvider, Value: "anthropic"})
|
|
|
|
out, err := mw.Invoke(context.Background(), in)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, middleware.DecisionAllow, out.Decision, "the exact match must still route")
|
|
})
|
|
|
|
t.Run("two pinned builds each route to their own provider", func(t *testing.T) {
|
|
other := pinned
|
|
other.ID = "anthropic-pinned-newer"
|
|
other.Models = []string{"claude-sonnet-4-5-20250202"}
|
|
other.UpstreamHost = "newer.example.com"
|
|
mw := New(Config{Providers: []ProviderRoute{pinned, other}})
|
|
|
|
in := newInputWithModelAndURL("claude-sonnet-4-5-20250202", "/v1/messages")
|
|
in.Metadata = append(in.Metadata, middleware.KV{Key: middleware.KeyLLMProvider, Value: "anthropic"})
|
|
|
|
out, err := mw.Invoke(context.Background(), in)
|
|
require.NoError(t, err)
|
|
require.Equal(t, middleware.DecisionAllow, out.Decision)
|
|
require.NotNil(t, out.Mutations)
|
|
require.NotNil(t, out.Mutations.RewriteUpstream)
|
|
assert.Equal(t, "newer.example.com", out.Mutations.RewriteUpstream.Host,
|
|
"declaration order must not decide between two deliberately pinned builds")
|
|
})
|
|
}
|
|
|
|
// TestRouter_DiscoveryBoundToCallersPolicies pins that a model listing is
|
|
// bounded by the policies that authorise the caller, not by the union across
|
|
// everyone who can reach the provider. Two teams sharing one provider record
|
|
// under different allowlists is the case that makes the difference visible: a
|
|
// flattened per-provider list would offer each team the other's models, and
|
|
// every one of those entries is a request the guardrail then refuses.
|
|
func TestRouter_DiscoveryBoundToCallersPolicies(t *testing.T) {
|
|
const (
|
|
eng = "grp-eng"
|
|
sales = "grp-sales"
|
|
)
|
|
route := ProviderRoute{
|
|
ID: "shared-gateway",
|
|
Models: []string{"claude-sonnet-5", "claude-haiku-4-5", "gpt-4o"},
|
|
AllowedGroupIDs: []string{eng, sales},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "gateway.example.com",
|
|
ModelPolicies: []ModelPolicyRule{
|
|
{GroupIDs: []string{eng}, Models: []string{"claude-sonnet-5"}},
|
|
{GroupIDs: []string{sales}, Models: []string{"gpt-4o"}},
|
|
},
|
|
}
|
|
|
|
listingFor := func(t *testing.T, group string) []string {
|
|
t.Helper()
|
|
mw := New(Config{Providers: []ProviderRoute{route}})
|
|
in := newModellessInput(modelListingPath)
|
|
in.UserGroups = []string{group}
|
|
|
|
out, err := mw.Invoke(context.Background(), in)
|
|
require.NoError(t, err)
|
|
require.Equal(t, middleware.DecisionAllow, out.Decision)
|
|
require.NotNil(t, out.Mutations)
|
|
require.NotNil(t, out.Mutations.RewriteUpstream)
|
|
return out.Mutations.RewriteUpstream.DiscoveryModels
|
|
}
|
|
|
|
t.Run("each group sees only its own policy's models", func(t *testing.T) {
|
|
assert.Equal(t, []string{"claude-sonnet-5"}, listingFor(t, eng),
|
|
"engineering must not be offered the model only sales may use")
|
|
assert.Equal(t, []string{"gpt-4o"}, listingFor(t, sales),
|
|
"sales must not be offered the model only engineering may use")
|
|
})
|
|
|
|
t.Run("a model no policy allows is offered to nobody", func(t *testing.T) {
|
|
for _, group := range []string{eng, sales} {
|
|
assert.NotContains(t, listingFor(t, group), "claude-haiku-4-5",
|
|
"the provider serves it, but no policy permits it")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestRouter_DiscoveryUnrestrictedPolicy covers the lifting rule: a caller
|
|
// holding one policy without a model allowlist sees everything the provider
|
|
// enumerates, whatever the other policies say.
|
|
func TestRouter_DiscoveryUnrestrictedPolicy(t *testing.T) {
|
|
const (
|
|
eng = "grp-eng"
|
|
admin = "grp-admin"
|
|
)
|
|
route := ProviderRoute{
|
|
ID: "shared-gateway",
|
|
Models: []string{"claude-sonnet-5", "gpt-4o"},
|
|
AllowedGroupIDs: []string{eng, admin},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "gateway.example.com",
|
|
ModelPolicies: []ModelPolicyRule{
|
|
{GroupIDs: []string{eng}, Models: []string{"claude-sonnet-5"}},
|
|
// nil Models: a policy that sets no allowlist at all.
|
|
{GroupIDs: []string{admin}},
|
|
},
|
|
}
|
|
mw := New(Config{Providers: []ProviderRoute{route}})
|
|
|
|
in := newModellessInput(modelListingPath)
|
|
in.UserGroups = []string{eng, admin}
|
|
|
|
out, err := mw.Invoke(context.Background(), in)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out.Mutations.RewriteUpstream)
|
|
assert.ElementsMatch(t, []string{"claude-sonnet-5", "gpt-4o"},
|
|
out.Mutations.RewriteUpstream.DiscoveryModels,
|
|
"an unrestricted policy the caller holds lifts the restriction")
|
|
}
|
|
|
|
// TestRouter_DiscoveryOnGatewayRecord covers a record that enumerates no
|
|
// models. It previously offered the upstream's whole catalogue however narrow
|
|
// the policy was, because there was nothing to intersect against; the policy
|
|
// allowlist is now the bound on its own.
|
|
func TestRouter_DiscoveryOnGatewayRecord(t *testing.T) {
|
|
const eng = "grp-eng"
|
|
base := ProviderRoute{
|
|
ID: "litellm",
|
|
AllowedGroupIDs: []string{eng},
|
|
UpstreamScheme: "https",
|
|
UpstreamHost: "litellm.internal",
|
|
}
|
|
|
|
t.Run("a policy allowlist bounds it", func(t *testing.T) {
|
|
route := base
|
|
route.ModelPolicies = []ModelPolicyRule{{GroupIDs: []string{eng}, Models: []string{"gpt-4o"}}}
|
|
mw := New(Config{Providers: []ProviderRoute{route}})
|
|
|
|
in := newModellessInput(modelListingPath)
|
|
in.UserGroups = []string{eng}
|
|
|
|
out, err := mw.Invoke(context.Background(), in)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"gpt-4o"}, out.Mutations.RewriteUpstream.DiscoveryModels,
|
|
"a catch-all record must still be bounded by what policy permits")
|
|
})
|
|
|
|
t.Run("an allowlist permitting nothing offers nothing", func(t *testing.T) {
|
|
route := base
|
|
route.ModelPolicies = []ModelPolicyRule{{GroupIDs: []string{eng}, Models: []string{}}}
|
|
mw := New(Config{Providers: []ProviderRoute{route}})
|
|
|
|
in := newModellessInput(modelListingPath)
|
|
in.UserGroups = []string{eng}
|
|
|
|
out, err := mw.Invoke(context.Background(), in)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out.Mutations.RewriteUpstream)
|
|
assert.Empty(t, out.Mutations.RewriteUpstream.DiscoveryModels,
|
|
"an empty allowlist permits nothing, and must not be read as unrestricted")
|
|
})
|
|
|
|
t.Run("no policy restriction leaves the listing alone", func(t *testing.T) {
|
|
mw := New(Config{Providers: []ProviderRoute{base}})
|
|
|
|
in := newModellessInput(modelListingPath)
|
|
in.UserGroups = []string{eng}
|
|
|
|
out, err := mw.Invoke(context.Background(), in)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, out.Mutations.RewriteUpstream)
|
|
assert.Nil(t, out.Mutations.RewriteUpstream.DiscoveryModels,
|
|
"nothing narrows the listing, so the upstream's own answer passes through")
|
|
})
|
|
}
|