From 579b5e26d406740694a6588ca1a5e447e5dcde83 Mon Sep 17 00:00:00 2001 From: fei <3303354867@qq.com> Date: Fri, 17 Jul 2026 15:54:59 +0800 Subject: [PATCH] fix(vllm): copy caller's extra_body before merging structured_outputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 #1931. --- src/outlines/models/vllm.py | 12 +++++++++-- tests/models/test_vllm.py | 40 +++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/outlines/models/vllm.py b/src/outlines/models/vllm.py index 16e972df8..9b15d70d1 100644 --- a/src/outlines/models/vllm.py +++ b/src/outlines/models/vllm.py @@ -199,7 +199,11 @@ def _build_client_args( """Build the arguments to pass to the OpenAI client.""" messages = self.type_adapter.format_input(model_input) output_type_args = self.type_adapter.format_output_type(output_type) - extra_body = inference_kwargs.pop("extra_body", {}) + # Copy the caller-provided dict so we never mutate it in place when + # merging structured_outputs below. Reusing the same `extra_body` dict + # across calls previously leaked the previous call's constraints into + # later unconstrained calls (see GH#1931). + extra_body = dict(inference_kwargs.pop("extra_body", {})) extra_body.update(output_type_args) if "model" not in inference_kwargs and self.model_name is not None: @@ -340,7 +344,11 @@ def _build_client_args( """Build the arguments to pass to the OpenAI client.""" messages = self.type_adapter.format_input(model_input) output_type_args = self.type_adapter.format_output_type(output_type) - extra_body = inference_kwargs.pop("extra_body", {}) + # Copy the caller-provided dict so we never mutate it in place when + # merging structured_outputs below. Reusing the same `extra_body` dict + # across calls previously leaked the previous call's constraints into + # later unconstrained calls (see GH#1931). + extra_body = dict(inference_kwargs.pop("extra_body", {})) extra_body.update(output_type_args) if "model" not in inference_kwargs and self.model_name is not None: diff --git a/tests/models/test_vllm.py b/tests/models/test_vllm.py index fd03f32be..18ec43fce 100644 --- a/tests/models/test_vllm.py +++ b/tests/models/test_vllm.py @@ -370,3 +370,43 @@ async def test_vllm_async_cfg(async_model): result = await async_model("foo?", CFG(YES_NO_GRAMMAR), max_tokens=10) assert isinstance(result, str) assert result in ["yes", "no"] + + +@pytest.mark.parametrize("model_fixture", ["sync_model", "async_model"]) +def test_vllm_build_client_args_does_not_mutate_caller_extra_body(request, model_fixture): + """Regression test for https://github.com/outlines-dev/outlines/issues/1931. + + ``_build_client_args`` must not mutate the caller-provided ``extra_body`` + dict in place when merging ``structured_outputs``. Reusing the same dict + across calls previously leaked the previous call's constraints into later + unconstrained calls. + """ + model = request.getfixturevalue(model_fixture) + + shared_extra_body = {"top_k": 5} + snapshot = dict(shared_extra_body) + + # Call 1: a Regex output type adds `structured_outputs` to the merged + # extra_body that gets forwarded to the OpenAI client. + args_with_constraint = model._build_client_args( + "prompt one", + Regex(r"[0-9]{3}"), + extra_body=shared_extra_body, + ) + + # The caller's dict must be untouched, and the merged dict must be a + # separate object carrying the constraint. + assert shared_extra_body == snapshot + assert args_with_constraint["extra_body"] is not shared_extra_body + assert "structured_outputs" in args_with_constraint["extra_body"] + + # Call 2: no output type — the stale constraint from call 1 must not + # leak through the shared dict into this unconstrained call. + args_without_constraint = model._build_client_args( + "prompt two", + None, + extra_body=shared_extra_body, + ) + + assert shared_extra_body == snapshot + assert "structured_outputs" not in args_without_constraint["extra_body"]