Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 40 additions & 6 deletions management/internals/modules/agentnetwork/catalog/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ type Provider struct {
// surface — the proxy middleware then falls back to URL sniffing
// or skips request-side enrichment.
ParserID string
// RouterVendors declares every parser surface a gateway route can serve.
// Leave empty for single-surface providers, where ParserID remains the
// router discriminator for backward compatibility.
RouterVendors []string
// PricingSurfaces names the cost-meter pricing surfaces this
// provider's Models are priced under ("openai", "anthropic",
// "bedrock" — the llm.Parser surface the request parser stamps as
Expand Down Expand Up @@ -116,8 +120,7 @@ type Provider struct {
// Discovery, when non-nil, describes how to ask this vendor which
// models the operator's own credential can actually reach, so the
// provider form can offer a live list instead of only the hand-curated
// Models above. Nil for entries with no listing endpoint (gateways
// vary too much) — those keep free-text entry.
// Models above. Nil entries keep free-text entry.
Discovery *Discovery
}

Expand Down Expand Up @@ -154,10 +157,13 @@ const (
// one from the caller is also what keeps this from being an open proxy: the
// only hosts management will dial are the ones written here.
type Discovery struct {
Host string
Path string
Query string
Shape ListingShape
Host string
Path string
Query string
Shape ListingShape
// ExactModelsOnly omits wildcard patterns from listings when NetBird's
// provider model rows cannot represent the vendor's matching semantics.
ExactModelsOnly bool
// Headers are static headers the vendor requires beyond the credential
// (Anthropic versions its API through one and rejects a request without
// it). The auth header itself comes from AuthHeaderName/Template.
Expand Down Expand Up @@ -635,6 +641,34 @@ var providers = []Provider{
},
Models: []Model{},
},
{
ID: "agentgateway",
Kind: KindGateway,
Name: "agentgateway",
Description: "Bring your own agentgateway with trusted NetBird identity stamped on every request",
DefaultHost: "",
AuthHeaderName: "Authorization",
AuthHeaderTemplate: "Bearer ${API_KEY}",
DefaultContentType: "application/json",
BrandColor: "#8023C3",
// Agentgateway accepts both OpenAI and Anthropic request shapes.
// Leave ParserID empty so the proxy detects the shape from the URL.
ParserID: "",
RouterVendors: []string{"openai", "anthropic"},
PricingSurfaces: []string{"openai", "anthropic"},
Discovery: &Discovery{
Path: "/v1/models",
Shape: ShapeOpenAIData,
ExactModelsOnly: true,
},
IdentityInjection: &IdentityInjection{
HeaderPair: &HeaderPairInjection{
EndUserIDHeader: "x-netbird-user-id",
TagsHeader: "x-netbird-groups",
},
},
Models: []Model{},
},
{
ID: "portkey",
Kind: KindGateway,
Expand Down
50 changes: 50 additions & 0 deletions management/internals/modules/agentnetwork/catalog/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (

"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
Expand Down Expand Up @@ -34,3 +36,51 @@ func TestClaudeLineupSelectable(t *testing.T) {
}
}
}

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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,8 @@ const (
)

// ErrNoDiscovery is returned for a catalog entry that declares no listing
// endpoint. Gateways vary too much to have one, and the caller should fall
// back to the catalog list plus free-text entry rather than treating this as
// a failure.
// 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
Expand Down Expand Up @@ -356,6 +355,9 @@ func decorate(entry catalog.Provider, ids []listedModel) []Model {
if listed.id == "" {
continue
}
if entry.Discovery.ExactModelsOnly && strings.Contains(listed.id, "*") {
continue
}
if _, dup := seen[listed.id]; dup {
continue
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ const openAIListing = `{"object":"list","data":[
{"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"}
Expand Down Expand Up @@ -97,6 +104,26 @@ func TestFetchOpenAIListing(t *testing.T) {
}
}

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)

Expand Down
13 changes: 13 additions & 0 deletions management/internals/modules/agentnetwork/synthesizer.go
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,7 @@ type routerConfig struct {
type routerProviderRoute struct {
ID string `json:"id"`
Vendor string `json:"vendor,omitempty"`
Vendors []string `json:"vendors,omitempty"`
Models []string `json:"models"`
UpstreamScheme string `json:"upstream_scheme"`
UpstreamHost string `json:"upstream_host"`
Expand Down Expand Up @@ -461,6 +462,7 @@ func buildRouterConfigJSON(providers []*types.Provider, groupIndex map[string][]
cfg.Providers = append(cfg.Providers, routerProviderRoute{
ID: p.ID,
Vendor: providerVendor(p),
Vendors: providerVendors(p),
Models: providerModelIDs(p),
UpstreamScheme: scheme,
UpstreamHost: host,
Expand Down Expand Up @@ -525,6 +527,17 @@ func providerVendor(p *types.Provider) string {
return entry.ParserID
}

// providerVendors returns the parser surfaces a multi-surface gateway route
// accepts. Single-surface providers keep using the singular vendor field so
// existing proxy versions and configurations retain their wire shape.
func providerVendors(p *types.Provider) []string {
entry, ok := catalog.Lookup(p.ProviderID)
if !ok || len(entry.RouterVendors) == 0 {
return nil
}
return append([]string(nil), entry.RouterVendors...)
}

// providerModelIDs returns the model identifiers exposed by the
// provider, deduplicated and in the operator's declared order. Empty
// slice when no models are configured — the router treats that as
Expand Down
51 changes: 50 additions & 1 deletion management/internals/modules/agentnetwork/synthesizer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import (
"testing"
"time"

"go.uber.org/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"

"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
Expand Down Expand Up @@ -497,6 +497,55 @@ func TestSynthesizeServices_IdentityInject_LiteLLM(t *testing.T) {
assert.Equal(t, "x-litellm-tags", entry.HeaderPair.TagsHeader)
}

func TestBuildIdentityInjectConfigJSON_Agentgateway(t *testing.T) {
provider := &types.Provider{
ID: "prov-agentgateway",
ProviderID: "agentgateway",
}

raw, err := buildIdentityInjectConfigJSON(
[]*types.Provider{provider},
map[string][]string{provider.ID: []string{"grp-eng"}},
)
require.NoError(t, err)

var cfg identityInjectConfig
require.NoError(t, json.Unmarshal(raw, &cfg))
require.Len(t, cfg.Providers, 1)

rule := cfg.Providers[0]
assert.Equal(t, provider.ID, rule.ProviderID)
require.NotNil(t, rule.HeaderPair)
assert.Nil(t, rule.JSONMetadata)
assert.Equal(t, "x-netbird-user-id", rule.HeaderPair.EndUserIDHeader)
assert.Equal(t, "x-netbird-groups", rule.HeaderPair.TagsHeader)
assert.False(t, rule.HeaderPair.EndUserIDInBody)
assert.False(t, rule.HeaderPair.TagsInBody)
}

func TestBuildRouterConfigJSON_AgentgatewayVendors(t *testing.T) {
provider := &types.Provider{
ID: "prov-agentgateway",
ProviderID: "agentgateway",
UpstreamURL: "https://gateway.example.com",
APIKey: "virtual-key",
}

raw, err := buildRouterConfigJSON(
[]*types.Provider{provider},
map[string][]string{provider.ID: {"grp-eng"}},
nil,
)
require.NoError(t, err)

var cfg routerConfig
require.NoError(t, json.Unmarshal(raw, &cfg))
require.Len(t, cfg.Providers, 1)
assert.Empty(t, cfg.Providers[0].Vendor,
"the singular vendor remains empty for a multi-surface gateway")
assert.Equal(t, []string{"openai", "anthropic"}, cfg.Providers[0].Vendors)
}

// TestSynthesizeServices_IdentityInject_Bifrost_OperatorOverrides
// covers the customizable HeaderPair contract. The Bifrost catalog
// entry sets HeaderPair.Customizable=true with x-bf-dim-* defaults
Expand Down
5 changes: 4 additions & 1 deletion proxy/internal/middleware/builtin/llm_router/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ type ProviderRoute struct {
// request on a same-vendor route so catch-all gateways of a different
// vendor can't swallow it. Empty disables vendor filtering for this
// route.
Vendor string `json:"vendor,omitempty"`
Vendor string `json:"vendor,omitempty"`
// Vendors lists every parser surface a multi-surface gateway accepts.
// Vendor remains supported for existing single-surface configurations.
Vendors []string `json:"vendors,omitempty"`
Models []string `json:"models"`
UpstreamScheme string `json:"upstream_scheme"`
UpstreamHost string `json:"upstream_host"`
Expand Down
30 changes: 20 additions & 10 deletions proxy/internal/middleware/builtin/llm_router/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@ func stripBedrockNamespace(out *middleware.Output) {
// peer, return matchOutcomeUnauthorised so the caller can emit
// the dedicated no_authorised_provider deny code.
// 3. Vendor precedence: when the request carries a detected vendor
// (llm.provider) and at least one candidate is the same vendor,
// (llm.provider) and at least one candidate declares that vendor,
// drop the rest — a vendor-tagged request must never cross to
// another vendor's route (e.g. an Anthropic call landing on an
// OpenAI-compatible gateway that also claims the model).
Expand All @@ -432,9 +432,9 @@ func (m *Middleware) matchRoute(model, vendor, reqPath string, userGroups []stri

// Vendor pinning runs BEFORE the group filter so a request the parser
// tagged with a vendor can never cross to another vendor's route — not
// even an authorised one. Narrow to same-vendor routes when any
// model-matched route declares that vendor; setups with no vendor tag on
// any route fall through unchanged. After narrowing, if no same-vendor
// even an authorised one. Narrow to supporting routes when any
// model-matched route declares that vendor; setups with no matching vendor
// declaration fall through unchanged. After narrowing, if no supporting
// route authorises the caller, that's matchOutcomeUnauthorised (no
// cross-vendor fallback).
if vendor != "" {
Expand Down Expand Up @@ -805,21 +805,31 @@ func authorisingGroupsCSV(routeGroups, userGroups []string) string {
return strings.Join(out, ",")
}

// matchingVendor returns the subset of routes whose Vendor equals the
// request's detected vendor. Routes with an empty Vendor never match — an
// untagged route can't be asserted to speak the request's surface, so it
// stays out of the vendor-filtered set (but remains eligible via the
// fall-through when no route matches the vendor at all).
// matchingVendor returns the routes that declare the request's detected
// vendor through either the legacy singular field or the multi-vendor field.
// Untagged routes remain eligible only when no route declares the vendor.
func matchingVendor(routes []ProviderRoute, vendor string) []ProviderRoute {
var out []ProviderRoute
for _, r := range routes {
if r.Vendor == vendor {
if routeSupportsVendor(r, vendor) {
out = append(out, r)
}
}
return out
}

func routeSupportsVendor(route ProviderRoute, vendor string) bool {
if route.Vendor == vendor {
return true
}
for _, candidate := range route.Vendors {
if candidate == vendor {
return true
}
}
return false
}

// explicitlyClaiming returns the subset of routes whose Models list
// names the model exactly. Catch-all routes (empty Models) are excluded,
// so callers can prefer a provider that genuinely declares the model over
Expand Down
Loading
Loading