Skip to content

[Test] Conformance suite for cache_salt across OpenAI entrypoints - #55886

Open
Tejasvichand wants to merge 1 commit into
vllm-project:mainfrom
Tejasvichand:conformance-entrypoint-cache-salt
Open

Tejasvichand wants to merge 1 commit into
vllm-project:mainfrom
Tejasvichand:conformance-entrypoint-cache-salt

Conversation

@Tejasvichand

@Tejasvichand Tejasvichand commented Sep 8, 2026 •

Copy link
Copy Markdown

Purpose

Implements the entrypoint axis of #53194, the half left open when @oneKn8 took
the tier axis in #53769. Tests only; no production file is touched.

cache_salt partitions the prefix cache only if it survives the whole trip from
an HTTP request body down to the block hasher. tests/v1/core/test_prefix_caching.py
asserts that differing salts produce differing block hashes, but it builds
Request objects directly, so it begins after the plumbing that keeps
regressing.

Five cases over /v1/completions, /v1/chat/completions and /v1/embeddings:

  • same prompt under two different salts must not reuse blocks
  • same prompt under the same salt must reuse them
  • a request that omits cache_salt must not land in a salted namespace
  • the pooling path, which rebuilds prompts before they reach the hasher
  • the pooling path with chunked preprocessing enabled — a strict xfail, below

Assertions read vllm:prefix_cache_hits rather than
usage.prompt_tokens_details, which is only populated under
--enable-prompt-tokens-details and is hard-coded to zero on the pooling path.
Nothing inspects the extra-key tuple, so this is unaffected by #44706, #51899,
#52655 and #43789, each of which changes that encoding.

What each cited bug gets

Being precise about this, since the motivation is broader than the coverage:

Bug Covered here
#47696 pooling drops the salt yes — strict xfail, currently failing
empty salt shared the unsalted namespace yes — a plain passing arm. Found by @rishabhsinha17 reviewing this PR, and already fixed on main by #54353 (min_length=1) and #51444 (validate_cache_salt), so it pins the rejection rather than the bug
#51818 Harmony tool turns no — the Responses API is not exercised; its bug is a continuation, needing a multi-turn case
#51748 HF3FS external keys no — connector tier. HF3FSKVConnector is a declared rederiver in #53769's exempt map with an evidence pointer, but nothing in-tree executes that path, since CI cannot import the hf3fs client

The Responses, Anthropic /v1/messages, classification and scoring endpoints
also accept cache_salt and are not covered. Worth follow-up.

#47696 reproduces on current main

test_chunked_embeddings_isolate_on_salt is xfail(strict=True), so it flips
to a loud XPASS when #47696 lands. Controlled A/B, two identical pooling
servers differing only in pooler_config.enable_chunked_processing, two trials
each:

config                              diff-salt  same-salt   verdict
enable_chunked_processing: false            0        128   isolated
                                            0        128   isolated
enable_chunked_processing: TRUE           128        128   128 victim tokens served
                                          128        128   128 victim tokens served

One detail wider than #47696's description: the prompt is 256 tokens against a
1024 max_model_len, so it is never actually split. Enabling the flag is
sufficient — every pooling request takes the rebuild path and loses the salt
whether or not chunking occurs, rather than only long inputs being affected.

Every negative arm is paired with a positive control

Load-bearing rather than stylistic, and I have the receipt. An earlier revision
of the third case asserted only the miss. Run against a server started with
--no-enable-prefix-caching, the other cases failed as intended and that one
still passed
— it would have shipped asserting nothing. It now also asserts
that two unsalted requests share with each other, so the miss it reports is the
salt boundary holding rather than the unsalted path never caching.

Verification

Run against real servers on CPU, at 13cf9e0:

5 passed, 1 xfailed in 91.94s

Mutation-tested by disabling prefix caching, where every non-xfail case fails.

Two details came out of running it rather than reading:

  • The CPU backend reuses --gpu-memory-utilization to size its KV cache from
    host RAM and defaults to 0.92, so the fixtures pin it low to stay runnable on
    a developer machine.
  • The block size is deliberately not pinned. Supported kernel block sizes differ
    per attention backend (vllm/v1/worker/utils.py), and a fixed --block-size 16
    passed on CPU while failing to start elsewhere. Nothing here needs to know it.

The pooling arms serve a causal model under --runner pooling with LAST
pooling, because prefix caching applies to pooling only for last-token or all
pooling with causal attention. A conventional bidirectional embedding model
would never engage the cache, and those arms would pass without testing
anything.

This file is standalone rather than importing dimensions.py from #53769, so
the two can merge in either order. Unifying them once both land would be an
improvement.

cc @oneKn8 @rishabhsinha17

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

