How LookupRoute distinguishes a novel prefix (the cache plane has the
requested (tenant, model, hash_scheme) populated but not this particular
prefix) from a contract-key mismatch (the caller asked with a key the cache
plane does not recognize at all). The latter is almost always a misconfigured
gateway/SDK, and surfacing it as a specific reason_code is what lets
operators debug "100% NO_HINT" without re-deriving the layering from
captured packets.
This is a wire-level addition only — no proto schema or field-number
change. The proto/ edit that introduced the codes only widened the inline
comment on the reason_code field; the regenerated stubs picked up the
comment, with no binary-format change. reason_code is a string, and old
clients degrade UNKNOWN_* to their NO_HINT default per the
forward-compatibility rule in
../reference/reason-codes.md.
A LookupRoute request asks the server for hints under a triple of contract
keys: tenant_id, model_id, and hash_scheme. The prefix lookup itself is
the leaf of that triple — what the request really wants is "do any
replicas hold this prefix under the (tenant, model, hash_scheme) I'm
asking about?".
Today every miss — whether the keys are right and the prefix is genuinely
novel, or the keys are wrong and the data is sitting one key away — collapses
to a single reason_code: NO_HINT. The server can't tell the gateway "you
asked the wrong question" because it has no vocabulary for it.
Two production silent-failure patterns observed end-to-end on a real cluster share this shape:
- Wrong
hash_scheme. Ingest underhash_scheme="vllm"; lookup underhash_scheme="vllm-v1". The server has the entry; the wrong scheme makes the prefix key (which is opaque bytes per the contract) un-findable. ReturnsNO_HINT. - Wrong
tenant_id. Ingest undertenant_id="ic-smoke"(the kvevent-subscriber sidecar Helm chart sets--tenant-id=$(POD_NAMESPACE)). Lookup undertenant_id="default"(the OpenAI-API-shaped SDK default that a naive gateway client picks up). Same prefix hash. Empirically:tenant_id="ic-smoke"→PREFIX_MATCHwithestimatedCacheHitProb=0.9997;tenant_id="default"→NO_HINT. Looks the same from outside.
Both shaped identically: the right state exists in the index under a different value of one contract key. There is currently no way for the caller to tell that from a "this is genuinely the first time we've seen this prompt" miss without out-of-band inspection.
The fix in this design is the smallest one that closes both: add specific
reason_code values for each contract-key mismatch, emit them on the miss
path, document them so SDK authors and operators can react.
Every contract key that can mismatch returns a specific
reason_codeon key-level no-data — not the catch-allNO_HINT.
LookupRoute has three such keys: tenant_id, model_id, hash_scheme.
This design adds one reason code per key. Future contract surfaces with
mismatchable keys (e.g. LookupPDRoute and pd_topology_ref) follow the
same rule when they ship; "diagnose key-mismatches" becomes part of the
contract-diagnostics pattern.
The rule does not apply to:
- Keys the caller failed to supply. An empty
tenant_id,model_id, orhash_schemeis a contract violation (the request is missing a required scoping field), not a mismatch. Empty-key cases continue to surface asNO_HINTper the existing fail-open semantics. TheUNKNOWN_*codes specifically diagnose "you supplied a value but it doesn't match anything we have" — emitting them for a missing field would be misleading guidance ("change your value" when the actual fix is "supply the field"). - A globally empty index (cold-start carve-out). When the server
holds zero prefix entries — fresh start before any
ReportCacheStatelands, a fully drained cluster — every tenant query would otherwise classify asUNKNOWN_TENANT, flooding gateways with configuration-error signals during normal operation. The classifier short-circuits the globally-empty case toNO_HINT; the diagnostic resumes the moment any replica reports, which is the asymmetric case the SDK guidance is targeted at (one tenant populated, the gateway pointing at another). - Policy-gate misses. With
affinityRouting: DisabledtheCachePolicy.spec.minimumPrefixTokensshort-circuit returnsNO_HINTbecause the lookup never touched the index — there's no key-level mismatch to surface. WithaffinityRouting: Enabled(the kubebuilder default) the same below-threshold request runs the full lookup so the index can classify the contract keys: a wrong-tenant/model/scheme request still surfaces as the matchingUNKNOWN_*diagnostic (those keep precedence overAFFINITY_HINT), while a same-key small-prompt request gets itsPREFIX_MATCH/TENANT_HOTdowngraded toStrategyNoneand the affinity fallback surfacesAFFINITY_HINT. Affinity-enabled is the only configuration where a below-minimumPrefixTokensrequest can return a diagnostic code at all. TIMEOUTpaths. Deadline-breach lookups never run the classification step; they returnTIMEOUTdirectly.
| Code | Emitted when | What the caller learns |
|---|---|---|
UNKNOWN_TENANT |
The request supplied a non-empty tenant_id and the index has zero prefix entries for that tenant across every model and hash scheme. |
"The tenant_id I queried with does not match anything the cache plane has heard about." Almost always a configuration error: the producer (engine sidecar) and consumer (gateway client) disagree on the tenant_id convention. |
UNKNOWN_MODEL |
The tenant is known (has entries somewhere) but the (tenant, model_id) pair has zero entries. |
"Right tenant, wrong model_id — there's no cache state for this model in this tenant." Either the model has never served traffic in this tenant or the model identifier disagrees between the producer and consumer. |
UNKNOWN_HASH_SCHEME |
The (tenant, model_id) pair has entries, but none under the request's hash_scheme. |
"Right tenant and model, wrong hash_scheme. The engine domain you asked about is empty; another engine's entries are there." Almost always a string-value typo or a vLLM-version-bump mismatch (e.g. vllm vs vllm-v1). |
These are emitted in outer-to-inner scope order — tenant first, then
model within tenant, then scheme within (tenant, model). A request whose
tenant is wrong gets UNKNOWN_TENANT and stops there; a request whose
tenant matches but whose model doesn't gets UNKNOWN_MODEL; and so on. The
classifier never reports a finer key as the mismatched one when a wider
key (above it in scope) already failed — the wider failure subsumes the
question. So the caller always sees the outermost mismatched key, which
is the one that has to be fixed first regardless of whether the
deeper-scoped keys are right.
On a LookupRoute request the server runs (in order):
- Pre-lookup policy gate —
CachePolicy.spec.minimumPrefixTokens. Below the threshold ANDaffinityRouting: Disabled→ short-circuit toNO_HINT(the cheap historical path). Below the threshold ANDaffinityRouting: Enabled(the kubebuilder default) → the request runs the full lookup so theUNKNOWN_*diagnostic classification below can still surface; if it doesn't, the handler downgrades any resultingPREFIX_MATCH/TENANT_HOTtoStrategyNoneand the affinity fallback may then returnAFFINITY_HINT. The classification step itself is untouched by this design. - Deadline / timeout — if the lookup exceeds the policy budget or the
caller's context is already past its deadline →
TIMEOUT. Untouched. - Prefix-match lookup — the existing
lookupExact/lookupChainranker. On a hit →PREFIX_MATCH. Untouched. TENANT_HOTfallback — only for non-chain requests, and only when the(tenant, model, hash_scheme)has at least one prefix entry. On a hit →TENANT_HOT. Untouched.- Contract-key classification (new) — runs only if step 3 (and step 4
for non-chain requests) found no candidates and the request supplied a
non-empty
hash_scheme. Short-circuits the cold-start case (globally empty index →NO_HINT), then walks the key triple outer-to-inner (widest scope first) and emits the first level that has no data:UNKNOWN_TENANT→UNKNOWN_MODEL→UNKNOWN_HASH_SCHEME. If every level is populated, the miss is a genuinely novel prefix →NO_HINT(the existing fail-open default).
The classification runs only on a miss, so it never adds work to the hot path
of a healthy gateway — PREFIX_MATCH and TENANT_HOT short-circuit before
it. The three lookups it does are all O(1) against secondary indexes
maintained in lockstep with the prefix map (prefixesByTenant,
prefixesByTenantModel, and per-scope servingByScope), so the miss path
stays cheap even when a sustained misconfigured client puts the diagnostic
path under load.
Chain-bearing requests run the same classification as the exact path on a
prefix miss (they do not fall through to TENANT_HOT by design — see
grpc-contract.md "Longest-prefix (block-level)
matching"). The diagnostic codes apply identically — the chain caller's
contract keys can mismatch in the same three ways.
A new code surfaces wrong-configuration to callers that previously had no way to see it. Two reactions are correct depending on the caller:
UNKNOWN_*codes are configuration errors. A well-behaved gateway-SDK treats them like an HTTP 4xx — surface to a log line / metric, emit a warning event, and route as ifNO_HINTwas returned (fail open — the cache plane is still hint-only, never blocking). Do not retry under a different key value; the cache plane will not have changed between calls.NO_HINTcontinues to mean "route normally, fail open." Existing callers that bucket every miss intoNO_HINTkeep working unchanged — by the contract's forward-compatibility rule, an unrecognized code degrades toNO_HINT.
The in-cluster kvevent-subscriber sidecar always sends
--tenant-id=$(POD_NAMESPACE). A gateway-SDK querying LookupRoute therefore
needs tenant_id to be the namespace of the engine pod it is asking
about, not the OpenAI-API-shaped "default" it may have inherited from an
upstream client library.
When a gateway-SDK sees UNKNOWN_TENANT, the operator playbook is:
- Confirm the gateway-SDK is passing the engine namespace as
tenant_id, not a synthetic API-level tenant. - Confirm the subscriber sidecar's
POD_NAMESPACEmatches what the gateway is sending.
A future kvevent-subscriber change to emit additional tenant aliases (e.g. also the engine pod's owning Deployment's namespace) is tracked separately.
inferencecache_lookup_route_calls_total{reason_code=…} already exists with
reason_code as a label, so the three new values appear as new label values
automatically — no metric-schema change. Dashboards filtering on
reason_code=NO_HINT continue to work; dashboards wanting to surface
configuration drift can split out the UNKNOWN_* values directly.
A useful operator query is the ratio of UNKNOWN_* to all misses, per model
— a sudden spike isolates a misdeploy of a producer or a consumer.
Three reasons the change is safe at v1alpha1:
- No proto change.
reason_codeis astring; new values are an additive server-side change. - Old clients degrade safely. The contract's forward-compatibility rule
says "clients MUST treat any unrecognized code as the no-hint default" —
so a gateway built against the pre-diagnostics contract sees
UNKNOWN_TENANTand treats it exactly likeNO_HINT. No new-codes-only test failure surface in any existing integration. - Existing emission paths are unchanged.
PREFIX_MATCH,TENANT_HOT,TIMEOUT, and the policy-gated / empty-hash_schemeNO_HINTpaths all keep their existing behavior. The new codes only narrow the previously ambiguous "miss with populated keys vs miss with mismatched keys" case.
grpc-contract.md—reason_codevocabulary on theLookupRouteResponseenvelope.../reference/reason-codes.md— the reference table of every emitted code, updated in lockstep with this change.lookuproute-ranking.md— the ranking strategies that producePREFIX_MATCHandTENANT_HOT; this design layers diagnostics underneath them on the miss path.