[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)
}