From 1eee8435e1dcac273bf4437ae77d63169fd6d90d Mon Sep 17 00:00:00 2001 From: WeiQing Chen <40507679+david6666666@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:24:57 +0800 Subject: [PATCH] [Bugfix] fix: return 400 for unsupported multi-image edits such as Qwen-Image-Layered (#2298) Signed-off-by: David Chen <530634352@qq.com> --- .../openai_api/test_image_server.py | 23 +++++++++++++++++++ .../entrypoints/test_async_omni_diffusion.py | 15 ++++++++++++ tests/entrypoints/test_omni_entrypoints.py | 13 +++++++++++ vllm_omni/entrypoints/async_omni.py | 17 ++++++++++++++ vllm_omni/entrypoints/async_omni_diffusion.py | 4 ++++ vllm_omni/entrypoints/openai/api_server.py | 19 +++++++++++++++ 6 files changed, 91 insertions(+) create mode 100644 tests/entrypoints/test_async_omni_diffusion.py diff --git a/tests/entrypoints/openai_api/test_image_server.py b/tests/entrypoints/openai_api/test_image_server.py index 61ede2283..18ec96a9f 100644 --- a/tests/entrypoints/openai_api/test_image_server.py +++ b/tests/entrypoints/openai_api/test_image_server.py @@ -740,6 +740,29 @@ def test_image_edit_images_processing(async_omni_test_client): assert processed_images[1].size == (24, 24) +def test_image_edit_rejects_multiple_images_when_model_does_not_support_them(async_omni_test_client): + img_bytes_1 = make_test_image_bytes((16, 16)) + img_bytes_2 = make_test_image_bytes((32, 32)) + + engine = async_omni_test_client.app.state.engine_client + engine.get_diffusion_od_config = lambda: SimpleNamespace(supports_multimodal_inputs=False) + + response = async_omni_test_client.post( + "/v1/images/edits", + files=[ + ("image", img_bytes_1), + ("image", img_bytes_2), + ], + data={"prompt": "hello world."}, + ) + + assert response.status_code == 400 + assert ( + response.json()["detail"] == "Received multiple input images. Only a single image is supported by this model." + ) + assert engine.captured_prompt is None + + def test_image_edit_parameter_pass(async_omni_test_client): img_bytes_1 = make_test_image_bytes((16, 16)) diff --git a/tests/entrypoints/test_async_omni_diffusion.py b/tests/entrypoints/test_async_omni_diffusion.py new file mode 100644 index 000000000..c0eae0992 --- /dev/null +++ b/tests/entrypoints/test_async_omni_diffusion.py @@ -0,0 +1,15 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import pytest + +from vllm_omni.entrypoints.async_omni_diffusion import AsyncOmniDiffusion + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def test_get_diffusion_od_config_returns_direct_config(): + diffusion = object.__new__(AsyncOmniDiffusion) + diffusion.od_config = object() + + assert diffusion.get_diffusion_od_config() is diffusion.od_config diff --git a/tests/entrypoints/test_omni_entrypoints.py b/tests/entrypoints/test_omni_entrypoints.py index 2bab12b80..0aeb6158e 100644 --- a/tests/entrypoints/test_omni_entrypoints.py +++ b/tests/entrypoints/test_omni_entrypoints.py @@ -379,6 +379,19 @@ def test_openai_serving_models_can_consume_async_omni_compat_attrs(): assert serving_models.input_processor is input_processor +def test_get_diffusion_od_config_returns_diffusion_stage_config(): + diffusion_od_config = object() + omni = object.__new__(AsyncOmni) + omni.engine = SimpleNamespace( + stage_clients=[ + SimpleNamespace(stage_type="llm"), + SimpleNamespace(stage_type="diffusion", _engine=SimpleNamespace(od_config=diffusion_od_config)), + ] + ) + + assert omni.get_diffusion_od_config() is diffusion_od_config + + @pytest.mark.asyncio async def test_async_omni_yields_only_final_stage_outputs(monkeypatch: pytest.MonkeyPatch): engine = FakeAsyncOmniEngine( diff --git a/vllm_omni/entrypoints/async_omni.py b/vllm_omni/entrypoints/async_omni.py index ab638b4b2..68c072c2b 100644 --- a/vllm_omni/entrypoints/async_omni.py +++ b/vllm_omni/entrypoints/async_omni.py @@ -118,6 +118,23 @@ class AsyncOmni(EngineClient, OmniBase): """Compatibility helper for call sites expecting async vllm config access.""" return self.vllm_config + def get_diffusion_od_config(self) -> Any | None: + """Return the diffusion-stage config when the pipeline has one.""" + for stage_client in self.engine.stage_clients: + if getattr(stage_client, "stage_type", None) != "diffusion": + continue + + od_config = getattr(stage_client, "od_config", None) + if od_config is not None: + return od_config + + inner_engine = getattr(stage_client, "_engine", None) + od_config = getattr(inner_engine, "od_config", None) + if od_config is not None: + return od_config + + return None + @property def model_config(self): """Return the model config for the comprehension stage when present.""" diff --git a/vllm_omni/entrypoints/async_omni_diffusion.py b/vllm_omni/entrypoints/async_omni_diffusion.py index 0ecd7c2ab..a7a02eded 100644 --- a/vllm_omni/entrypoints/async_omni_diffusion.py +++ b/vllm_omni/entrypoints/async_omni_diffusion.py @@ -251,6 +251,10 @@ class AsyncOmniDiffusion: finished=True, ) + def get_diffusion_od_config(self) -> OmniDiffusionConfig: + """Return the diffusion config used by this engine.""" + return self.od_config + # ------------------------------------------------------------------ # Public generate API # ------------------------------------------------------------------ diff --git a/vllm_omni/entrypoints/openai/api_server.py b/vllm_omni/entrypoints/openai/api_server.py index 9f5d465b3..e275ffbd6 100644 --- a/vllm_omni/entrypoints/openai/api_server.py +++ b/vllm_omni/entrypoints/openai/api_server.py @@ -1406,6 +1406,11 @@ async def edit_images( if not input_images_list: raise HTTPException(status_code=422, detail="Field 'image' or 'url' is required") pil_images = await _load_input_images(input_images_list) + if len(pil_images) > 1 and not _supports_multimodal_image_inputs(raw_request, engine_client): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST.value, + detail="Received multiple input images. Only a single image is supported by this model.", + ) prompt["multi_modal_data"] = {} prompt["multi_modal_data"]["image"] = pil_images @@ -1584,6 +1589,20 @@ def _get_engine_and_model(raw_request: Request): return engine_client, model_name, normalized_stage_configs +def _supports_multimodal_image_inputs(raw_request: Request, engine_client: Any) -> bool: + diffusion_engine = getattr(raw_request.app.state, "diffusion_engine", None) or engine_client + get_diffusion_od_config = getattr(diffusion_engine, "get_diffusion_od_config", None) + od_config = ( + get_diffusion_od_config() if callable(get_diffusion_od_config) else getattr(diffusion_engine, "od_config", None) + ) + + if od_config is None: + # Preserve the existing compatibility behavior when the diffusion + # config is not exposed on the serving surface. + return True + return bool(getattr(od_config, "supports_multimodal_inputs", False)) + + def _get_lora_from_json_str(lora_body): if lora_body is None: return None