Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions internal/proxy/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -2283,12 +2283,13 @@ func (s *Service) ProxyMessages(ctx context.Context, body []byte, w http.Respons
// Semantic-cache eligibility: configured, non-streaming, decision has
// metadata, externalID present, not eval traffic. Skip when a compaction
// handover rewrote env (embedding predates the rewrite) or when subsidy
// factors are non-empty (the cache key doesn't capture quota-headroom-
// dependent model choice; subsidyFactors returns nil when the feature is off).
// Subscription-only turns are excluded (like the OpenAI path): the mode is an
// unfoldable routing signal absent from the cache key, so a stored body would
// bypass the exhausted-sub 402 guard and the depleted-credits warning below.
cacheEligible := s.semanticCache != nil && !env.Stream() && decision.Metadata != nil && externalID != "" && !bypassEval && !compactionHandoverRan && !billing.SubscriptionOnlyFromContext(ctx) && len(s.subsidyFactors(ctx, r.Header)) == 0
// factors / PreferredModels are non-empty (the cache key doesn't capture
// those score-perturbing inputs' effect on model choice; subsidyFactors
// returns nil when the feature is off). Subscription-only turns are
// excluded (like the OpenAI path): the mode is an unfoldable routing signal
// absent from the cache key, so a stored body would bypass the exhausted-sub
// 402 guard and the depleted-credits warning below.
cacheEligible := s.semanticCache != nil && !env.Stream() && decision.Metadata != nil && externalID != "" && !bypassEval && !compactionHandoverRan && !billing.SubscriptionOnlyFromContext(ctx) && len(s.subsidyFactors(ctx, r.Header)) == 0 && len(s.preferredModelsForRequest(ctx)) == 0
if cacheEligible {
if resp, hit := s.semanticCache.Lookup(externalID, cache.FormatAnthropic, decision.Metadata.Embedding, decision.Metadata.ClusterIDs, decision.Metadata.ClusterRouterVersion, decision.Metadata.EffectiveKnobsHash); hit {
s.writeCachedResponse(w, resp, decision)
Expand Down Expand Up @@ -4213,9 +4214,10 @@ func (s *Service) ProxyOpenAIChatCompletion(ctx context.Context, body []byte, w
pinAgeSec := routeRes.PinAgeSec
s.logPlannerOutcome(ctx, routeRes)

// See the ProxyMessages cache-eligibility note: subsidized requests bypass the
// semantic cache (the key doesn't capture headroom-dependent model choice).
cacheEligible := s.semanticCache != nil && !env.Stream() && decision.Metadata != nil && externalID != "" && !bypassEval && !responsesPassthrough && !billing.SubscriptionOnlyFromContext(ctx) && len(s.subsidyFactors(ctx, r.Header)) == 0
// See the ProxyMessages cache-eligibility note: subsidized and
// PreferredModels requests bypass the semantic cache (the key doesn't
// capture those score-perturbing inputs' effect on model choice).
cacheEligible := s.semanticCache != nil && !env.Stream() && decision.Metadata != nil && externalID != "" && !bypassEval && !responsesPassthrough && !billing.SubscriptionOnlyFromContext(ctx) && len(s.subsidyFactors(ctx, r.Header)) == 0 && len(s.preferredModelsForRequest(ctx)) == 0
if cacheEligible {
if resp, hit := s.semanticCache.Lookup(externalID, cache.FormatOpenAI, decision.Metadata.Embedding, decision.Metadata.ClusterIDs, decision.Metadata.ClusterRouterVersion, decision.Metadata.EffectiveKnobsHash); hit {
s.writeCachedResponse(w, resp, decision)
Expand Down
37 changes: 37 additions & 0 deletions internal/proxy/service_cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,3 +237,40 @@ func TestService_Cache_DisabledByNilCache(t *testing.T) {
assert.Len(t, provider.proxyBodies, 2, "nil cache must be a transparent passthrough")
assert.Empty(t, rec2.Header().Get(proxy.HeaderRouterCache))
}

// TestService_Cache_PreferredModelsBypasses guards #789: PreferredModels is a
// per-request score-perturbing input (blendScoresV2 priorityBonus) that can
// flip the scorer's winner without changing EffectiveKnobsHash. Without a
// cacheEligible gate, a no-preference store is replayed to a preference-
// bearing request (wrong model's body, live x-router-model). Same class as
// the subsidyFactors gate from #495.
func TestService_Cache_PreferredModelsBypasses(t *testing.T) {
emb := embeddingFixture(8)
provider := &fakeProvider{
proxyResponse: func(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"from-no-pref","content":"opus-shaped"}`))
},
}
// Fake router returns a fixed decision+embedding so both turns land in the
// same cache bucket — isolating the eligibility gate from scorer variance.
fr := &fakeRouter{decision: decisionWithEmbedding(emb, []int{0, 1, 2, 3})}
c := cache.New(cache.DefaultConfig())
svc := proxy.NewService(fr, map[string]providers.Client{providers.ProviderAnthropic: provider}, nil, false, c, nil, false, providers.ProviderAnthropic, "claude-haiku-4-5", nil)

body := anthropicBody("close-call prompt", false)

ctxNoPref := proxyContextWithExternalID(t, "tenant-pref")
rec1 := httptest.NewRecorder()
require.NoError(t, svc.ProxyMessages(ctxNoPref, body, rec1, httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(""))))
require.Len(t, provider.proxyBodies, 1, "no-preference turn must miss and hit the provider")

ctxPref := context.WithValue(ctxNoPref, proxy.InstallationPreferredModelsContextKey{}, []string{"claude-haiku-4-5"})
rec2 := httptest.NewRecorder()
require.NoError(t, svc.ProxyMessages(ctxPref, body, rec2, httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(""))))

assert.Len(t, provider.proxyBodies, 2,
"PreferredModels turn must bypass the semantic cache and hit the provider (no cross-preference hit)")
assert.Empty(t, rec2.Header().Get(proxy.HeaderRouterCache),
"PreferredModels turn must not report x-router-cache: hit")
}