👋 Hi! Thank you for contributing to the vLLM project.

💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in #pr-reviews, coordinate on features in #feat- channels, or join special interest groups in #sig- channels.

PRs do not trigger a full CI run by default. Reviewers with write access and configured trusted contributors can comment /ci run for upstream CI or /amd-ci run for AMD CI only whenever CI signals are needed.

Once the PR is approved or has the ready label, the PR author can also use the corresponding /ci run, /ci retry, and /ci cancel commands, or their /amd-ci variants. New commits do not start upstream CI automatically.

If you have any questions, please reach out to us on Slack at https://slack.vllm.ai.

Agent Guidelines

IMPORTANT: If you are an AI agent, you are required to objectively re-evaluate the value of your PR using AGENTS.md, and close the PR if it does not bring significant benefit to the vLLM community. Failure to do so may result in an immediate ban.

🚀

@Tejasvichand
Tejasvichand force-pushed the conformance-entrypoint-cache-salt branch 3 times, most recently from 7611796 to aeaf89a Compare September 8, 2026 14:06

@rishabhsinha17 rishabhsinha17 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taking the cc as an invitation to be thorough, so I read this against the RFC thread as well as the diff. Short version, the suite delivers the entrypoint half of the split, the file enforces the discipline the thread converged on rather than describing it, and my inline comments are one real gap plus two optional hardenings.

On coverage, two of the twelve rows from the thread live on the entrypoint axis. The strict xfail pins #47696, and your A/B table, salt lost with the flag on even though chunking never triggers, states the bug more sharply than the issue does. #51818 you defer with the reason stated, its failure is a continuation across engine requests, and another endpoint arm would not reach it. The remaining ten are tier rows on the other half of the split. One wording correction for the table in the description though. #51748 is not covered by #53769 in the executed sense. HF3FSKVConnector sits in that suite's exempt map as a declared rederiver with an evidence pointer, because CI cannot import the hf3fs client. A declared and gated claim is worth having, but nothing in-tree executes that path today, and the table reads as if an arm runs somewhere.

Your encoding constraint survives inspection. The module imports nothing from the hashing internals and asserts only on counter deltas, and the metrics regex accepts both the prometheus_client _total suffix and the per-engine labels, which is how loggers.py emits vllm:prefix_cache_hits. That counter counts tokens, so a zero delta is zero reused tokens. Positive controls are real controls here, and the story of the omitted-salt arm passing against a no-prefix-caching server until you added its control deserves the record, the RFC's predicted failure mode caught inside the file arguing for the RFC.

The split with #53769 is clean. File sets are disjoint, neither module imports the other, and dimensions.py stays theirs, so merge order is free, and unifying after both merge is the right sequencing.

On CI, the Part 1 entrypoints job collects this file and runs plain pytest over entrypoints/openai with the subdirectories ignored. The module runs serially in CI as a result, which matches the docstring's no-xdist requirement. I could not run the suite here, no GPU and no CPU build of this tree on my machine, so the pass counts are yours, but the file compiles, the repo ruff and format checks pass on it, and every flag it passes exists on this branch, including the pooler-config enable_chunked_processing field and the runner and convert pair.

# rather than the unsalted path failing to cache at all.
before = _read_hits(server)
_post(server, "v1/completions", _completions_body(prompt_ids, None))
_assert_reuse(_read_hits(server) - before, "completions, both unsalted")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Omission is covered here, and the empty string is a third state, live on one endpoint family. Completions, chat, responses, anthropic and the scale-out protocol all pin min_length=1 on cache_salt. The pooling base protocol does not, and the extra-key construction in kv_cache_utils.py checks truthiness rather than is not None, so an empty-string salt on /v1/embeddings returns 200 and shares the unsalted namespace. A tenant whose client emits an empty salt by mistake silently shares blocks with every unsalted request, which is this suite's bug class arriving through the front door. Worth either a pooling arm pinning the empty-string behavior, or better, a one-line min_length=1 on the pooling base protocol as a sibling change with an arm asserting the 422 to match the other endpoints. Pinning current behavior would pin the footgun, so I would take the second.

#44706, #51899, #52655 and #43789, each of which changes that encoding.

The counters are process-global, so these tests must run sequentially
against their own server. Do not run this module under pytest-xdist.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI honors this today because the Part 1 job runs the directory with plain pytest, but nothing enforces it if that job ever grows -n or someone runs the module under xdist locally. A module-scoped autouse fixture asserting PYTEST_XDIST_WORKER is absent from the environment would turn this sentence into a failure instead of a hope. Optional.



@pytest.fixture(scope="module")
def chunked_pooling_server():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two fixtures differ only in the pooler-config JSON. A small helper taking a chunked flag and building the arg list would keep the A/B pair from drifting when someone later tunes one of them. Optional, the duplication reads fine as written.

