fix(router): exclude PreferredModels turns from semantic cache#790
Open
rohith500 wants to merge 2 commits into
Open
fix(router): exclude PreferredModels turns from semantic cache#790rohith500 wants to merge 2 commits into
rohith500 wants to merge 2 commits into
Conversation
Signed-off-by: N Rohith Reddy <rohithreddy2202@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…eave#789) Mirrors the existing subsidyFactors/SubscriptionOnly gates on cacheEligible: PreferredModels is a per-request score-perturbing input (blendScoresV2 adds priorityBonus additively, same as subsidy) that can change the scorer's winning model without changing EffectiveKnobsHash, so a stored response can be served across a preference-driven winner change. Gating matches workweave#495/workweave#747 precedent rather than folding into the hash (workweave#214/workweave#338's mixModel pattern), since PreferredModels is structurally a score-perturbing input like subsidy, not a post-hoc override like bandit exploration. Fixes workweave#789 Signed-off-by: N Rohith Reddy <rohithreddy2202@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
|
PR author is not in the allowed authors list. |
Contributor
|
Thanks for this, @rohith500 — really nice catch and a well-scoped fix. Reviewed against the repo conventions (root
The write-up on gate-vs-fold and the two deliberately-unaddressed follow-ups (stale-preference over-gating, the |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #789.
Summary
cacheEligiblealready excludes requests wheresubsidyFactors(per-installation subscription quota headroom) or
SubscriptionOnlyFromContextare in play, because both can change the scorer's winning model without
changing the semantic cache's isolation key (
EffectiveKnobsHash). This PRadds the same exclusion for
PreferredModels, which has the identicalstructural problem and was missing the gate.
Background / root cause
The semantic cache buckets responses by
{format, clusterID, clusterVersion, knobsHash}(internal/router/cache/cache.go).knobsHashcomes fromComputeKnobsHash(alpha, speedWeight, outputCostRatio, expectedOutputTokens, perModelVerbosity)(internal/router/cluster/knobs.go) — a hash over therouting knobs, not over anything that identifies which model actually won.
blendScoresV2(internal/router/cluster/scorer.go) computes per-modelscores from those knobs, then adds two additional per-request adjustments
on top, inside the same loop:
priorityBonusis built fromreq.PreferredModels(rank-0 bonus ≈0.15,decaying by rank, added once per top-P cluster — so it compounds and can
flip a genuinely close call, not just a tie; see
TestScorer_PreferredModelSoftNudge). BothsubsidyFactorsandpriorityBonuscan change which model wins without changingEffectiveKnobsHash, because the hash is computed from knobs alone,before either adjustment is applied.
cacheEligiblealready accounted for this forsubsidyFactors(andseparately,
SubscriptionOnlyFromContext, for a related but distinctreason — see the existing comment). It didn't account for it for
PreferredModels, introduced later in #521. That gap meant: two requestswith the same prompt/embedding/knobs but different
PreferredModelscouldproduce different winning models, land in the same cache bucket, and a
cache hit would serve the wrong model's response body while
writeCachedResponsesetx-router-modelfrom the current (correct)decision — a body/header mismatch, confirmed in #789 via a mechanism-level
test against the real
ScorerandCache(see the issue for the isolatedtest and its output).
Fix
Mirrors the existing
subsidyFactorsgate exactly, at both call sites:Same addition at the OpenAI path (
ProxyOpenAIChatCompletion, ~line 4220).Comments above both sites updated to mention
PreferredModelsalongsidesubsidy factors as a "score-perturbing input the cache key doesn't capture."
Why gate, not fold
Two existing patterns in this codebase address this class of gap:
cacheEligibleexclusion) — used by feat: subscription-aware routing (discount covered models by observed quota headroom) #495 (subsidy) and router: serve subscription-only below the overdraft floor instead of 402 #747(
SubscriptionOnly).mixModel) — used by feat(router): bandit foundation — propensity + reward logging, gated explorer #338 for the bandit explorer's non-argmax pick, andoriginally by feat(router): runtime-tunable routing knobs via v2 bundle format #214 for
clusterVersion.PreferredModelsis structurally identical tosubsidyFactors— both areper-request maps added additively to
scores[m]in the same loop, i.e.score-perturbing inputs. The bandit explorer's case is different in kind:
it's a post-hoc override of an already-fixed argmax pick for online-learning
exploration, not a perturbation of the scoring function itself. Given that
structural match, gating (matching #495/#747) is the more consistent choice
here, and it's also the smaller, lower-risk diff — folding would require
either restructuring
EffectiveKnobsHash's computation to happen afterblendScoresV2resolves a winner (it currently runs before), or hashing thefull
priorityBonusmap, both larger changes than this PR's scope.Trade-off: gating means any installation with a non-empty
preferred_modelslist gets zero semantic-cache benefit, full stop, rather than only losing it
on the specific requests where the preference actually changes the outcome.
Given
preferred_modelsdefaults to{}(opt-in, not the common case),this seemed like the right trade for a minimal, safe fix — flagging here in
case there's a strong preference for the fold approach instead, since it's
a real trade either way.
Commits
bfbf829— failing test reproducing the collision (confirmed failingfor the right reason: cache hit + wrong body, not a compile/setup error)
dec5706— the gate, at both call sites, plus comment updatesTesting
TestService_Cache_PreferredModelsBypasses) confirms: arequest with non-empty
PreferredModelsnow results in 2 real providercalls (no incorrect cache hit) instead of 1, and never reports
x-router-cache: hit.ProxyMessages,ProxyOpenAIChatCompletion), not just thecachepackage in isolation —cacheEligibleis computed inline inservice.go, so this is where thegate needed to be verified.
TestService_Cache_ HitShortCircuitsProvider(nil/absent preferences, the common case) stillhits correctly; an explicit empty slice (
[]string{}) also still hits.preferredModelsForRequest(ctx)is nil/panic-safe: missingcontext key, empty slice, and a wrong-type context value (defensive type
assertion) all resolve to
len == 0without panicking.semanticCache.Lookup/Storecallsites — only the two fixed here exist. Gemini's proxy path doesn't use
the semantic cache at all (
cache.Format*only covers Anthropic/OpenAI).ProxyOpenAIResponsesdelegates toProxyOpenAIChatCompletionandinherits the fix.
/v1/routeperforms no cache I/O (dry-run).go vet ./internal/proxy/...clean;gofmt -l internal/proxy/service.goclean (no diff).
./internal/proxy/... ./internal/router/cluster/... ./internal/router/cache/..., verbose,-count=1.make checkpasses clean.Known, deliberately unaddressed
configured" (any non-empty
preferred_modelslist on the installation),not "preference currently eligible for this request." An installation
whose entire list is stale (references excluded/undeployed models —
the same no-op case
TestScorer_PreferredModelSoftNudgealready coversat the scorer level) loses caching even though the scorer would ignore
the stale entries entirely and routing is unaffected. This is safe
(over-broad, not incorrect) and a minor hit-rate cost, not a correctness
issue, so I left it out of this PR's scope rather than adding eligibility
resolution logic here.
ExcludedModelssibling gap. Same class of problem:installation.ExcludedModelscan also change the scorer's winning model(by removing an otherwise-winning model from the eligible set) and isn't
reflected in
cacheEligibleorEffectiveKnobsHasheither. Didn'texpand this PR to cover it since it's a separate root cause investigation
and repro, but happy to open a follow-up issue if that's useful — let me
know.