Files
netbird/proxy/internal/middleware/builtin/llm_router/middleware.go
Maycon Santos 7be45c2dd8 [proxy,management] Bound model discovery to the caller's own policies (#7239)
[proxy,management] Bound model discovery to the caller's own policies

GET /v1/models was bounded by the provider record's enumerated models, which is
the right bound only while a single policy reaches a provider. Where two teams
share one provider under different allowlists, every caller was offered the
union — each model outside their own policy being a request the guardrail
refuses a moment later. A gateway record enumerating nothing was worse: it
offered the upstream's entire catalogue however narrow the policy was.

Each route now carries one rule per authorising policy — its source groups and
the models it permits — instead of a single flattened list. At request time the
router keeps the rules whose groups intersect the caller's, unions their
models, and intersects that with what the provider serves.

nil and [] stay distinct end to end: a policy setting no allowlist reaches the
router as nil and lifts the restriction for the groups it binds, while an
enabled allowlist with no models arrives as [] and permits nothing. Collapsing
them would let a listing that should offer nothing fall open to everything.

The guardrail's own per-provider allowlist is untouched. It is a fail-closed
backstop that cannot tell who is asking, so discovery is now narrower than the
backstop rather than wider.
2026-08-23 20:13:35 +02:00

1047 lines
40 KiB
Go