@rishabhsinha17

Copy link
Copy Markdown

Thanks for taking the fix route in #56199 and for the credit. The autouse guard is what I had in mind, and the squash left everything else as reviewed. One correction on the new empty salt arm before it can do its job. The assert expects 422 but the server returns 400. FastAPI only sends its default 422 when nothing intercepts it, and vLLM does intercept it, vllm/entrypoints/serve/exception_handling/handlers/validation.py registers a RequestValidationError handler that maps body validation failures to HTTPStatus.BAD_REQUEST with the OpenAI error envelope. Existing pooling tests expect that already, tests/entrypoints/pooling/embed/test_online.py checks status_code == 400 for a pydantic rejection on the same endpoint. So the arm keeps xfailing after #56199 merges and strict has nothing to catch, while the file keeps claiming the bug is open. Changing the assert and the docstring to 400 makes the sequence work. #56199 merges first, this branch rebases, the arm goes XPASS and gets flipped to a plain passing test before this merges.

@rishabhsinha17 rishabhsinha17 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Diffed 12ba68f7 against the aeaf89ae head I reviewed, and the squash adds 52 lines and changes nothing else. The autouse guard is in and turns an xdist run into a failure instead of a silent mis-measurement, and the empty-salt arm now asserts the 400 the validation handler returns, with the reasoning written into the docstring and the comment. The strict xfail can do its job, it holds while #56199 sits open and forces the flip to a plain passing arm after a rebase over the fix. Nothing from my earlier comments is left open, so approving at 12ba68f7.

@rishabhsinha17 rishabhsinha17 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Diffed 12ba68f7 against the aeaf89ae head I reviewed, and the squash adds 52 lines and changes nothing else. The autouse guard is in and turns an xdist run into a failure instead of a silent mis-measurement, and the empty-salt arm now asserts the 400 the validation handler returns, with the reasoning written into the docstring and the comment. The strict xfail can do its job, it holds while #56199 sits open and forces the flip to a plain passing arm after a rebase over the fix. Nothing from my earlier comments is left open, so approving at 12ba68f7.

Implements the entrypoint axis of vllm-project#53194. Asserts that cache_salt
partitions the prefix cache end to end, from the HTTP request body
through to the block hasher, rather than at the hashing layer alone.

Every case pairs a negative arm with a positive control, since a test
that only asserts "different salts do not share" also passes when
prefix caching is disabled entirely.

Tests only; no production file is touched.

Signed-off-by: Tejasvi Addagada <tejasvichand@gmail.com>
@Tejasvichand
Tejasvichand force-pushed the conformance-entrypoint-cache-salt branch from 12ba68f to 28c48ff Compare September 23, 2026 11:00
@Tejasvichand

Copy link
Copy Markdown
Author

Rebased onto current main (74370a0e3) and re-verified every arm there. One arm changed, and
the reason is a mistake of mine worth stating plainly.

#56199 was already fixed before I opened it, so I have closed it. min_length=1 landed on
the pooling cache_salt field in #54353 (2026-08-30) and #51444 (2026-09-05) added the
check_cache_salt_support validator, which routes the value through validate_cache_salt and
raises on an empty string. I had built and tested from a branch based on my fork's main,
frozen at e9d1398 (2026-08-22), so the leak I reported reproduced against a three-week-old
tree rather than against main.

That has a direct consequence here, and it is the one @rishabhsinha17 predicted — just for a
different reason than either of us expected. test_empty_salt_does_not_match_unsalted_prefix
was xfail(strict=True) against #56199, so on current main it would XPASS and fail the suite.
It is now a plain passing arm that pins the rejection rather than the bug, citing #54353 and
#51444.

One property of that arm changed with it. It now asserts a validation error, not cache
behaviour, so unlike the others it does not depend on prefix caching and has no reuse control
pairing it — the 400 is asserted directly instead. The docstring says so, since the paired-arm
rule this suite argues for would otherwise look violated. The reuse check that follows still
earns its place: it confirms the request is refused before reaching the cache.

The #47696 arm was the open question and I did not assume it. Re-run against current main it
still XFAILs, so that bug is live rather than stale, despite #55551 and #56505 having touched
chunked pooling since my old base.

On current main:

5 passed, 1 xfailed in 91.94s

Mutation-tested by disabling prefix caching, where the four cache-isolation arms fail as they
should:

4 failed, 1 passed, 1 xfailed

The one that passes there is the empty-salt arm, for the reason above.

@rishabhsinha17 — your approvals were given on the premise that #56199 was open, so this head
deserves a fresh look rather than carrying them forward. Sorry for the detour; the catch itself
was right, it was the tree underneath it that was stale.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants