mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-18 20:59:07 +02:00
[proxy] Bound the model-listing response to what policy authorises
Discovery proxies the upstream's full list, so the picker offers every model the shared provider key can reach and each one outside the policy is a request the chain denies a moment later. Restricting models is the point of the product, and the client had no way to see the restriction. Carry the resolved route's model list on the upstream rewrite and drop the rest from the listing response. Only a route that enumerates its models bounds anything: a catch-all claims every model, so its list passes through. Anything the filter cannot safely rewrite, including a compressed or oversized body, reaches the client untouched.
This commit is contained in:
@@ -199,8 +199,16 @@ func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middlewar
|
||||
case matchOutcomeFound:
|
||||
out := m.allowWithRoute(route, surface, in.UserGroups)
|
||||
out.Metadata = append(out.Metadata, middleware.KV{Key: middleware.KeyLLMNonInference, Value: "true"})
|
||||
if _, hadPrefix := splitBedrockNamespace(reqPath); hadPrefix && out.Mutations != nil && out.Mutations.RewriteUpstream != nil {
|
||||
out.Mutations.RewriteUpstream.StripPathPrefix = bedrockNamespacePrefix
|
||||
if out.Mutations != nil && out.Mutations.RewriteUpstream != nil {
|
||||
if _, hadPrefix := splitBedrockNamespace(reqPath); hadPrefix {
|
||||
out.Mutations.RewriteUpstream.StripPathPrefix = bedrockNamespacePrefix
|
||||
}
|
||||
// A route that enumerates its models bounds what the caller
|
||||
// may use, so the picker must not offer the rest: every
|
||||
// entry outside the list is a request the chain will deny.
|
||||
if reqPath == modelListingPath && len(route.Models) > 0 {
|
||||
out.Mutations.RewriteUpstream.DiscoveryModels = append([]string(nil), route.Models...)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
case matchOutcomeUnauthorised:
|
||||
@@ -318,9 +326,15 @@ const connectionWarmPath = "/api/hello"
|
||||
// that legitimately carries no model in its request (model listing and the
|
||||
// connection-warming probe). These must route to an upstream rather than
|
||||
// deny, so model enumeration works end to end.
|
||||
// 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"
|
||||
|
||||
func isModelLessPath(reqPath string) bool {
|
||||
return reqPath == "/v1/models" ||
|
||||
strings.HasPrefix(reqPath, "/v1/models/") ||
|
||||
return reqPath == modelListingPath ||
|
||||
strings.HasPrefix(reqPath, modelListingPath+"/") ||
|
||||
reqPath == connectionWarmPath
|
||||
}
|
||||
|
||||
|
||||
@@ -935,3 +935,48 @@ func TestRouter_ConnectionWarmProbeRoutes(t *testing.T) {
|
||||
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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -253,6 +253,12 @@ type UpstreamRewrite struct {
|
||||
// without verifying its TLS certificate. Set by llm_router from the
|
||||
// provider's skip_tls_verification for self-hosted / internal gateways.
|
||||
SkipTLSVerify bool
|
||||
// DiscoveryModels, when non-empty, is the set of model ids the resolved
|
||||
// route authorises, and the proxy drops everything else from the
|
||||
// model-listing response. Empty leaves the upstream's list untouched,
|
||||
// which is what a route that claims every model wants. Set by
|
||||
// llm_router on a model-listing request only.
|
||||
DiscoveryModels []string
|
||||
}
|
||||
|
||||
// AuthHeader is a single name/value pair the proxy injects on the
|
||||
|
||||
Reference in New Issue
Block a user