// Package llm_router implements the SlotOnRequest middleware that
// routes a request to an upstream LLM provider based on the model name
// emitted upstream by llm_request_parser. The router rewrites the
// request's outbound target (scheme + host), strips known LLM-vendor
// auth headers, and injects the per-provider auth header from the
// matched route. Unknown or unconfigured models deny with a 403 and
// the canonical llm_policy.model_not_routable code.
package llm_router
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"net/http"
"net/url"
"sort"
"strings"
"sync"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"github.com/netbirdio/netbird/proxy/internal/llm"
"github.com/netbirdio/netbird/proxy/internal/middleware"
)
// gcpScope is the OAuth2 scope minted for Vertex AI service-account auth.
const gcpScope = "https://www.googleapis.com/auth/cloud-platform"
// gcpTokenTimeout bounds each GCP token mint/refresh HTTP call so a slow or
// unreachable token endpoint can't block the request indefinitely.
const gcpTokenTimeout = 10 * time.Second
// ID is the registry key for this middleware.
const ID = "llm_router"
// Version is reported via Middleware.Version().
const Version = "1.0.0"
const (
denyCodeNotRoutable = "llm_policy.model_not_routable"
denyReasonNotRoutable = "model_not_routable"
denyCodeNoAuthorisedRoute = "llm_policy.no_authorised_provider"
denyReasonNoAuthorisedRoute = "no_authorised_provider"
//nolint:gosec // deny code label, not a credential
denyCodeUpstreamAuth = "llm_policy.upstream_auth_failed"
denyCodeUnmeterable = "llm_policy.unmeterable_publisher"
denyReasonUnmeterable = "unmeterable_publisher"
)
// strippedAuthHeaders is the closed list of vendor authentication
// credentials the router clears before injecting the provider-specific
// credential. Strictly auth headers — vendor-specific metadata
// (anthropic-version, openai-organization, openai-project, etc.) is
// NOT stripped because the client SDK sets those and the upstream
// requires them (e.g. Anthropic returns 400 without
// anthropic-version). Each entry is canonicalised by Go's
// http.Header.Del/Set, so listing the canonical shapes here is
// sufficient.
var strippedAuthHeaders = []string{
"Authorization", // OpenAI, OpenAI-compatible, most vendors, Bedrock bearer
"Proxy-Authorization", // upstream proxy auth (defense-in-depth)
"x-api-key", // Anthropic
"api-key", // Azure OpenAI
"X-Amz-Date", // AWS SigV4 — strip client-supplied AWS signing material
"X-Amz-Security-Token",
"X-Amz-Content-Sha256",
}
// Middleware routes requests to upstream LLM providers based on the
// llm.model metadata emitted by llm_request_parser.
type Middleware struct {
cfg Config
// tokenSrc caches one auto-refreshing OAuth2 TokenSource per GCP
// service-account key (keyed by a hash of the key material), so Vertex
// token minting happens once and refreshes are amortised across requests.
tokenMu sync.Mutex
tokenSrc map[string]oauth2.TokenSource
}
// New constructs a Middleware with the supplied configuration. Empty
// or nil Providers slice yields a router that denies every request as
// not-routable.
func New(cfg Config) *Middleware {
return &Middleware{cfg: cfg, tokenSrc: map[string]oauth2.TokenSource{}}
}
// ID returns the registry identifier.
func (m *Middleware) ID() string { return ID }
// Version returns the implementation version.
func (m *Middleware) Version() string { return Version }
// Slot reports the chain slot the middleware lives in.
func (m *Middleware) Slot() middleware.Slot { return middleware.SlotOnRequest }
// AcceptedContentTypes returns nil because the router only consults
// the metadata emitted by llm_request_parser.
func (m *Middleware) AcceptedContentTypes() []string { return nil }
// MetadataKeys is the closed set of metadata keys this middleware may
// emit. The accumulator drops anything outside this allowlist.
func (m *Middleware) MetadataKeys() []string {
return []string{
middleware.KeyLLMResolvedProviderID,
middleware.KeyLLMAuthorisingGroups,
middleware.KeyLLMPolicyDecision,
middleware.KeyLLMPolicyReason,
middleware.KeyLLMNonInference,
// Emitted only for the per-model lookup, whose model lives in the path
// rather than a body the parser could read.
middleware.KeyLLMModel,
}
}
// MutationsSupported reports that the middleware emits header and
// upstream-rewrite mutations.
func (m *Middleware) MutationsSupported() bool { return true }
// Close releases resources owned by the middleware. The router is
// stateless, so this is a no-op.
func (m *Middleware) Close() error { return nil }
// matchOutcome captures why matchRoute returned what it did so the
// caller can distinguish "no provider knows this model" from "providers
// know it but none authorise this peer's groups".
type matchOutcome int
const (
matchOutcomeFound matchOutcome = iota
matchOutcomeUnknownModel
matchOutcomeUnauthorised
)
// Invoke resolves the model to a provider authorised for the caller's
// groups, strips known vendor auth headers, and injects the route's
// auth header. Unknown models deny with model_not_routable; models
// known to a provider that no policy authorises for the caller deny
// with no_authorised_provider.
func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middleware.Output, error) {
reqPath := requestPath(in.URL)
// The caller's API dialect, used to mirror a denial in the vendor's own
// error shape so the client can explain it to the user.
surface, _ := lookupMetadata(in.Metadata, middleware.KeyLLMProvider)
model, _ := lookupMetadata(in.Metadata, middleware.KeyLLMModel)
// Vertex AI carries the model in the URL path, not the body, and is
// selected by path rather than by the model/vendor table. Route it before
// the model lookup so a model the parser extracted from the path can't be
// claimed by a same-vendor direct provider (e.g. claude-* on api.anthropic.com).
if isVertexPath(reqPath) {
// The request parser emits no llm.provider for a Vertex publisher it
// can't parse (e.g. google/gemini). Forwarding such a request would
// bypass token/budget metering, so deny it rather than serve it
// unmetered.
if surface == "" {
return denyUnmeterable(surface), nil
}
route, outcome := m.matchVertex(reqPath, model, in.UserGroups)
return m.decide(route, outcome, surface, model, in.UserGroups, nil), nil
}
// Bedrock likewise carries the model in the URL path (/model/{id}/{action}),
// optionally behind a "/bedrock" gateway-namespace prefix. Route it by path
// before the model lookup; when the prefix is present, strip it from the
// forwarded path so the real Bedrock endpoint receives its native path.
if isBedrockPath(reqPath) {
native, hadPrefix := splitBedrockNamespace(reqPath)
route, outcome := m.matchBedrock(native, model, in.UserGroups)
return m.decide(route, outcome, surface, model, in.UserGroups, func(out *middleware.Output) {
if hadPrefix {
stripBedrockNamespace(out)
}
}), nil
}
// GET /v1/models/{id} carries no body, so no model reaches the router in
// metadata — but the path names one, and answering it confirms a model
// exists and is reachable. Authorise it against the model table like any
// other per-model request, then mark it non-inference so it still skips
// the token pre-flight it would otherwise charge nothing against.
if detail, isDetail := modelDetailID(reqPath); isDetail && isNonInferenceMethod(in.Method) {
route, outcome := m.matchRoute(detail, surface, reqPath, in.UserGroups)
return m.decide(route, outcome, surface, detail, in.UserGroups, func(out *middleware.Output) {
markNonInference(out)
// The parser reads models from JSON bodies only, and this request
// has none, so stamp the one the path names. Without it the
// guardrail's own allowlist — a separate, possibly narrower list
// than the route's — never sees a model to check.
out.Metadata = append(out.Metadata, middleware.KV{Key: middleware.KeyLLMModel, Value: detail})
}), nil
}
if model == "" {
return m.routeModelless(reqPath, surface, in.Method, in.UserGroups), nil
}
route, outcome := m.matchRoute(model, surface, reqPath, in.UserGroups)
return m.decide(route, outcome, surface, model, in.UserGroups, nil), nil
}
// decide turns a per-model match result into the middleware's decision. Every
// surface that routes by model shares the same two denial arms — a model no
// route claims is not routable, one that some route claims but none authorises
// for this caller is an authorisation failure — so they live here once.
// decorate, when non-nil, adjusts the allow with whatever that surface needs.
func (m *Middleware) decide(
route ProviderRoute,
outcome matchOutcome,
surface, model string,
userGroups []string,
decorate func(*middleware.Output),
) *middleware.Output {
switch outcome {
case matchOutcomeFound:
out := m.allowWithRoute(route, surface, userGroups)
if decorate != nil {
decorate(out)
}
return out
case matchOutcomeUnauthorised:
return denyNoAuthorisedRoute(surface, model)
default:
return denyUnknownModel(surface, model)
}
}
// routeModelless serves the endpoints that name no model at all: the model
// listing, the connection-warming probe, and the Bedrock inference-profile
// lookup. They still need rewriting from the synth placeholder to a real
// upstream — clients such as Codex call GET /v1/models at startup to enumerate
// availability and read a 403 as "model unavailable".
func (m *Middleware) routeModelless(reqPath, surface, method string, userGroups []string) *middleware.Output {
route, outcome := m.matchModelless(reqPath, method, userGroups)
switch outcome {
case matchOutcomeFound:
out := m.allowWithRoute(route, surface, userGroups)
markNonInference(out)
if _, hadPrefix := splitBedrockNamespace(reqPath); hadPrefix {
stripBedrockNamespace(out)
}
// What the caller may actually use bounds what the picker may offer:
// every entry outside it is a request the chain will deny a moment
// later.
if reqPath == modelListingPath && out.Mutations != nil && out.Mutations.RewriteUpstream != nil {
if models, bounded := discoverableModels(route, userGroups); bounded {
out.Mutations.RewriteUpstream.DiscoveryModels = models
}
}
return out
case matchOutcomeUnauthorised:
// A recognised model-less endpoint exists but no provider authorises
// the caller — deny as an authorisation failure rather than masking it
// as a missing model.
return denyNoAuthorisedRoute(surface, "")
default:
return denyMissingModel(surface)
}
}
// isNonInferenceMethod reports whether a request method is one the
// non-inference endpoints actually use: the listing and the per-model lookup
// are GET, the connection-warming probe is HEAD or GET. The method is the only
// thing separating "GET /v1/models/{id}" from a POST to the same path carrying
// an inference body, and the non-inference mark exempts a request from the
// token pre-flight — so anything else falls through to normal per-model
// routing, which denies when the request names no model.
func isNonInferenceMethod(method string) bool {
return method == http.MethodGet || method == http.MethodHead
}
// discoverableModels returns the model ids a caller in userGroups may actually
// use on this route, and whether the listing should be bounded to them at all.
//
// Two things narrow a listing, and both must apply or the picker offers models
// the very next request refuses:
//
// - the provider's own enumerated models, when it lists any (a gateway record
// enumerates nothing and claims everything);
// - the model allowlists of the policies that authorise THIS caller. A
// provider reachable by two groups under different allowlists must not
// offer either group the other's models, which is why the rules carry their
// source groups rather than arriving pre-flattened.
//
// A policy that sets no allowlist lifts the restriction for the groups it
// binds, so a caller holding one unrestricted policy sees the provider's full
// list. bounded is false when nothing narrows the listing — an unrestricted
// caller on a route that enumerates nothing — in which case the upstream's own
// answer passes through untouched.
func discoverableModels(route ProviderRoute, userGroups []string) ([]string, bool) {
permitted, restricted := policyPermittedModels(route, userGroups)
switch {
case !restricted && len(route.Models) == 0:
return nil, false
case !restricted:
return append([]string(nil), route.Models...), true
case len(route.Models) == 0:
// A gateway record enumerates nothing, so the allowlist is the whole
// bound — previously such a record offered the upstream's entire
// catalogue however narrow the policy was.
return sortedModels(permitted), true
}
// Both bound: only what the provider serves and the policy permits.
intersection := make(map[string]struct{}, len(route.Models))
for _, m := range route.Models {
if _, ok := permitted[m]; ok {
intersection[m] = struct{}{}
}
}
return sortedModels(intersection), true
}
// policyPermittedModels folds the rules whose groups intersect the caller's
// into the set of models they permit. restricted is false when the caller
// holds at least one authorising policy that sets no allowlist, or when no
// rule binds them at all.
func policyPermittedModels(route ProviderRoute, userGroups []string) (map[string]struct{}, bool) {
permitted := make(map[string]struct{})
restricted := false
for _, rule := range route.ModelPolicies {
if !groupsIntersect(rule.GroupIDs, userGroups) {
continue
}
if rule.Models == nil {
// An unrestricted policy the caller holds lifts the restriction
// entirely, whatever the others say.
return nil, false
}
restricted = true
for _, m := range rule.Models {
permitted[m] = struct{}{}
}
}
return permitted, restricted
}
// groupsIntersect reports whether the two group-id sets share a member.
func groupsIntersect(a, b []string) bool {
for _, x := range a {
for _, y := range b {
if x == y {
return true
}
}
}
return false
}
// sortedModels flattens a model set into a stable slice so the bound the proxy
// applies — and any test asserting on it — does not depend on map order.
func sortedModels(set map[string]struct{}) []string {
out := make([]string, 0, len(set))
for m := range set {
out = append(out, m)
}
sort.Strings(out)
return out
}
// markNonInference tags an allow as a request that spends no tokens, so the
// limit check skips the management pre-flight it would charge nothing against.
func markNonInference(out *middleware.Output) {
out.Metadata = append(out.Metadata, middleware.KV{Key: middleware.KeyLLMNonInference, Value: "true"})
}
// stripBedrockNamespace tells the rewrite to drop the optional "/bedrock"
// gateway namespace so the upstream receives its native Bedrock path.
func stripBedrockNamespace(out *middleware.Output) {
if out.Mutations != nil && out.Mutations.RewriteUpstream != nil {
out.Mutations.RewriteUpstream.StripPathPrefix = bedrockNamespacePrefix
}
}
// matchRoute returns the ProviderRoute that should serve the given
// model + request path for a caller in the given user-groups. Selection
// is:
//
// 1. Filter the configured providers to those whose Models list
// contains the model.
// 2. Filter the model-matched candidates to those whose
// AllowedGroupIDs intersect the caller's UserGroups. A route with
// no AllowedGroupIDs is the catch-all: it stays in the list. If
// the model was known but no candidate is authorised for this
// 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,
// 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).
// 4. Model precedence over path: a route that explicitly lists the
// model beats a catch-all (empty Models) gateway.
// 5. Disambiguate the survivors by URL path prefix: longest
// UpstreamPath that prefix-matches the request path wins; an empty
// UpstreamPath is the catchall. If none prefix-matches, fall back
// to declaration order so the model stays routable.
func (m *Middleware) matchRoute(model, vendor, reqPath string, userGroups []string) (ProviderRoute, matchOutcome) {
var modelMatched []ProviderRoute
for _, route := range m.cfg.Providers {
if routeClaimsModel(route, model) {
modelMatched = append(modelMatched, route)
}
}
if len(modelMatched) == 0 {
return ProviderRoute{}, matchOutcomeUnknownModel
}
// 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
// route authorises the caller, that's matchOutcomeUnauthorised (no
// cross-vendor fallback).
if vendor != "" {
if vendorMatched := matchingVendor(modelMatched, vendor); len(vendorMatched) > 0 {
modelMatched = vendorMatched
}
}
var candidates []ProviderRoute
for _, route := range modelMatched {
if routeAuthorisesGroups(route, userGroups) {
candidates = append(candidates, route)
}
}
if len(candidates) == 0 {
return ProviderRoute{}, matchOutcomeUnauthorised
}
// Model routing takes precedence over path. A route that explicitly
// lists the model must beat a catch-all (empty Models) gateway that
// claims every model — otherwise an Anthropic request can fall through
// to an OpenAI-compatible gateway declared earlier. Only when no
// candidate explicitly claims the model do the catch-alls compete, and
// the path-prefix tiebreak applies within whichever tier wins.
if explicit := explicitlyClaiming(candidates, model); len(explicit) > 0 {
candidates = explicit
}
if len(candidates) == 1 {
return candidates[0], matchOutcomeFound
}
best := candidates[0]
bestLen := -1
for _, c := range candidates {
if !pathPrefixMatches(c.UpstreamPath, reqPath) {
continue
}
if len(c.UpstreamPath) > bestLen {
best = c
bestLen = len(c.UpstreamPath)
}
}
return best, matchOutcomeFound
}
// connectionWarmPath is the probe Anthropic clients send before their first
// inference request to open the upstream connection early. Forwarding it
// warms the connection the request will actually use; denying it only fills
// the access log with rejections at every session start.
const connectionWarmPath = "/api/hello"
// modelListingPath is the endpoint clients read at startup to populate
// their model picker. Its response is a list the proxy can bound; the
// per-model "/v1/models/{id}" lookup returns a single object and is left
// alone.
const modelListingPath = "/v1/models"
// isModelLessPath reports whether reqPath is a known non-inference endpoint
// that legitimately carries no model at all: the model listing and the
// connection-warming probe. These must route to an upstream rather than
// deny, so model enumeration works end to end. The per-model
// "/v1/models/{id}" lookup is deliberately excluded — it names a model, so
// it is authorised against the model table instead (see modelDetailID).
func isModelLessPath(reqPath string) bool {
return reqPath == modelListingPath || reqPath == connectionWarmPath
}
// modelDetailID returns the model id named by a "/v1/models/{id}" lookup.
// reqPath comes from url.URL.Path, which is already percent-decoded, so an
// id carrying a "/" (a self-hosted "Qwen/Qwen2.5-0.5B-Instruct" sent as
// "Qwen%2FQwen2.5-...") arrives whole and everything after the prefix is the
// id, separators included.
func modelDetailID(reqPath string) (string, bool) {
if !strings.HasPrefix(reqPath, modelListingPath+"/") {
return "", false
}
id := strings.TrimPrefix(reqPath, modelListingPath+"/")
if id == "" {
return "", false
}
return id, true
}
// isBedrockModelLessPath reports whether reqPath is a Bedrock
// inference-profile lookup, optionally behind the "/bedrock" gateway
// namespace. Clients read these at startup to resolve a configured profile
// to its underlying model. They carry no model of their own, so they route
// by path to a Bedrock provider rather than through the model table.
//
// On native AWS these live on the control plane ("bedrock.<region>") while a
// provider's upstream is normally the runtime host ("bedrock-runtime.<region>"),
// so forwarding yields a 404 there. That is deliberate: a client has one base
// URL, so pointing it straight at the runtime host 404s identically, and
// forwarding keeps the proxy transparent instead of inventing a policy denial
// the client would never otherwise see. Operators whose Bedrock upstream is a
// gateway that does serve the lookup get a working answer.
func isBedrockModelLessPath(reqPath string) bool {
native, _ := splitBedrockNamespace(reqPath)
return native == "/inference-profiles" || strings.HasPrefix(native, "/inference-profiles/")
}
// isVertexPath reports whether reqPath is a Google Vertex AI publisher
// endpoint: /v1/projects/{project}/locations/{region}/publishers/{publisher}/
// models/{model}:{action}. The model + vendor live in the path, so these
// requests are routed by path to the Vertex provider rather than by model.
func isVertexPath(reqPath string) bool {
return strings.HasPrefix(reqPath, "/v1/projects/") &&
strings.Contains(reqPath, "/publishers/") &&
strings.Contains(reqPath, "/models/")
}
// bedrockNamespacePrefix is an optional gateway-namespace prefix some clients
// place before the native Bedrock path to disambiguate it from other providers
// that also use "/model/...". It is stripped before forwarding upstream.
const bedrockNamespacePrefix = "/bedrock"
// splitBedrockNamespace removes an optional "/bedrock" namespace prefix,
// returning the native Bedrock path and whether the prefix was present.
func splitBedrockNamespace(reqPath string) (string, bool) {
if strings.HasPrefix(reqPath, bedrockNamespacePrefix+"/") {
return strings.TrimPrefix(reqPath, bedrockNamespacePrefix), true
}
return reqPath, false
}
// bedrockActions are the runtime actions that follow the model id in a
// Bedrock path. count-tokens is here so a client can price its context
// against the dedicated endpoint; denying it pushes that work back onto
// the inference endpoint, which bills for it.
var bedrockActions = []string{
"/invoke",
"/invoke-with-response-stream",
"/converse",
"/converse-stream",
"/count-tokens",
}
// isBedrockPath reports whether reqPath is an AWS Bedrock runtime model
// endpoint: /model/{modelId}/{action} — optionally behind a "/bedrock"
// gateway-namespace prefix. The model lives in the path, so these requests
// are routed by path to the Bedrock provider.
func isBedrockPath(reqPath string) bool {
native, _ := splitBedrockNamespace(reqPath)
if !strings.HasPrefix(native, "/model/") {
return false
}
for _, action := range bedrockActions {
if strings.HasSuffix(native, action) {
return true
}
}
return false
}
// matchVertex selects the Vertex provider authorised for the caller's groups
// and claiming the requested model.
func (m *Middleware) matchVertex(reqPath, model string, userGroups []string) (ProviderRoute, matchOutcome) {
return m.matchPathRoute(reqPath, model, userGroups, func(r ProviderRoute) bool { return r.Vertex })
}
// matchBedrock selects the Bedrock provider authorised for the caller's groups
// and claiming the requested model.
func (m *Middleware) matchBedrock(reqPath, model string, userGroups []string) (ProviderRoute, matchOutcome) {
return m.matchPathRoute(reqPath, model, userGroups, func(r ProviderRoute) bool { return r.Bedrock })
}
// matchPathRoute selects a path-routed provider (Vertex/Bedrock). These carry
// the model in the URL, so the model/vendor table is bypassed — but the route's
// configured Models allowlist is still enforced (empty Models = catch-all) so a
// provider credential can't be used for models the operator didn't authorise.
// Returns matchOutcomeUnauthorised when no style route authorises the caller's
// groups, matchOutcomeUnknownModel when an authorised route exists but none
// claims the model (or no style route exists at all), else the chosen route
// (longest UpstreamPath prefix-match wins among multiple).
func (m *Middleware) matchPathRoute(reqPath, model string, userGroups []string, isStyle func(ProviderRoute) bool) (ProviderRoute, matchOutcome) {
var styled []ProviderRoute
for _, route := range m.cfg.Providers {
if isStyle(route) {
styled = append(styled, route)
}
}
if len(styled) == 0 {
return ProviderRoute{}, matchOutcomeUnknownModel
}
var authorised []ProviderRoute
for _, route := range styled {
if routeAuthorisesGroups(route, userGroups) {
authorised = append(authorised, route)
}
}
if len(authorised) == 0 {
return ProviderRoute{}, matchOutcomeUnauthorised
}
var candidates []ProviderRoute
for _, route := range authorised {
if routeClaimsModel(route, model) {
candidates = append(candidates, route)
}
}
if len(candidates) == 0 {
return ProviderRoute{}, matchOutcomeUnknownModel
}
if len(candidates) == 1 {
return candidates[0], matchOutcomeFound
}
best := candidates[0]
bestLen := -1
for _, c := range candidates {
if !pathPrefixMatches(c.UpstreamPath, reqPath) {
continue
}
if len(c.UpstreamPath) > bestLen {
best = c
bestLen = len(c.UpstreamPath)
}
}
return best, matchOutcomeFound
}
// matchModelless selects a route for a non-inference, model-less request.
// It mirrors matchRoute's group-authorisation filter and path-prefix
// tiebreak but skips the per-model filter, since any provider the caller's
// groups authorise can serve a model-listing request. Returns
// matchOutcomeFound with the chosen route (single authorised provider wins
// outright; multiple fall to the longest UpstreamPath prefix-match, then
// declaration order), matchOutcomeUnauthorised when no provider authorises
// the caller, or matchOutcomeUnknownModel when the path isn't a recognised
// model-less endpoint.
func (m *Middleware) matchModelless(reqPath, method string, userGroups []string) (ProviderRoute, matchOutcome) {
if !isNonInferenceMethod(method) {
return ProviderRoute{}, matchOutcomeUnknownModel
}
var eligible func(ProviderRoute) bool
switch {
case isBedrockModelLessPath(reqPath):
eligible = func(r ProviderRoute) bool { return r.Bedrock }
case isModelLessPath(reqPath):
// Vertex/Bedrock are path-routed and don't serve OpenAI-style
// model-listing endpoints; including them here could rewrite a
// GET /v1/models to an upstream that 404s it.
eligible = func(r ProviderRoute) bool { return !r.Vertex && !r.Bedrock }
default:
return ProviderRoute{}, matchOutcomeUnknownModel
}
var candidates []ProviderRoute
for _, route := range m.cfg.Providers {
if eligible(route) && routeAuthorisesGroups(route, userGroups) {
candidates = append(candidates, route)
}
}
if len(candidates) == 0 {
return ProviderRoute{}, matchOutcomeUnauthorised
}
if len(candidates) == 1 {
return candidates[0], matchOutcomeFound
}
best := candidates[0]
bestLen := -1
for _, c := range candidates {
if !pathPrefixMatches(c.UpstreamPath, reqPath) {
continue
}
if len(c.UpstreamPath) > bestLen {
best = c
bestLen = len(c.UpstreamPath)
}
}
return best, matchOutcomeFound
}
// routeAuthorisesGroups reports whether the route's AllowedGroupIDs
// intersect the caller's userGroups. A route with empty AllowedGroupIDs
// is unreachable: the synthesiser only emits routes bound to at least
// one enabled policy, so an empty list signals a misconfiguration that
// must not be allowed to fall through.
func routeAuthorisesGroups(r ProviderRoute, userGroups []string) bool {
for _, ug := range userGroups {
for _, ag := range r.AllowedGroupIDs {
if ug == ag {
return true
}
}
}
return false
}
// authorisingGroupsCSV returns the sorted, deduplicated comma-separated
// intersection of routeGroups and userGroups — i.e. the groups that
// actually authorise the resolved route for this caller. Returns the
// empty string when the intersection is empty (shouldn't happen on the
// allow path, but defensive).
func authorisingGroupsCSV(routeGroups, userGroups []string) string {
if len(routeGroups) == 0 || len(userGroups) == 0 {
return ""
}
allowed := make(map[string]struct{}, len(routeGroups))
for _, g := range routeGroups {
allowed[g] = struct{}{}
}
seen := make(map[string]struct{}, len(userGroups))
out := make([]string, 0, len(userGroups))
for _, ug := range userGroups {
if _, ok := allowed[ug]; !ok {
continue
}
if _, dup := seen[ug]; dup {
continue
}
seen[ug] = struct{}{}
out = append(out, ug)
}
if len(out) == 0 {
return ""
}
sort.Strings(out)
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).
func matchingVendor(routes []ProviderRoute, vendor string) []ProviderRoute {
var out []ProviderRoute
for _, r := range routes {
if r.Vendor == vendor {
out = append(out, r)
}
}
return out
}
// 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
// a gateway that claims everything.
func explicitlyClaiming(routes []ProviderRoute, model string) []ProviderRoute {
var out []ProviderRoute
for _, r := range routes {
for _, candidate := range r.Models {
if candidate == model {
out = append(out, r)
break
}
}
}
return out
}
// routeClaimsModel reports whether the route's Models list contains
// the given model identifier. An empty Models list is treated as
// "claim every model" — used by gateway-style providers (LiteLLM,
// custom OpenAI-compatible endpoints) that proxy an open-ended set of
// upstream models the operator can't enumerate in NetBird's provider
// config.
func routeClaimsModel(route ProviderRoute, model string) bool {
if len(route.Models) == 0 {
return true
}
for _, candidate := range route.Models {
if candidate == model {
return true
}
// Bedrock request models reach the router already normalized (the parser
// strips the region / inference-profile prefix and version suffix), but
// the operator may register the raw inference-profile id (e.g.
// "us.anthropic.claude-haiku-4-5"). Normalize the candidate so both sides
// compare equal; otherwise a native Bedrock request denies as not-routable.
if route.Bedrock && llm.NormalizeBedrockModel(candidate) == model {
return true
}
// A client may pin a dated Anthropic id ("claude-sonnet-4-5-20250929")
// where the operator registered the undated one. Only an undated
// registration absorbs a dated request: normalising both sides would
// let a route pinned to one dated release claim a different one, so an
// operator who deliberately pinned a build would silently serve
// another — and with several such routes, ordering would decide which.
if candidate == llm.NormalizeAnthropicModel(candidate) &&
candidate == llm.NormalizeAnthropicModel(model) {
return true
}
}
return false
}
// pathPrefixMatches reports whether upstreamPath matches reqPath on a path-
// segment boundary: an exact match, or reqPath continuing after
// upstreamPath at a "/" separator. This avoids a sibling base like
// "/openai" spuriously matching "/openai-test". An empty (or "/")
// upstreamPath always matches (catchall).
func pathPrefixMatches(upstreamPath, reqPath string) bool {
if upstreamPath == "" || upstreamPath == "/" {
return true
}
upstreamPath = strings.TrimRight(upstreamPath, "/")
return reqPath == upstreamPath || strings.HasPrefix(reqPath, upstreamPath+"/")
}
// requestPath extracts the path component from an Input.URL string
// (which is r.URL.String() — typically "/path?query"). Returns the
// raw input on parse failure so the prefix check can still operate on
// the unparsed value.
func requestPath(raw string) string {
if raw == "" {
return ""
}
parsed, err := url.Parse(raw)
if err != nil {
return raw
}
return parsed.Path
}
// allowWithRoute builds the Output for a successful route match. The
// returned Mutations carry the upstream rewrite plus — riding on it —
// the StripHeaders list and the AuthHeader to inject.
//
// The strip + inject MUST go through UpstreamRewrite (not HeadersAdd /
// HeadersRemove) because the framework's mutation gate runs every
// header change through a denylist that blocks Authorization,
// Cookie, etc. — exactly the headers the router is replacing. The
// proxy's upstream-build path applies AuthHeader / StripHeaders
// directly, bypassing the denylist by virtue of being a trusted
// proxy operation rather than an arbitrary middleware mutation.
//
// Emits the authorising-groups intersection alongside the resolved
// provider id so identity-stamping middlewares (llm_identity_inject)
// tag the request with ONLY the groups that authorised this specific
// route — not every group the peer happens to be in.
func (m *Middleware) allowWithRoute(route ProviderRoute, surface string, userGroups []string) *middleware.Output {
rewrite := &middleware.UpstreamRewrite{
Scheme: route.UpstreamScheme,
Host: route.UpstreamHost,
// UpstreamPath is the path component the operator pasted on
// the provider record (e.g. "/v1/{account}/{gateway}/compat"
// for Cloudflare AI Gateway). Carrying it on the rewrite so
// the proxy's URL composer joins it with the agent's request
// path — without this, the operator's configured upstream
// path is silently dropped and the gateway returns a 4xx for
// the malformed URL. Empty value leaves the original
// target's path untouched.
Path: route.UpstreamPath,
StripHeaders: append([]string(nil), strippedAuthHeaders...),
SkipTLSVerify: route.SkipTLSVerify,
}
authValue := route.AuthHeaderValue
if route.GCPServiceAccountKeyB64 != "" {
// Mint a short-lived OAuth2 token from the service-account key at
// request time (cached + auto-refreshed) instead of a static value.
bearer, err := m.gcpBearer(route.GCPServiceAccountKeyB64)
if err != nil {
return denyUpstreamAuth(surface)
}
authValue = bearer
}
if route.AuthHeaderName != "" && authValue != "" {
rewrite.AuthHeader = &middleware.AuthHeader{
Name: route.AuthHeaderName,
Value: authValue,
}
}
return &middleware.Output{
Decision: middleware.DecisionAllow,
Mutations: &middleware.Mutations{RewriteUpstream: rewrite},
Metadata: []middleware.KV{
{Key: middleware.KeyLLMResolvedProviderID, Value: route.ID},
{Key: middleware.KeyLLMAuthorisingGroups, Value: authorisingGroupsCSV(route.AllowedGroupIDs, userGroups)},
{Key: middleware.KeyLLMPolicyDecision, Value: "allow"},
},
}
}
// gcpBearer returns a "Bearer <token>" value minted from a base64-encoded GCP
// service-account key, using a cached, auto-refreshing token source.
func (m *Middleware) gcpBearer(saKeyB64 string) (string, error) {
ts, err := m.gcpTokenSource(saKeyB64)
if err != nil {
return "", err
}
tok, err := ts.Token()
if err != nil {
return "", fmt.Errorf("mint gcp token: %w", err)
}
return "Bearer " + tok.AccessToken, nil
}
// gcpTokenSource returns the cached TokenSource for the given service-account
// key, building it (decode base64 → parse JSON → cloud-platform scope) on first
// use. The returned source caches the token and refreshes it before expiry.
func (m *Middleware) gcpTokenSource(saKeyB64 string) (oauth2.TokenSource, error) {
sum := sha256.Sum256([]byte(saKeyB64))
key := hex.EncodeToString(sum[:])
m.tokenMu.Lock()
defer m.tokenMu.Unlock()
if m.tokenSrc == nil {
m.tokenSrc = map[string]oauth2.TokenSource{}
}
if ts, ok := m.tokenSrc[key]; ok {
return ts, nil
}
jsonKey, err := base64.StdEncoding.DecodeString(strings.TrimSpace(saKeyB64))
if err != nil {
return nil, fmt.Errorf("decode gcp service-account key: %w", err)
}
conf, err := google.JWTConfigFromJSON(jsonKey, gcpScope)
if err != nil {
return nil, fmt.Errorf("parse gcp service-account key: %w", err)
}
// Bound mint/refresh with a timeout HTTP client so a slow token endpoint
// can't hang the request. The oauth2 library uses this client for the
// lifetime of the (auto-refreshing) source.
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, &http.Client{Timeout: gcpTokenTimeout})
ts := conf.TokenSource(ctx)
m.tokenSrc[key] = ts
return ts, nil
}
// denyUpstreamAuth is returned when the router cannot obtain the upstream
// credential (e.g. a malformed service-account key or an unreachable token
// endpoint). It surfaces as a 502 — an upstream problem, not a policy denial.
func denyUpstreamAuth(surface string) *middleware.Output {
return &middleware.Output{
Decision: middleware.DecisionDeny,
DenyStatus: 502,
DenyReason: &middleware.DenyReason{
Surface: surface,
Code: denyCodeUpstreamAuth,
Message: "could not obtain upstream credential",
},
Metadata: []middleware.KV{
{Key: middleware.KeyLLMPolicyDecision, Value: "deny"},
{Key: middleware.KeyLLMPolicyReason, Value: "upstream_auth_failed"},
},
}
}
// denyUnmeterable returns the deny envelope for a path-routed request whose
// publisher has no parser surface, so its usage can't be metered. Serving it
// would bypass token/budget caps, so it is rejected with a 403.
func denyUnmeterable(surface string) *middleware.Output {
return &middleware.Output{
Decision: middleware.DecisionDeny,
DenyStatus: 403,
DenyReason: &middleware.DenyReason{
Surface: surface,
Code: denyCodeUnmeterable,
Message: "request publisher is not supported for metering",
},
Metadata: []middleware.KV{
{Key: middleware.KeyLLMPolicyDecision, Value: "deny"},
{Key: middleware.KeyLLMPolicyReason, Value: denyReasonUnmeterable},
},
}
}
// denyMissingModel returns the deny envelope for a request whose
// envelope has no llm.model metadata.
func denyMissingModel(surface string) *middleware.Output {
return &middleware.Output{
Decision: middleware.DecisionDeny,
DenyStatus: 403,
DenyReason: &middleware.DenyReason{
Surface: surface,
Code: denyCodeNotRoutable,
Message: "missing llm.model on request envelope",
},
Metadata: []middleware.KV{
{Key: middleware.KeyLLMPolicyDecision, Value: "deny"},
{Key: middleware.KeyLLMPolicyReason, Value: denyReasonNotRoutable},
},
}
}
// denyUnknownModel returns the deny envelope for a model that no
// configured provider claims.
func denyUnknownModel(surface, model string) *middleware.Output {
return &middleware.Output{
Decision: middleware.DecisionDeny,
DenyStatus: 403,
DenyReason: &middleware.DenyReason{
Surface: surface,
Code: denyCodeNotRoutable,
Message: fmt.Sprintf("no provider configured for model %s", model),
Details: map[string]string{"model": model},
},
Metadata: []middleware.KV{
{Key: middleware.KeyLLMPolicyDecision, Value: "deny"},
{Key: middleware.KeyLLMPolicyReason, Value: denyReasonNotRoutable},
},
}
}
// denyNoAuthorisedRoute returns the deny envelope for a model that one
// or more providers claim, but where no policy authorises the caller's
// groups for any of those providers.
func denyNoAuthorisedRoute(surface, model string) *middleware.Output {
return &middleware.Output{
Decision: middleware.DecisionDeny,
DenyStatus: 403,
DenyReason: &middleware.DenyReason{
Surface: surface,
Code: denyCodeNoAuthorisedRoute,
Message: fmt.Sprintf("no policy authorises model %s for the caller's groups", model),
Details: map[string]string{"model": model},
},
Metadata: []middleware.KV{
{Key: middleware.KeyLLMPolicyDecision, Value: "deny"},
{Key: middleware.KeyLLMPolicyReason, Value: denyReasonNoAuthorisedRoute},
},
}
}
// lookupMetadata returns the value for key plus a presence flag so
// callers can distinguish absent from empty.
func lookupMetadata(meta []middleware.KV, key string) (string, bool) {
for _, kv := range meta {
if kv.Key == key {
return kv.Value, true
}
}
return "", false
}