mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-16 19:59:07 +02:00
[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:
@@ -104,3 +104,93 @@ func TestModelAllowlist_URLRoutedProviders(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelAllowlist_VertexRequestShapes replays the Vertex request shapes an
|
||||
// Anthropic SDK client sends (model in the URL path, optionally unversioned,
|
||||
// plus the count-tokens body-model endpoint) against bare and "@version"
|
||||
// allowlists. URLs mirror a customer-reported request.
|
||||
func TestModelAllowlist_VertexRequestShapes(t *testing.T) {
|
||||
const (
|
||||
opusBare = "/v1/projects/corp-gcp-it-all-claude/locations/global/publishers/anthropic/models/claude-opus-4-6:rawPredict"
|
||||
opusBareSSE = "/v1/projects/corp-gcp-it-all-claude/locations/global/publishers/anthropic/models/claude-opus-4-6:streamRawPredict"
|
||||
countTokens = "/v1/projects/corp-gcp-it-all-claude/locations/global/publishers/anthropic/models/count-tokens:rawPredict"
|
||||
messagesBody = `{"anthropic_version":"vertex-2023-10-16","messages":[{"role":"user","content":"hi"}]}`
|
||||
countOpusBody = `{"model":"claude-opus-4-6","messages":[{"role":"user","content":"hi"}]}`
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
body string
|
||||
allowlist []string
|
||||
decision middleware.Decision
|
||||
denyCode string
|
||||
}{
|
||||
{
|
||||
name: "unversioned model allowed by bare catalog entry",
|
||||
url: opusBare,
|
||||
body: messagesBody,
|
||||
allowlist: []string{"claude-opus-4-6"},
|
||||
decision: middleware.DecisionAllow,
|
||||
},
|
||||
{
|
||||
name: "unversioned model allowed by @version allowlist entry",
|
||||
url: opusBare,
|
||||
body: messagesBody,
|
||||
allowlist: []string{"claude-opus-4-6@20250514"},
|
||||
decision: middleware.DecisionAllow,
|
||||
},
|
||||
{
|
||||
name: "streaming action allowed the same as rawPredict",
|
||||
url: opusBareSSE,
|
||||
body: messagesBody,
|
||||
allowlist: []string{"claude-opus-4-6"},
|
||||
decision: middleware.DecisionAllow,
|
||||
},
|
||||
{
|
||||
// The customer report: a Sonnet-only allowlist must block Opus.
|
||||
name: "unversioned model outside the allowlist denied",
|
||||
url: opusBare,
|
||||
body: messagesBody,
|
||||
allowlist: []string{"claude-sonnet-4-5"},
|
||||
decision: middleware.DecisionDeny,
|
||||
denyCode: "llm_policy.model_blocked",
|
||||
},
|
||||
{
|
||||
name: "count-tokens resolves the body model and passes when allowed",
|
||||
url: countTokens,
|
||||
body: countOpusBody,
|
||||
allowlist: []string{"claude-opus-4-6"},
|
||||
decision: middleware.DecisionAllow,
|
||||
},
|
||||
{
|
||||
name: "count-tokens with a disallowed body model denied",
|
||||
url: countTokens,
|
||||
body: countOpusBody,
|
||||
allowlist: []string{"claude-sonnet-4-5"},
|
||||
decision: middleware.DecisionDeny,
|
||||
denyCode: "llm_policy.model_blocked",
|
||||
},
|
||||
{
|
||||
// No body model: the pseudo-model stays and fails closed.
|
||||
name: "count-tokens without a body model fails closed",
|
||||
url: countTokens,
|
||||
body: messagesBody,
|
||||
allowlist: []string{"claude-opus-4-6"},
|
||||
decision: middleware.DecisionDeny,
|
||||
denyCode: "llm_policy.model_blocked",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
out := runParserGuardrail(t, tt.url, []byte(tt.body), tt.allowlist)
|
||||
assert.Equal(t, tt.decision, out.Decision, "unexpected decision for %s", tt.name)
|
||||
if tt.decision == middleware.DecisionDeny {
|
||||
require.NotNil(t, out.DenyReason, "deny reason must be set for %s", tt.name)
|
||||
assert.Equal(t, 403, out.DenyStatus, "deny status must be 403 for %s", tt.name)
|
||||
assert.Equal(t, tt.denyCode, out.DenyReason.Code, "deny code for %s", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,9 +253,7 @@ func parseVertexPath(reqPath string) (vertexRequest, bool) {
|
||||
if c := strings.LastIndex(rest, ":"); c >= 0 {
|
||||
model, action = rest[:c], rest[c+1:]
|
||||
}
|
||||
if at := strings.Index(model, "@"); at >= 0 {
|
||||
model = model[:at]
|
||||
}
|
||||
model = llm.NormalizeVertexModel(model)
|
||||
if model == "" {
|
||||
return vertexRequest{}, false
|
||||
}
|
||||
@@ -276,24 +274,40 @@ func vertexPublisherVendor(publisher string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// vertexCountTokensModel is the pseudo-model of the Vertex token-count endpoint,
|
||||
// the one Vertex shape whose real model lives in the body, not the URL path.
|
||||
const vertexCountTokensModel = "count-tokens"
|
||||
|
||||
// invokeVertex emits the model/vendor/session/prompt for a Vertex publisher
|
||||
// request, using the publisher's parser to read the (vendor-native) body.
|
||||
func (m middlewareImpl) invokeVertex(in *middleware.Input, vx vertexRequest) *middleware.Output {
|
||||
out := &middleware.Output{Decision: middleware.DecisionAllow}
|
||||
vendor := vertexPublisherVendor(vx.publisher)
|
||||
|
||||
md := []middleware.KV{}
|
||||
if vendor != "" {
|
||||
md = append(md, middleware.KV{Key: middleware.KeyLLMProvider, Value: vendor})
|
||||
}
|
||||
md = append(md, middleware.KV{Key: middleware.KeyLLMModel, Value: vx.model})
|
||||
md = append(md, middleware.KV{Key: middleware.KeyLLMStream, Value: strconv.FormatBool(vx.stream)})
|
||||
|
||||
var parser llm.Parser
|
||||
if vendor != "" {
|
||||
parser, _ = llm.ParserByName(vendor)
|
||||
}
|
||||
|
||||
model := vx.model
|
||||
// count-tokens carries its real model in the body; resolve it so the
|
||||
// guardrail and router evaluate the actual model. An unreadable body keeps
|
||||
// the pseudo-model and fails closed downstream.
|
||||
if model == vertexCountTokensModel && parser != nil {
|
||||
if facts, err := parser.ParseRequest(in.Body); err == nil && facts.Model != "" {
|
||||
if bodyModel := llm.NormalizeVertexModel(facts.Model); bodyModel != "" {
|
||||
model = bodyModel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
md := []middleware.KV{}
|
||||
if vendor != "" {
|
||||
md = append(md, middleware.KV{Key: middleware.KeyLLMProvider, Value: vendor})
|
||||
}
|
||||
md = append(md, middleware.KV{Key: middleware.KeyLLMModel, Value: model})
|
||||
md = append(md, middleware.KV{Key: middleware.KeyLLMStream, Value: strconv.FormatBool(vx.stream)})
|
||||
|
||||
sessionID := sessionIDFromHeaders(in.Headers)
|
||||
if sessionID == "" && parser != nil {
|
||||
sessionID = parser.ExtractSessionID(in.Body)
|
||||
|
||||
Reference in New Issue
Block a user