diff --git a/e2e/agentnetwork/discovery_live_test.go b/e2e/agentnetwork/discovery_live_test.go
index 321e751bf..f71e178dc 100644
--- a/e2e/agentnetwork/discovery_live_test.go
+++ b/e2e/agentnetwork/discovery_live_test.go
@@ -169,17 +169,15 @@ func liveDiscoveryCases() []liveDiscoveryCase {
// Bedrock lists inference profiles, not models: matchModelless routes
// /inference-profiles to a Bedrock route and refuses /v1/models for one.
//
- // The request reaches AWS and AWS refuses it — bedrock-runtime answers
- // , because ListInferenceProfiles is a CONTROL
- // PLANE operation served by bedrock..amazonaws.com, not the runtime
- // host. A provider record carries one upstream and it has to be the runtime
- // host for InvokeModel to work, so no Bedrock record can serve a listing as
- // the model stands today.
+ // The listing is served by the CONTROL PLANE (bedrock.), not the
+ // runtime host a provider record must point at for InvokeModel — the
+ // runtime host answers . The router now sends
+ // the listing, and only the listing, to the control plane, so this case
+ // asserts a real filtered listing rather than the 404 it used to get.
//
- // The mock upstream hides this entirely: it answers /inference-profiles on
- // the same listener as everything else, so the routing test passes there
- // while the real endpoint 404s. That is the whole reason this file exists,
- // so the case is kept, asserting what actually happens.
+ // The mock upstream cannot show any of this: it answers
+ // /inference-profiles on the same listener as everything else, so a
+ // mock-based test passes whichever host the request went to.
if k := os.Getenv("AWS_BEARER_TOKEN_BEDROCK"); k != "" {
region := os.Getenv("AWS_REGION")
if region == "" {
@@ -192,9 +190,13 @@ func liveDiscoveryCases() []liveDiscoveryCase {
cases = append(cases, liveDiscoveryCase{
name: "bedrock", catalogID: "bedrock_api",
upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k,
- path: "/inference-profiles",
- models: []string{sharedllm.NormalizeAnthropicModel(strings.TrimPrefix(model, "global."))},
- outcome: outcomeUpstreamNoListing,
+ path: "/inference-profiles",
+ // Registered verbatim, as an operator would copy it from AWS: the
+ // region prefix is what makes the id invocable, and the listing
+ // returns ids in exactly this form.
+ models: []string{model},
+ outcome: outcomeFiltered,
+ permitted: []string{model},
})
}
@@ -342,8 +344,11 @@ func runLiveDiscoveryCase(t *testing.T, ctx context.Context, tc liveDiscoveryCas
}
for _, id := range ids {
_, direct := permitted[id]
- _, normalised := permitted[sharedllm.NormalizeAnthropicModel(id)]
- assert.Truef(t, direct || normalised,
+ _, dated := permitted[sharedllm.NormalizeAnthropicModel(id)]
+ // Bedrock ids carry a region prefix and version suffix the record may
+ // not repeat; the proxy's filter tries the same forms.
+ _, bedrock := permitted[sharedllm.NormalizeBedrockModel(id)]
+ assert.Truef(t, direct || dated || bedrock,
"%s offered %q, which no policy on this route permits — every entry the picker shows must be a request the guardrail would allow", tc.name, id)
}
for _, hidden := range tc.wantHidden {
diff --git a/management/internals/modules/agentnetwork/modeldiscovery/discovery.go b/management/internals/modules/agentnetwork/modeldiscovery/discovery.go
index 7fdef7c6f..14e7b240f 100644
--- a/management/internals/modules/agentnetwork/modeldiscovery/discovery.go
+++ b/management/internals/modules/agentnetwork/modeldiscovery/discovery.go
@@ -171,7 +171,7 @@ func (c *Client) discoveryURL(entry catalog.Provider, req Request) (string, erro
// A provider record carries no region field: the region lives
// inside the upstream host the operator already configured, so
// read it back out rather than asking them for it twice.
- region = regionFromUpstream(entry, req.UpstreamURL)
+ region = RegionFromUpstream(entry, req.UpstreamURL)
}
if region == "" {
return "", fmt.Errorf("%s discovery needs a region, and none could be read from the provider upstream", entry.Name)
@@ -186,13 +186,13 @@ func (c *Client) discoveryURL(entry catalog.Provider, req Request) (string, erro
return target.String(), nil
}
-// regionFromUpstream recovers the region an operator embedded in the provider
+// RegionFromUpstream recovers the region an operator embedded in the provider
// upstream, by matching it against the catalog's own host template. Bedrock's
// template is "bedrock-runtime..amazonaws.com" and Vertex's is
// "-aiplatform.googleapis.com", so the region is whatever sits between
// the fixed halves. Returns empty when the upstream does not match the
// template, which is the case for a custom or proxied endpoint.
-func regionFromUpstream(entry catalog.Provider, upstreamURL string) string {
+func RegionFromUpstream(entry catalog.Provider, upstreamURL string) string {
prefix, suffix, found := strings.Cut(entry.DefaultHost, catalog.RegionPlaceholder)
if !found {
return ""
diff --git a/management/internals/modules/agentnetwork/modeldiscovery/discovery_test.go b/management/internals/modules/agentnetwork/modeldiscovery/discovery_test.go
index 23827bbf6..2a2939bf5 100644
--- a/management/internals/modules/agentnetwork/modeldiscovery/discovery_test.go
+++ b/management/internals/modules/agentnetwork/modeldiscovery/discovery_test.go
@@ -315,7 +315,7 @@ func TestRegionFromUpstream(t *testing.T) {
{"vertex global host has no region segment", vertex, "https://aiplatform.googleapis.com", ""},
} {
t.Run(tc.name, func(t *testing.T) {
- assert.Equal(t, tc.want, regionFromUpstream(tc.entry, tc.upstream))
+ assert.Equal(t, tc.want, RegionFromUpstream(tc.entry, tc.upstream))
})
}
}
diff --git a/management/internals/modules/agentnetwork/synthesizer.go b/management/internals/modules/agentnetwork/synthesizer.go
index 76944698e..66a19acd9 100644
--- a/management/internals/modules/agentnetwork/synthesizer.go
+++ b/management/internals/modules/agentnetwork/synthesizer.go
@@ -10,6 +10,7 @@ import (
"strings"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
+ "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/modeldiscovery"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey"
@@ -380,6 +381,9 @@ type routerProviderRoute struct {
// proxy dials this provider's upstream. For self-hosted / internal gateways
// behind a private or self-signed certificate.
SkipTLSVerify bool `json:"skip_tls_verify,omitempty"`
+ // DiscoveryHost, when set, is the host serving this provider's model
+ // listing, for a vendor that does not serve it from the inference host.
+ DiscoveryHost string `json:"discovery_host,omitempty"`
}
// indexProviderGroups walks the enabled policies and returns, per
@@ -447,6 +451,9 @@ func buildRouterConfigJSON(providers []*types.Provider, groupIndex map[string][]
if err != nil {
return nil, fmt.Errorf("router config for provider %s: %w", p.ID, err)
}
+ // Lookup rather than assume: an unknown provider id yields the zero
+ // entry, which declares no discovery and so contributes nothing.
+ catalogEntry, _ := catalog.Lookup(p.ProviderID)
headerName, headerValue, gcpSAKeyB64, err := providerAuthHeader(p)
if err != nil {
return nil, err
@@ -466,6 +473,7 @@ func buildRouterConfigJSON(providers []*types.Provider, groupIndex map[string][]
Bedrock: catalog.IsBedrockPathStyle(p.ProviderID),
GCPServiceAccountKeyB64: gcpSAKeyB64,
SkipTLSVerify: p.SkipTLSVerification,
+ DiscoveryHost: discoveryHost(catalogEntry, p.UpstreamURL),
})
}
out, err := json.Marshal(cfg)
@@ -475,6 +483,33 @@ func buildRouterConfigJSON(providers []*types.Provider, groupIndex map[string][]
return out, nil
}
+// discoveryHost returns the host serving this provider's model listing when it
+// differs from the inference host, and empty when the two are the same — which
+// is true of every vendor but Bedrock, whose ListInferenceProfiles is a control
+// plane operation on bedrock. while InvokeModel must go to
+// bedrock-runtime.. One provider record therefore needs two hosts.
+//
+// The catalog declares the listing host; the region is recovered from the
+// upstream the operator configured, since a provider record carries no region
+// field. An upstream matching no catalog template yields empty rather than a
+// guess: a proxied or self-hosted Bedrock endpoint may serve both from one
+// place, and inventing a host would send the credential somewhere the operator
+// never configured.
+func discoveryHost(entry catalog.Provider, upstreamURL string) string {
+ if entry.Discovery == nil || entry.Discovery.Host == "" {
+ return ""
+ }
+ host := entry.Discovery.Host
+ if !strings.Contains(host, catalog.RegionPlaceholder) {
+ return host
+ }
+ region := modeldiscovery.RegionFromUpstream(entry, upstreamURL)
+ if region == "" {
+ return ""
+ }
+ return strings.ReplaceAll(host, catalog.RegionPlaceholder, region)
+}
+
// providerVendor returns the parser surface ("openai", "anthropic", …)
// the provider speaks, sourced from its catalog entry's ParserID. The
// router uses it to keep a request the parser tagged with a vendor on a
diff --git a/management/internals/modules/agentnetwork/synthesizer_test.go b/management/internals/modules/agentnetwork/synthesizer_test.go
index 387f44b74..01a3aaac0 100644
--- a/management/internals/modules/agentnetwork/synthesizer_test.go
+++ b/management/internals/modules/agentnetwork/synthesizer_test.go
@@ -10,6 +10,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
"github.com/netbirdio/netbird/management/server/store"
@@ -1245,3 +1246,57 @@ func TestSynthesizeServices_EmptyAPIKey_FailsClosed(t *testing.T) {
require.Error(t, err, "synthesis must refuse a provider with no api key")
assert.Contains(t, err.Error(), "no api key", "error must surface the missing credential")
}
+
+// TestDiscoveryHost pins which providers get a separate listing host. Getting
+// this wrong in either direction is costly: a missing host leaves Bedrock
+// discovery 404ing at AWS, and a host on the wrong provider would send that
+// provider's listing — and its credential — somewhere the operator never
+// configured.
+func TestDiscoveryHost(t *testing.T) {
+ entry := func(id string) catalog.Provider {
+ p, ok := catalog.Lookup(id)
+ require.True(t, ok, "catalog entry %s must exist", id)
+ return p
+ }
+
+ for _, tc := range []struct {
+ name string
+ entry catalog.Provider
+ upstream string
+ want string
+ }{
+ {
+ // ListInferenceProfiles is a control-plane operation; the runtime
+ // host answers for it.
+ name: "bedrock splits the listing off the runtime host",
+ entry: entry("bedrock_api"), upstream: "https://bedrock-runtime.eu-central-1.amazonaws.com",
+ want: "bedrock.eu-central-1.amazonaws.com",
+ },
+ {
+ name: "bedrock in another region",
+ entry: entry("bedrock_api"), upstream: "https://bedrock-runtime.us-west-2.amazonaws.com",
+ want: "bedrock.us-west-2.amazonaws.com",
+ },
+ {
+ // A proxied Bedrock endpoint may well serve both from one place,
+ // and there is no region to read back out of it.
+ name: "proxied bedrock upstream yields no discovery host",
+ entry: entry("bedrock_api"), upstream: "https://bedrock.internal.example.com",
+ want: "",
+ },
+ {
+ name: "openai serves its listing from the same host",
+ entry: entry("openai_api"), upstream: "https://api.openai.com",
+ want: "",
+ },
+ {
+ name: "vertex serves its listing from the same host",
+ entry: entry("vertex_ai_api"), upstream: "https://us-east5-aiplatform.googleapis.com",
+ want: "",
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ assert.Equal(t, tc.want, discoveryHost(tc.entry, tc.upstream))
+ })
+ }
+}
diff --git a/proxy/internal/middleware/builtin/llm_router/bedrock_discovery_test.go b/proxy/internal/middleware/builtin/llm_router/bedrock_discovery_test.go
new file mode 100644
index 000000000..d50b06e92
--- /dev/null
+++ b/proxy/internal/middleware/builtin/llm_router/bedrock_discovery_test.go
@@ -0,0 +1,133 @@
+package llm_router
+
+import (
+ "context"
+ "net/http"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/netbirdio/netbird/proxy/internal/middleware"
+)
+
+// bedrockRoute is a Bedrock provider whose listing lives on the control plane
+// while inference goes to the runtime host — the split this file is about.
+func bedrockRoute(models []string, policies []ModelPolicyRule) ProviderRoute {
+ return ProviderRoute{
+ ID: "prov-bedrock",
+ Bedrock: true,
+ Models: models,
+ ModelPolicies: policies,
+ UpstreamScheme: "https",
+ UpstreamHost: "bedrock-runtime.eu-central-1.amazonaws.com",
+ DiscoveryHost: "bedrock.eu-central-1.amazonaws.com",
+ AuthHeaderName: "Authorization",
+ AuthHeaderValue: "Bearer aws-token",
+ AllowedGroupIDs: []string{defaultTestGroup},
+ }
+}
+
+func getInput(path string) *middleware.Input {
+ return &middleware.Input{
+ Slot: middleware.SlotOnRequest,
+ Method: http.MethodGet,
+ URL: "https://endpoint.netbird.local" + path,
+ UserGroups: []string{defaultTestGroup},
+ }
+}
+
+// TestBedrockListingGoesToTheControlPlane is the whole point of DiscoveryHost.
+// ListInferenceProfiles is not an operation bedrock-runtime implements — it
+// answers — so a listing forwarded to the
+// inference upstream can only 404, however well it is routed.
+func TestBedrockListingGoesToTheControlPlane(t *testing.T) {
+ mw := New(Config{Providers: []ProviderRoute{bedrockRoute(nil, nil)}})
+
+ out, err := mw.Invoke(context.Background(), getInput("/inference-profiles"))
+ require.NoError(t, err)
+ require.Equal(t, middleware.DecisionAllow, out.Decision)
+ require.NotNil(t, out.Mutations)
+ require.NotNil(t, out.Mutations.RewriteUpstream)
+
+ assert.Equal(t, "bedrock.eu-central-1.amazonaws.com", out.Mutations.RewriteUpstream.Host)
+}
+
+// TestBedrockInferenceStillGoesToTheRuntimeHost is the other half: the
+// redirect must apply to the listing alone. Sending an InvokeModel call to the
+// control plane would break every Bedrock request in the account.
+func TestBedrockInferenceStillGoesToTheRuntimeHost(t *testing.T) {
+ mw := New(Config{Providers: []ProviderRoute{bedrockRoute(nil, nil)}})
+
+ in := newInputWithModelAndURL("anthropic.claude-haiku-4-5",
+ "https://endpoint.netbird.local/model/eu.anthropic.claude-haiku-4-5-20251001-v1:0/invoke")
+ out, err := mw.Invoke(context.Background(), in)
+ require.NoError(t, err)
+ require.Equal(t, middleware.DecisionAllow, out.Decision)
+ require.NotNil(t, out.Mutations.RewriteUpstream)
+
+ assert.Equal(t, "bedrock-runtime.eu-central-1.amazonaws.com", out.Mutations.RewriteUpstream.Host)
+}
+
+// TestIsListingPath guards the narrower reading of "model-less". Both the
+// upstream redirect and the policy bound key on this, and the warming probe
+// must be excluded from both: it carries no listing to filter, and pointing it
+// at the control plane would warm a pool the inference requests never use.
+func TestIsListingPath(t *testing.T) {
+ for path, want := range map[string]bool{
+ "/v1/models": true,
+ "/inference-profiles": true,
+ "/bedrock/inference-profiles": true,
+ "/api/hello": false,
+ "/v1/models/gpt-4o": false, // the per-model lookup, routed elsewhere
+ "/v1/chat/completions": false,
+ } {
+ t.Run(path, func(t *testing.T) {
+ assert.Equal(t, want, isListingPath(path))
+ })
+ }
+}
+
+// TestBedrockListingIsBoundByPolicy covers the case that was previously
+// unreachable: filtering keyed on /v1/models alone, so a Bedrock listing was
+// routed but never narrowed to what the caller may use.
+func TestBedrockListingIsBoundByPolicy(t *testing.T) {
+ route := bedrockRoute(
+ []string{"eu.anthropic.claude-haiku-4-5-20251001-v1:0", "eu.anthropic.claude-sonnet-4-6"},
+ []ModelPolicyRule{{
+ GroupIDs: []string{defaultTestGroup},
+ // A guardrail allowlist names the catalog key, which is the form an
+ // operator picks in the UI — not the region-prefixed wire id the
+ // record registers.
+ Models: []string{"anthropic.claude-haiku-4-5"},
+ }},
+ )
+ mw := New(Config{Providers: []ProviderRoute{route}})
+
+ out, err := mw.Invoke(context.Background(), getInput("/inference-profiles"))
+ require.NoError(t, err)
+ require.NotNil(t, out.Mutations.RewriteUpstream)
+
+ // Exact-string intersection would find nothing here and bound the listing
+ // to empty, handing the caller a picker with no models on a provider that
+ // works perfectly well.
+ assert.Equal(t, []string{"eu.anthropic.claude-haiku-4-5-20251001-v1:0"},
+ out.Mutations.RewriteUpstream.DiscoveryModels)
+}
+
+// TestBedrockListingWithoutADiscoveryHostFallsThrough keeps a proxied or
+// self-hosted Bedrock endpoint working: the synthesiser emits no discovery
+// host for one, and the listing must then go to the configured upstream rather
+// than nowhere.
+func TestBedrockListingWithoutADiscoveryHostFallsThrough(t *testing.T) {
+ route := bedrockRoute(nil, nil)
+ route.UpstreamHost = "bedrock.internal.example.com"
+ route.DiscoveryHost = ""
+ mw := New(Config{Providers: []ProviderRoute{route}})
+
+ out, err := mw.Invoke(context.Background(), getInput("/inference-profiles"))
+ require.NoError(t, err)
+ require.NotNil(t, out.Mutations.RewriteUpstream)
+
+ assert.Equal(t, "bedrock.internal.example.com", out.Mutations.RewriteUpstream.Host)
+}
diff --git a/proxy/internal/middleware/builtin/llm_router/factory.go b/proxy/internal/middleware/builtin/llm_router/factory.go
index ae3d44a40..81b8727f1 100644
--- a/proxy/internal/middleware/builtin/llm_router/factory.go
+++ b/proxy/internal/middleware/builtin/llm_router/factory.go
@@ -50,6 +50,13 @@ type ProviderRoute struct {
// under different allowlists must not offer either group the other's
// models. Empty means no policy restricts models on this route.
ModelPolicies []ModelPolicyRule `json:"model_policies,omitempty"`
+ // DiscoveryHost, when set, is the host that serves this provider's model
+ // listing, for a vendor that does not serve it from the same host as
+ // inference. Bedrock is why it exists: ListInferenceProfiles is a control
+ // plane operation on bedrock., while InvokeModel must go to
+ // bedrock-runtime., so one record genuinely needs two hosts.
+ // Empty means the listing is served from UpstreamHost like everything else.
+ DiscoveryHost string `json:"discovery_host,omitempty"`
// Vertex marks a Google Vertex AI provider. Vertex requests carry the
// model in the URL path, so the router selects this route by path
// (isVertexPath) and bypasses the model/vendor table entirely.
diff --git a/proxy/internal/middleware/builtin/llm_router/middleware.go b/proxy/internal/middleware/builtin/llm_router/middleware.go
index 01981666c..7afd7afd9 100644
--- a/proxy/internal/middleware/builtin/llm_router/middleware.go
+++ b/proxy/internal/middleware/builtin/llm_router/middleware.go
@@ -242,10 +242,16 @@ func (m *Middleware) routeModelless(reqPath, surface, method string, userGroups
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 isListingPath(reqPath) && out.Mutations != nil && out.Mutations.RewriteUpstream != nil {
+ // A vendor that serves its listing from somewhere other than its
+ // inference upstream is redirected here, and only for the listing
+ // — every other request still goes to the configured upstream.
+ if route.DiscoveryHost != "" {
+ out.Mutations.RewriteUpstream.Host = route.DiscoveryHost
+ }
+ // 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 models, bounded := discoverableModels(route, userGroups); bounded {
out.Mutations.RewriteUpstream.DiscoveryModels = models
}
@@ -310,6 +316,20 @@ func discoverableModels(route ProviderRoute, userGroups []string) ([]string, boo
for _, m := range route.Models {
if _, ok := permitted[m]; ok {
intersection[m] = struct{}{}
+ continue
+ }
+ // The two sides are not always written the same way. A Bedrock record
+ // may register the raw inference-profile id an operator copied from
+ // AWS while a guardrail allowlist names the catalog key, and comparing
+ // those verbatim finds nothing — which would bound a correctly
+ // configured provider's listing down to empty. routeClaimsModel
+ // already normalises the candidate for exactly this reason, and the
+ // listing bound has to agree with it or the picker disagrees with what
+ // the guardrail will actually allow.
+ if route.Bedrock {
+ if _, ok := permitted[llm.NormalizeBedrockModel(m)]; ok {
+ intersection[m] = struct{}{}
+ }
}
}
return sortedModels(intersection), true
@@ -472,6 +492,14 @@ const connectionWarmPath = "/api/hello"
// alone.
const modelListingPath = "/v1/models"
+// isListingPath reports whether reqPath asks for a MODEL LISTING, as opposed
+// to the other model-less endpoints. Only a listing gets an upstream redirect
+// and a policy bound: the connection-warming probe carries no model list to
+// filter, and rewriting its host would send the warm-up to the wrong pool.
+func isListingPath(reqPath string) bool {
+ return reqPath == modelListingPath || isBedrockModelLessPath(reqPath)
+}
+
// 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
diff --git a/proxy/internal/proxy/discovery_filter.go b/proxy/internal/proxy/discovery_filter.go
index 7b5f8d415..365bbc9db 100644
--- a/proxy/internal/proxy/discovery_filter.go
+++ b/proxy/internal/proxy/discovery_filter.go
@@ -97,6 +97,18 @@ func isPlainJSONListing(resp *http.Response) bool {
return strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "application/json")
}
+// listingEnvelopes maps a listing's wrapper key to the field naming the model
+// id inside it. Vendors did not converge on one shape: OpenAI's is what
+// Anthropic adopted, while Bedrock returns inference-profile summaries under a
+// key of its own. A body matching none of these is forwarded untouched.
+var listingEnvelopes = []struct {
+ key string
+ idField string
+}{
+ {"data", "id"},
+ {"inferenceProfileSummaries", "inferenceProfileId"},
+}
+
// filterListingBody returns the listing with unauthorised entries removed.
// ok is false when the body is not a listing shape, in which case the
// caller must forward the original bytes.
@@ -105,38 +117,41 @@ func filterListingBody(body []byte, permitted map[string]struct{}) ([]byte, bool
if err := json.Unmarshal(body, &doc); err != nil {
return nil, false
}
- raw, present := doc["data"]
- if !present {
- return nil, false
- }
- var entries []map[string]json.RawMessage
- if err := json.Unmarshal(raw, &entries); err != nil {
- return nil, false
- }
-
- kept := make([]map[string]json.RawMessage, 0, len(entries))
- for _, entry := range entries {
- if entryPermitted(entry, permitted) {
- kept = append(kept, entry)
+ for _, envelope := range listingEnvelopes {
+ raw, present := doc[envelope.key]
+ if !present {
+ continue
+ }
+ var entries []map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &entries); err != nil {
+ return nil, false
}
- }
- encoded, err := json.Marshal(kept)
- if err != nil {
- return nil, false
+ kept := make([]map[string]json.RawMessage, 0, len(entries))
+ for _, entry := range entries {
+ if entryPermitted(entry, envelope.idField, permitted) {
+ kept = append(kept, entry)
+ }
+ }
+
+ encoded, err := json.Marshal(kept)
+ if err != nil {
+ return nil, false
+ }
+ doc[envelope.key] = encoded
+ out, err := json.Marshal(doc)
+ if err != nil {
+ return nil, false
+ }
+ return out, true
}
- doc["data"] = encoded
- out, err := json.Marshal(doc)
- if err != nil {
- return nil, false
- }
- return out, true
+ return nil, false
}
// entryPermitted reports whether a listing entry names a model the policy
// authorises, trying every form the same model is written in.
-func entryPermitted(entry map[string]json.RawMessage, permitted map[string]struct{}) bool {
- raw, ok := entry["id"]
+func entryPermitted(entry map[string]json.RawMessage, idField string, permitted map[string]struct{}) bool {
+ raw, ok := entry[idField]
if !ok {
return false
}
@@ -162,6 +177,13 @@ func modelIDForms(id string) []string {
return nil
}
forms := []string{id, sharedllm.NormalizeAnthropicModel(id)}
+ // A Bedrock listing returns region-prefixed, version-suffixed profile ids
+ // ("eu.anthropic.claude-haiku-4-5-20251001-v1:0") while the record may
+ // register the catalog key. Stripping to the key is a no-op for ids that
+ // carry neither, so this costs nothing on the other surfaces.
+ if bedrock := sharedllm.NormalizeBedrockModel(id); bedrock != id {
+ forms = append(forms, bedrock)
+ }
if slash := strings.LastIndex(id, "/"); slash >= 0 {
tail := id[slash+1:]
forms = append(forms, tail, sharedllm.NormalizeAnthropicModel(tail))
diff --git a/proxy/internal/proxy/discovery_filter_test.go b/proxy/internal/proxy/discovery_filter_test.go
index 85dddf230..84b6a8639 100644
--- a/proxy/internal/proxy/discovery_filter_test.go
+++ b/proxy/internal/proxy/discovery_filter_test.go
@@ -215,3 +215,40 @@ func TestModelDiscoveryFilter_OversizedBodyKeepsUpstreamHeaders(t *testing.T) {
assert.Equal(t, strconv.Itoa(len(body)), resp.Header.Get("Content-Length"),
"the Content-Length header must not be rewritten to the truncated prefix")
}
+
+// TestFilterBedrockInferenceProfiles covers the second listing envelope. AWS
+// returns inference-profile summaries under a key of its own with an id field
+// of its own, so a filter that only knew OpenAI's shape forwarded a Bedrock
+// listing whole — offering every profile in the account regardless of policy.
+func TestFilterBedrockInferenceProfiles(t *testing.T) {
+ body := []byte(`{"inferenceProfileSummaries":[
+ {"inferenceProfileId":"eu.anthropic.claude-haiku-4-5-20251001-v1:0","status":"ACTIVE"},
+ {"inferenceProfileId":"eu.anthropic.claude-sonnet-4-6","status":"ACTIVE"},
+ {"inferenceProfileId":"global.cohere.embed-v4:0","status":"ACTIVE"}
+ ]}`)
+
+ // The permitted set holds what the record registers. Here that is the
+ // catalog key, while the vendor answers with region-prefixed wire ids —
+ // the two must still line up.
+ permitted := map[string]struct{}{"anthropic.claude-haiku-4-5": {}}
+
+ out, ok := filterListingBody(body, permitted)
+ require.True(t, ok, "a Bedrock listing must be recognised as filterable")
+
+ var doc struct {
+ Summaries []struct {
+ ID string `json:"inferenceProfileId"`
+ } `json:"inferenceProfileSummaries"`
+ }
+ require.NoError(t, json.Unmarshal(out, &doc))
+ require.Len(t, doc.Summaries, 1)
+ assert.Equal(t, "eu.anthropic.claude-haiku-4-5-20251001-v1:0", doc.Summaries[0].ID)
+}
+
+// TestFilterLeavesUnknownEnvelopesAlone keeps the best-effort contract: a body
+// the filter cannot parse must reach the client exactly as the upstream sent
+// it, rather than being rewritten into something shorter and wrong.
+func TestFilterLeavesUnknownEnvelopesAlone(t *testing.T) {
+ _, ok := filterListingBody([]byte(`{"models":[{"name":"something"}]}`), map[string]struct{}{})
+ assert.False(t, ok)
+}