mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-22 16:31:28 +02:00
[proxy] match Bedrock provider models against the normalized request model (#6773)
## Describe your changes
Native AWS Bedrock requests carry the model in the URL path as a
cross-region inference-profile id (e.g.
`us.anthropic.claude-haiku-4-5`). The request parser normalizes that to
the catalog key (`anthropic.claude-haiku-4-5`) before the router runs,
but the router matched it against the operator's registered provider
models with exact string equality. So a Bedrock provider registered with
the id Bedrock actually uses (`us.anthropic…`) never matched a
normalized request → the request denied with
`llm_policy.model_not_routable` ("no provider configured for model …").
Only a provider registered with the already-stripped catalog id worked,
which is not how Bedrock ids appear.
Fix: introduce a single shared `llm.NormalizeBedrockModel` (the same
ARN/region-prefix/version-suffix stripping the parser already does) and,
in the router's `routeClaimsModel`, normalize a **Bedrock** route's
candidate models before comparing. Now a Bedrock provider registered
with either the raw inference-profile id or the normalized catalog id
matches the request. Non-Bedrock routes keep exact matching.
Surfaced by the new native-Bedrock e2e (`WireBedrock`,
`/model/{id}/invoke`); the old e2e used the Anthropic body shape, which
never normalized either side and so hid this.
The request parser keeps its own identical normalizer for now;
de-duplicating it onto `llm.NormalizeBedrockModel` is a trivial
follow-up.
## Issue ticket number and link
N/A — follow-up to the Agent Network Bedrock support / model-allowlist
work.
## Stack
<!-- branch-stack -->
### Checklist
- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [x] Created tests that fail without the change (if possible)
- [x] This change does **not** modify the public API, gRPC protocols,
functionality behavior, CLI / service flags, or introduce a new feature
— **OR** I have discussed it with the NetBird team beforehand (link the
issue / Slack thread in the description). See
[CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first).
> By submitting this pull request, you confirm that you have read and
agree to the terms of the [Contributor License
Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).
## Documentation
Select exactly one:
- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)
Internal routing correctness fix; no user-facing surface change.
### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:
https://github.com/netbirdio/docs/pull/__
## Tests
- `proxy/internal/llm`: `NormalizeBedrockModel` unit cases (region
prefixes, version suffixes, ARN).
- `proxy/internal/middleware/builtin/llm_router`: `routeClaimsModel`
matches a Bedrock route registered with the raw `us.anthropic…` id
against a normalized request model; non-Bedrock routes still match
exactly.
Note: full through-tunnel e2e verification of this (the native-Bedrock
`TestProvidersMatrix/bedrock`) also needs the DNS lazy-connection
warm-up (separate PR) to get the client past the proxy-peer gate; they
converge once both land.
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Bug Fixes**
* Improved Amazon Bedrock model matching across ARN formats, regional
prefixes, and version or throughput suffixes.
* Bedrock routes now correctly match equivalent model identifiers even
when requests and route configurations use different formats.
* Non-Bedrock model matching remains exact.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
38
proxy/internal/llm/bedrock_model.go
Normal file
38
proxy/internal/llm/bedrock_model.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// bedrockRegionPrefixes are the cross-region inference-profile prefixes that
|
||||||
|
// front a Bedrock model id (e.g. "eu.anthropic.claude-...").
|
||||||
|
var bedrockRegionPrefixes = []string{"us.", "eu.", "apac.", "global."}
|
||||||
|
|
||||||
|
// bedrockVersionSuffix matches the trailing "-vN[:N]" or "-YYYYMMDD-vN[:N]"
|
||||||
|
// version/throughput suffix of a Bedrock model id.
|
||||||
|
var bedrockVersionSuffix = regexp.MustCompile(`-(\d{8}-)?v\d+(:\d+)?$`)
|
||||||
|
|
||||||
|
// NormalizeBedrockModel strips an ARN wrapper, a cross-region inference-profile
|
||||||
|
// prefix, and the version/throughput suffix from a Bedrock model id so it
|
||||||
|
// matches the catalog/pricing key, e.g.
|
||||||
|
// "eu.anthropic.claude-sonnet-4-5-20250929-v1:0" -> "anthropic.claude-sonnet-4-5"
|
||||||
|
// and the inference-profile ARN's last segment likewise. It is the single
|
||||||
|
// source of truth shared by the request parser (which normalizes the request
|
||||||
|
// model from the URL path) and the router (which normalizes the operator's
|
||||||
|
// registered Bedrock model ids so both sides compare equal).
|
||||||
|
func NormalizeBedrockModel(modelID string) string {
|
||||||
|
m := modelID
|
||||||
|
if strings.HasPrefix(m, "arn:") {
|
||||||
|
if i := strings.LastIndex(m, "/"); i >= 0 {
|
||||||
|
m = m[i+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range bedrockRegionPrefixes {
|
||||||
|
if strings.HasPrefix(m, p) {
|
||||||
|
m = m[len(p):]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bedrockVersionSuffix.ReplaceAllString(m, "")
|
||||||
|
}
|
||||||
23
proxy/internal/llm/bedrock_model_test.go
Normal file
23
proxy/internal/llm/bedrock_model_test.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeBedrockModel(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"eu.anthropic.claude-sonnet-4-5-20250929-v1:0": "anthropic.claude-sonnet-4-5",
|
||||||
|
"us.anthropic.claude-haiku-4-5": "anthropic.claude-haiku-4-5",
|
||||||
|
"us.anthropic.claude-opus-4-8-20250101-v1:0": "anthropic.claude-opus-4-8",
|
||||||
|
"anthropic.claude-sonnet-4-5-20250929-v1:0": "anthropic.claude-sonnet-4-5",
|
||||||
|
"meta.llama3-3-70b-instruct-v1:0": "meta.llama3-3-70b-instruct",
|
||||||
|
"amazon.nova-pro-v1:0": "amazon.nova-pro",
|
||||||
|
// Inference-profile ARN — model id lives in the last path segment.
|
||||||
|
"arn:aws:bedrock:eu-central-1:123456789012:inference-profile/eu.anthropic.claude-sonnet-4-5-20250929-v1:0": "anthropic.claude-sonnet-4-5",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
require.Equal(t, want, NormalizeBedrockModel(in), "normalize %q", in)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package llm_router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRouteClaimsModel_BedrockNormalizesCandidate guards the fix for the native
|
||||||
|
// Bedrock routing gap: the request model reaches the router already normalized
|
||||||
|
// (the parser strips the region/inference-profile prefix and version suffix),
|
||||||
|
// so a provider registered with the raw inference-profile id must still match.
|
||||||
|
func TestRouteClaimsModel_BedrockNormalizesCandidate(t *testing.T) {
|
||||||
|
route := ProviderRoute{Bedrock: true, Models: []string{"us.anthropic.claude-haiku-4-5"}}
|
||||||
|
assert.True(t, routeClaimsModel(route, "anthropic.claude-haiku-4-5"),
|
||||||
|
"raw region-prefixed Bedrock model must match the normalized request model")
|
||||||
|
assert.False(t, routeClaimsModel(route, "anthropic.claude-opus-4-8"),
|
||||||
|
"a model outside the provider's list must not match")
|
||||||
|
|
||||||
|
// A provider registered with the already-normalized id also matches.
|
||||||
|
normalized := ProviderRoute{Bedrock: true, Models: []string{"anthropic.claude-haiku-4-5"}}
|
||||||
|
assert.True(t, routeClaimsModel(normalized, "anthropic.claude-haiku-4-5"),
|
||||||
|
"normalized Bedrock model must match")
|
||||||
|
|
||||||
|
// Non-Bedrock routes keep exact matching (no prefix stripping).
|
||||||
|
openai := ProviderRoute{Models: []string{"gpt-4o"}}
|
||||||
|
assert.True(t, routeClaimsModel(openai, "gpt-4o"), "exact model must match")
|
||||||
|
assert.False(t, routeClaimsModel(openai, "us.gpt-4o"),
|
||||||
|
"non-Bedrock routes must not strip a us. prefix")
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
"golang.org/x/oauth2/google"
|
"golang.org/x/oauth2/google"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/proxy/internal/llm"
|
||||||
"github.com/netbirdio/netbird/proxy/internal/middleware"
|
"github.com/netbirdio/netbird/proxy/internal/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -555,6 +556,14 @@ func routeClaimsModel(route ProviderRoute, model string) bool {
|
|||||||
if candidate == model {
|
if candidate == model {
|
||||||
return true
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user