[proxy] normalize Vertex model ids in routing, guardrail, and token counting

Vertex requests reach the router/guardrail with the "@version" suffix
already stripped from the URL model by the request parser, but the
operator-facing config may carry the raw versioned id the Google console
documents (e.g. "claude-opus-4-6@20250514"):

- a Vertex provider registered with versioned model ids denied every
  request as model_not_routable (the Vertex analog of the Bedrock gap
  fixed in #6773), and
- a guardrail allowlist entry with a versioned id never matched, denying
  the model as model_blocked.

Both bit a customer driving Anthropic-on-Vertex through the agent
network with unversioned model ids (…/models/claude-opus-4-6:rawPredict
at the global location).

Introduce a shared llm.NormalizeVertexModel (the same "@" stripping the
parser already does) and apply it to a Vertex route's candidate models in
routeClaimsModel and to guardrail allowlist entries, so either spelling
of the same model matches. Non-Vertex routes and entries without "@"
keep exact matching.

Also resolve the real model for the Vertex token-count endpoint:
…/models/count-tokens:rawPredict carries the literal "count-tokens"
pseudo-model in the URL and the actual model in the body (Claude Code
sends one such call per request), so any configured allowlist denied all
token counting. The parser now reads the body model for that endpoint;
an unreadable body keeps the pseudo-model and fails closed.
This commit is contained in:
Maycon Santos
2026-07-22 17:56:06 +00:00
parent 9770814f39
commit a69fe9ba8c
8 changed files with 236 additions and 15 deletions
@@ -556,14 +556,16 @@ func routeClaimsModel(route ProviderRoute, model string) bool {
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.
// The request model is already normalized by the parser, but the operator
// may register the raw id (Bedrock inference-profile, Vertex "@version").
// Normalize the candidate so both spellings match; else it denies as
// not-routable.
if route.Bedrock && llm.NormalizeBedrockModel(candidate) == model {
return true
}
if route.Vertex && llm.NormalizeVertexModel(candidate) == model {
return true
}
}
return false
}
@@ -0,0 +1,56 @@
package llm_router
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/proxy/internal/middleware"
)
// A Vertex route registered with the raw "@version" id must match the
// version-stripped request model; non-Vertex routes stay exact.
func TestRouteClaimsModel_VertexNormalizesCandidate(t *testing.T) {
route := ProviderRoute{Vertex: true, Models: []string{"claude-opus-4-6@20250514"}}
assert.True(t, routeClaimsModel(route, "claude-opus-4-6"))
assert.False(t, routeClaimsModel(route, "claude-haiku-4-5"))
bare := ProviderRoute{Vertex: true, Models: []string{"claude-opus-4-6"}}
assert.True(t, routeClaimsModel(bare, "claude-opus-4-6"))
direct := ProviderRoute{Models: []string{"claude-opus-4-6@20250514"}}
assert.False(t, routeClaimsModel(direct, "claude-opus-4-6"),
"non-Vertex routes must not normalize @version candidates")
}
// TestRouter_VertexUnversionedModelRoutes replays the customer-reported request:
// an unversioned model id must route on a provider registered with "@version" ids.
func TestRouter_VertexUnversionedModelRoutes(t *testing.T) {
route := vertexRoute()
route.Models = []string{"claude-opus-4-6@20250514", "claude-sonnet-4-5@20250929"}
mw := New(Config{Providers: []ProviderRoute{route}})
in := pathRoutedInput(
"/v1/projects/corp-gcp-it-all-claude/locations/global/publishers/anthropic/models/claude-opus-4-6:rawPredict",
"anthropic",
"claude-opus-4-6",
)
out, err := mw.Invoke(context.Background(), in)
require.NoError(t, err)
assert.Equal(t, middleware.DecisionAllow, out.Decision,
"unversioned request model must route on a provider registered with @version ids")
denied := pathRoutedInput(
"/v1/projects/corp-gcp-it-all-claude/locations/global/publishers/anthropic/models/claude-haiku-4-5:rawPredict",
"anthropic",
"claude-haiku-4-5",
)
out, err = mw.Invoke(context.Background(), denied)
require.NoError(t, err)
assert.Equal(t, middleware.DecisionDeny, out.Decision,
"a model outside the registered list must still deny")
require.NotNil(t, out.DenyReason)
assert.Equal(t, denyCodeNotRoutable, out.DenyReason.Code)
}