From a64a417ea53f4beb273ac8675f1e3a3178cc2a52 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Tue, 11 Aug 2026 02:58:22 +0000 Subject: [PATCH] [proxy] Forward the Anthropic connection-warming probe Clients send HEAD /api/hello before their first inference request to open the upstream connection early. The path carries no model, so it denied as not-routable and each session start left a policy rejection in the access log for a request that was never a policy question. Treat it as a model-less endpoint. Forwarding it warms the connection the first real request will use, which is what the probe is for. --- .../builtin/llm_router/middleware.go | 16 +++++++--- .../builtin/llm_router/middleware_test.go | 30 +++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/proxy/internal/middleware/builtin/llm_router/middleware.go b/proxy/internal/middleware/builtin/llm_router/middleware.go index c38e00a43..88d7d6cbd 100644 --- a/proxy/internal/middleware/builtin/llm_router/middleware.go +++ b/proxy/internal/middleware/builtin/llm_router/middleware.go @@ -308,12 +308,20 @@ func (m *Middleware) matchRoute(model, vendor, reqPath string, userGroups []stri return best, matchOutcomeFound } +// connectionWarmPath is the probe Anthropic clients send before their first +// inference request to open the upstream connection early. Forwarding it +// warms the connection the request will actually use; denying it only fills +// the access log with rejections at every session start. +const connectionWarmPath = "/api/hello" + // isModelLessPath reports whether reqPath is a known non-inference endpoint -// that legitimately carries no model in its request (the model-listing -// endpoints). These must route to an upstream rather than deny, so model -// enumeration works end to end. +// that legitimately carries no model in its request (model listing and the +// connection-warming probe). These must route to an upstream rather than +// deny, so model enumeration works end to end. func isModelLessPath(reqPath string) bool { - return reqPath == "/v1/models" || strings.HasPrefix(reqPath, "/v1/models/") + return reqPath == "/v1/models" || + strings.HasPrefix(reqPath, "/v1/models/") || + reqPath == connectionWarmPath } // isBedrockModelLessPath reports whether reqPath is a Bedrock diff --git a/proxy/internal/middleware/builtin/llm_router/middleware_test.go b/proxy/internal/middleware/builtin/llm_router/middleware_test.go index a63028427..ec67ec05b 100644 --- a/proxy/internal/middleware/builtin/llm_router/middleware_test.go +++ b/proxy/internal/middleware/builtin/llm_router/middleware_test.go @@ -2,6 +2,7 @@ package llm_router import ( "context" + "net/http" "testing" "github.com/stretchr/testify/assert" @@ -905,3 +906,32 @@ func TestRouter_DatedAnthropicModelRoutes(t *testing.T) { require.NotNil(t, out.Mutations.RewriteUpstream) assert.Equal(t, "api.anthropic.com", out.Mutations.RewriteUpstream.Host) } + +// TestRouter_ConnectionWarmProbeRoutes covers the HEAD /api/hello probe an +// Anthropic client sends before its first request. Forwarding it warms the +// connection that request will use; denying it only wrote a rejection into +// the access log at every session start. +func TestRouter_ConnectionWarmProbeRoutes(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{{ + ID: "anthropic-prod", + Vendor: "anthropic", + Models: []string{"claude-sonnet-5"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.anthropic.com", + }}}) + + in := newModellessInput("/api/hello") + in.Method = http.MethodHead + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "the warm-up probe must reach the upstream") + require.NotNil(t, out.Mutations) + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Equal(t, "api.anthropic.com", out.Mutations.RewriteUpstream.Host) + + nonInference, _ := metaValue(t, out.Metadata, middleware.KeyLLMNonInference) + assert.Equal(t, "true", nonInference, "the probe carries no model to gate on") +}