From 07475213d3bc7e2f05352578bb80350d39577b24 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Tue, 11 Aug 2026 15:20:18 +0000 Subject: [PATCH] [proxy] Keep deliberately pinned dated models distinct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes to routeClaimsModel, both about dated Anthropic ids. Normalising the configured candidate as well as the requested model made every dated build of a family interchangeable: a route registered against claude-sonnet-4-5-20250101 also claimed ...-20250202, so an operator who pinned a build deliberately would have served a different one, and with several such routes declaration or path order decided which. Only an undated registration now absorbs a dated request. The per-model lookup also stamps the model its path names, so the guardrail's allowlist — a separate and possibly narrower list than the route's — still decides GET /v1/models/{id} rather than seeing no model at all. --- .../builtin/llm_router/middleware.go | 22 +++++-- .../builtin/llm_router/middleware_test.go | 58 +++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/proxy/internal/middleware/builtin/llm_router/middleware.go b/proxy/internal/middleware/builtin/llm_router/middleware.go index 5fbdce837..e6ad332fc 100644 --- a/proxy/internal/middleware/builtin/llm_router/middleware.go +++ b/proxy/internal/middleware/builtin/llm_router/middleware.go @@ -110,6 +110,9 @@ func (m *Middleware) MetadataKeys() []string { middleware.KeyLLMPolicyDecision, middleware.KeyLLMPolicyReason, middleware.KeyLLMNonInference, + // Emitted only for the per-model lookup, whose model lives in the path + // rather than a body the parser could read. + middleware.KeyLLMModel, } } @@ -181,7 +184,14 @@ func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middlewar // the token pre-flight it would otherwise charge nothing against. if detail, isDetail := modelDetailID(reqPath); isDetail && isNonInferenceMethod(in.Method) { route, outcome := m.matchRoute(detail, surface, reqPath, in.UserGroups) - return m.decide(route, outcome, surface, detail, in.UserGroups, markNonInference), nil + return m.decide(route, outcome, surface, detail, in.UserGroups, func(out *middleware.Output) { + markNonInference(out) + // The parser reads models from JSON bodies only, and this request + // has none, so stamp the one the path names. Without it the + // guardrail's own allowlist — a separate, possibly narrower list + // than the route's — never sees a model to check. + out.Metadata = append(out.Metadata, middleware.KV{Key: middleware.KeyLLMModel, Value: detail}) + }), nil } if model == "" { @@ -692,9 +702,13 @@ func routeClaimsModel(route ProviderRoute, model string) bool { return true } // A client may pin a dated Anthropic id ("claude-sonnet-4-5-20250929") - // where the operator registered the undated one. Exact matches above - // win, so two dated releases stay distinct when both are registered. - if llm.NormalizeAnthropicModel(candidate) == llm.NormalizeAnthropicModel(model) { + // where the operator registered the undated one. Only an undated + // registration absorbs a dated request: normalising both sides would + // let a route pinned to one dated release claim a different one, so an + // operator who deliberately pinned a build would silently serve + // another — and with several such routes, ordering would decide which. + if candidate == llm.NormalizeAnthropicModel(candidate) && + candidate == llm.NormalizeAnthropicModel(model) { return true } } diff --git a/proxy/internal/middleware/builtin/llm_router/middleware_test.go b/proxy/internal/middleware/builtin/llm_router/middleware_test.go index 7a9951a33..336cdb9fe 100644 --- a/proxy/internal/middleware/builtin/llm_router/middleware_test.go +++ b/proxy/internal/middleware/builtin/llm_router/middleware_test.go @@ -62,6 +62,7 @@ func TestMiddlewareIdentity(t *testing.T) { middleware.KeyLLMResolvedProviderID, middleware.KeyLLMAuthorisingGroups, middleware.KeyLLMNonInference, + middleware.KeyLLMModel, middleware.KeyLLMPolicyDecision, middleware.KeyLLMPolicyReason, }, @@ -1087,3 +1088,60 @@ func TestRouter_NonInferenceRequiresReadMethod(t *testing.T) { "the HEAD warm probe carries no model to meter") }) } + +// TestRouter_PinnedDatedModelStaysDistinct pins that a route registered +// against one dated Anthropic release does not claim another. Normalising +// both sides of the comparison made every dated build of a family +// interchangeable, so an operator who deliberately pinned a build would have +// served a different one — and with several such routes, declaration or path +// order would have decided which. +func TestRouter_PinnedDatedModelStaysDistinct(t *testing.T) { + pinned := ProviderRoute{ + ID: "anthropic-pinned", + Vendor: "anthropic", + Models: []string{"claude-sonnet-4-5-20250101"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "pinned.example.com", + } + + t.Run("a different dated release is not claimed", func(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{pinned}}) + in := newInputWithModelAndURL("claude-sonnet-4-5-20250202", "/v1/messages") + in.Metadata = append(in.Metadata, middleware.KV{Key: middleware.KeyLLMProvider, Value: "anthropic"}) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionDeny, out.Decision, + "a route pinned to one dated build must not serve another") + }) + + t.Run("its own dated release still routes", func(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{pinned}}) + in := newInputWithModelAndURL("claude-sonnet-4-5-20250101", "/v1/messages") + in.Metadata = append(in.Metadata, middleware.KV{Key: middleware.KeyLLMProvider, Value: "anthropic"}) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "the exact match must still route") + }) + + t.Run("two pinned builds each route to their own provider", func(t *testing.T) { + other := pinned + other.ID = "anthropic-pinned-newer" + other.Models = []string{"claude-sonnet-4-5-20250202"} + other.UpstreamHost = "newer.example.com" + mw := New(Config{Providers: []ProviderRoute{pinned, other}}) + + in := newInputWithModelAndURL("claude-sonnet-4-5-20250202", "/v1/messages") + in.Metadata = append(in.Metadata, middleware.KV{Key: middleware.KeyLLMProvider, Value: "anthropic"}) + + out, err := mw.Invoke(context.Background(), in) + 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, "newer.example.com", out.Mutations.RewriteUpstream.Host, + "declaration order must not decide between two deliberately pinned builds") + }) +}