[management,proxy] Add agentgateway integration (#7274)

* [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>
This commit is contained in:
Daneyon Hansen
2026-09-01 13:03:16 +02:00
committed by GitHub
parent 4749005a50
commit 7a9582db16
13 changed files with 372 additions and 24 deletions
@@ -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
@@ -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
}
@@ -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.
@@ -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,
@@ -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
@@ -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)
}
@@ -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
@@ -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
}
@@ -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"}
@@ -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)
@@ -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"`
@@ -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,
@@ -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
@@ -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"
@@ -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