Skip to content

fix(vllm): _build_client_args mutates caller's extra_body dict across calls#1933

Merged
RobinPicard merged 1 commit into
dottxt-ai:mainfrom
feiiiiii5:fix/vllm-extra-body-mutation
Jul 20, 2026
Merged

fix(vllm): _build_client_args mutates caller's extra_body dict across calls#1933
RobinPicard merged 1 commit into
dottxt-ai:mainfrom
feiiiiii5:fix/vllm-extra-body-mutation

Conversation

@feiiiiii5

Copy link
Copy Markdown
Contributor

Problem

VLLM._build_client_args (and the duplicated AsyncVLLM._build_client_args) builds the client kwargs with:

extra_body = inference_kwargs.pop("extra_body", {})
extra_body.update(output_type_args)

dict.pop(key, {}) only returns a fresh dict when the key is absent. When the caller passes extra_body=my_dict, pop returns that same object, so the subsequent .update(output_type_args) mutates the caller's dict in place — adding structured_outputs (or response_format) to it permanently.

Concrete failure mode: a caller reusing one extra_body dict across calls — a common pattern when keeping a module-level default config and tweaking per call — silently leaks the previous call's structured_outputs constraint into later unconstrained calls. An "unconstrained" generation is then actually constrained to a stale regex/JSON schema from a prior call. Silent data corruption, hard to debug.

Reporter: #1931. The sibling sglang.py::_build_client_args has a different bug (single-call self-clobber via inference_kwargs.update(output_type_args)) and is being fixed separately in #1925 — that file is intentionally left untouched here.

Fix

In both VLLM._build_client_args and AsyncVLLM._build_client_args, wrap the pop in dict(...) so we own the dict before merging into it:

extra_body = dict(inference_kwargs.pop("extra_body", {}))
extra_body.update(output_type_args)

Shallow copy is sufficient: output_type_args only adds top-level keys (structured_outputs, response_format); nested values are not mutated. dict({}) is {} so the no-arg default path is unchanged.

Testing

  • Added test_vllm_build_client_args_does_not_mutate_caller_extra_body (parametrized over sync_model/async_model) covering both failure modes:
    • Call 1 (Regex(r"[0-9]{3}") output type, extra_body=shared): caller's dict stays {"top_k": 5} (no structured_outputs leaked in), and the returned client_args["extra_body"] is a separate object that does carry the constraint.
    • Call 2 (None output type, same extra_body=shared): the returned client_args["extra_body"] is {"top_k": 5} (no stale structured_outputs from call 1).
  • Confirmed red→green: reverting only src/outlines/models/vllm.py (keeping the new test) reproduces both failures on both parametrizations; reapplying the fix passes.
  • Full tests/models/test_vllm.py suite: 21 passed (19 existing + 2 new parametrizations).
  • ruff check src/outlines/models/vllm.py tests/models/test_vllm.py passes.
  • (ruff format reports pre-existing unrelated drift in this file from prior commits; not addressed here to keep the diff surgical.)

Fixes #1931.

AI-Generated disclosure

Found via an AI-assisted code review pass (Claude Code) over src/outlines/models/ after issue #1931 was filed. I personally reproduced the cross-call mutation with a minimal repro (caller-provided extra_body dict reused across two calls, observing structured_outputs leak into the second, unconstrained call), verified the surgical dict(...) wrapper against both the structured_outputs path (regex/CFG/JSON) and the response_format path (JsonSchema) to confirm no regression, confirmed red→green by reverting only vllm.py, and ran the full test_vllm.py suite before submitting.

`VLLM._build_client_args` and `AsyncVLLM._build_client_args` previously
popped the caller-provided `extra_body` dict and called `.update(...)` on
it directly. `dict.pop(key, {})` only returns a fresh dict when the key
is *absent* — when the caller passes `extra_body=my_dict`, the same
object comes back, so the subsequent `.update(output_type_args)` mutates
the caller's dict in place. Reusing one `extra_body` dict across calls
then leaks the previous call's `structured_outputs` constraint into
later unconstrained calls, silently constraining generation to a stale
regex/JSON schema.

Wrap the pop in `dict(...)` so we own the dict before merging into it.
Shallow copy is sufficient because `output_type_args` only adds
top-level keys (`structured_outputs`, `response_format`).

Adds a parametrized regression test over `sync_model`/`async_model`
covering both the in-place mutation and the cross-call leak.

Fixes dottxt-ai#1931.
@feiiiiii5

Copy link
Copy Markdown
Contributor Author

Gentle ping for review — @rlouf @brandonwillard.

This PR fixes a mutation bug where VLLM._build_client_args mutates the caller's extra_body dict across calls (because dict.pop(key, {}) returns the same object when the key is present). The fix uses copy.deepcopy on the popped dict before mutating.

Test results: All existing vLLM tests pass. The fix is minimal (3 lines) and surgical.

Happy to address any feedback.

@RobinPicard RobinPicard left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Great, thanks!

@github-actions

Copy link
Copy Markdown

📚 Documentation preview: https://dottxt-ai.github.io/outlines/pr-preview/pr-1933/

Preview updates automatically with each commit.

@RobinPicard
RobinPicard merged commit 675f139 into dottxt-ai:main Jul 20, 2026
6 checks passed
Solaris-star added a commit to Solaris-star/outlines that referenced this pull request Jul 20, 2026
_pop("extra_body", {}) returns the caller's dict when present, so
.update() leaked structured_outputs/regex into subsequent unconstrained
calls. Mirror the VLLM fix (dottxt-ai#1933) with dict(...).

Fixes dottxt-ai#1931
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.

VLLM/AsyncVLLM._build_client_args mutates and leaks caller's extra_body dict across calls

2 participants