Summary
ComputeKnobsHash never incorporates PreferredModels or the winning model.
When a per-installation preference changes which model wins a close scoring
call, the semantic cache doesn't isolate on that — a near-duplicate prompt
can return a cached response body from a different model than the one the
live routing decision (and x-router-model header) reports.
Why this is a bug, not a tradeoff
PR #214 introduced clusterVersion + knobsHash into the cache bucket key
specifically to fix this exact class of problem, and named it explicitly:
Cache fix (latent bug, pre-existing this work)
bucketKey gains clusterVersion + knobsHash. Without this, requests
differing only on knobs share cache buckets…
That's the maintainers' own stated principle: any axis that can change the
routing winner must be part of cache-bucket isolation, or sharing a bucket
across it is a bug. PreferredModels (introduced later, #521) is exactly
such an axis — confirmed below — and was never folded in.
No ADR, comment, or PR discussion argues for excluding preferences from cache
isolation as an intentional tradeoff. "Soft nudge" language in #521 describes
the scorer's quality floor (a preference won't override a clearly-better
model) — it is not a statement that cache correctness may be sacrificed once
a preference does win.
Where
internal/router/cache/cache.go: bucketKey{format, clusterID, clusterVersion, knobsHash} — no model or preference dimension.
internal/router/cluster/knobs.go: ComputeKnobsHash(alpha, speedWeight, outputCostRatio, expectedOutputTokens, perModelVerbosity) — no model
identity, no preferences.
internal/router/cluster/scorer.go ~618-636: priorityBonus built from
req.PreferredModels; ~1208-1212: added into blendScoresV2's per-model
score inside the top-P cluster loop (so a rank-0 bonus of ~0.15 compounds
across clusters — enough to flip a real close call, not just a tie).
EffectiveKnobsHash (~754-762) is computed separately and never sees this.
internal/proxy/service.go writeCachedResponse (~1436-1453): headers are
intentionally overwritten from the live decision ("x-router-* headers come
from the live decision so the client sees an accurate routing trace"), but
the body is written unconditionally from the cached entry. The header
overwrite is correct on its own; the bug is that the body isn't co-isolated
with the same decision axis the headers now reflect.
Reproduction (mechanism-level, verified independently twice)
Using the existing scorer test fixtures (newV2BundleForTest, same setup as
TestScorer_PreferredModelSoftNudge) and the real cache.Cache — no mocks:
- Same prompt, no
PreferredModels → wins claude-opus-4-7 (score 0.55).
EffectiveKnobsHash = 12184787796704120736.
- Same prompt,
PreferredModels = ["claude-haiku-4-5"] → wins
claude-haiku-4-5 (score 0.60). EffectiveKnobsHash — identical.
cache.Store under (1)'s key, cache.Lookup under (2)'s key → hit,
returns (1)'s (Opus's) body while (2)'s live decision says Haiku.
Verified independently twice against current HEAD (two separate runs,
same result both times).
Severity (honest assessment)
Medium, not critical:
preferred_models defaults to {} — inert unless an installation sets it.
- Requires a near-duplicate prompt clearing the cache's cosine threshold
(default 0.95).
- The sticky-pin STAY path bypasses the cache entirely (
Metadata == nil
there) — this only affects the scorer-fresh routing path, not every turn
of a pinned session.
- Realistic trigger: an installation changes its preference list and replays
a similar prompt, or two installations sharing the same cache scope
(same org external_id) have different preferences.
Real correctness bug when it fires — wrong model's output silently served —
but not a cross-org isolation failure and not continuous on organic traffic.
Suggested fix direction
Both patterns exist in the codebase already:
Fold is likely preferable given #214's own precedent, but deferring to
maintainer judgment on the tradeoff.
Related (not duplicate)
No open issue or PR currently tracks this.
Summary
ComputeKnobsHashnever incorporatesPreferredModelsor the winning model.When a per-installation preference changes which model wins a close scoring
call, the semantic cache doesn't isolate on that — a near-duplicate prompt
can return a cached response body from a different model than the one the
live routing decision (and
x-router-modelheader) reports.Why this is a bug, not a tradeoff
PR #214 introduced
clusterVersion+knobsHashinto the cache bucket keyspecifically to fix this exact class of problem, and named it explicitly:
That's the maintainers' own stated principle: any axis that can change the
routing winner must be part of cache-bucket isolation, or sharing a bucket
across it is a bug.
PreferredModels(introduced later, #521) is exactlysuch an axis — confirmed below — and was never folded in.
No ADR, comment, or PR discussion argues for excluding preferences from cache
isolation as an intentional tradeoff. "Soft nudge" language in #521 describes
the scorer's quality floor (a preference won't override a clearly-better
model) — it is not a statement that cache correctness may be sacrificed once
a preference does win.
Where
internal/router/cache/cache.go:bucketKey{format, clusterID, clusterVersion, knobsHash}— no model or preference dimension.internal/router/cluster/knobs.go:ComputeKnobsHash(alpha, speedWeight, outputCostRatio, expectedOutputTokens, perModelVerbosity)— no modelidentity, no preferences.
internal/router/cluster/scorer.go~618-636:priorityBonusbuilt fromreq.PreferredModels; ~1208-1212: added intoblendScoresV2's per-modelscore inside the top-P cluster loop (so a rank-0 bonus of ~0.15 compounds
across clusters — enough to flip a real close call, not just a tie).
EffectiveKnobsHash(~754-762) is computed separately and never sees this.internal/proxy/service.gowriteCachedResponse(~1436-1453): headers areintentionally overwritten from the live decision ("x-router-* headers come
from the live decision so the client sees an accurate routing trace"), but
the body is written unconditionally from the cached entry. The header
overwrite is correct on its own; the bug is that the body isn't co-isolated
with the same decision axis the headers now reflect.
Reproduction (mechanism-level, verified independently twice)
Using the existing scorer test fixtures (
newV2BundleForTest, same setup asTestScorer_PreferredModelSoftNudge) and the realcache.Cache— no mocks:PreferredModels→ winsclaude-opus-4-7(score 0.55).EffectiveKnobsHash = 12184787796704120736.PreferredModels = ["claude-haiku-4-5"]→ winsclaude-haiku-4-5(score 0.60).EffectiveKnobsHash— identical.cache.Storeunder (1)'s key,cache.Lookupunder (2)'s key → hit,returns (1)'s (Opus's) body while (2)'s live decision says Haiku.
Verified independently twice against current
HEAD(two separate runs,same result both times).
Severity (honest assessment)
Medium, not critical:
preferred_modelsdefaults to{}— inert unless an installation sets it.(default 0.95).
Metadata == nilthere) — this only affects the scorer-fresh routing path, not every turn
of a pinned session.
a similar prompt, or two installations sharing the same cache scope
(same org
external_id) have different preferences.Real correctness bug when it fires — wrong model's output silently served —
but not a cross-org isolation failure and not continuous on organic traffic.
Suggested fix direction
Both patterns exist in the codebase already:
cacheEligiblewhenlen(req.PreferredModels) > 0. Simpler, safe, but disables cachingentirely for any installation using preferences.
mixModel): incorporate the resolvedpriorityBonusor winning model intoEffectiveKnobsHash. Preservescache hit rate for preference-using installations.
Fold is likely preferable given #214's own precedent, but deferring to
maintainer judgment on the tradeoff.
Related (not duplicate)
PreferredModels; Bugbot raised this exact gap on thePR twice, unaddressed, no human reply.
principle this gap violates.
mixModel) for the bandit-explore case.No open issue or PR currently tracks this.