fix(mlx): reject image input when the runner has no vision processor - #2293
fix(mlx): reject image input when the runner has no vision processor#2293zhast wants to merge 4 commits into
Conversation
A request carrying images against a runner whose vision processor is None used to succeed silently: load_mlx_items disables vision on any load failure, submit() only prepares images when a processor exists, and the chat template still renders the image placeholder. The model then answers confidently about an image it never received -- a solid red PNG came back as "Black" with HTTP 200. Raise a request-level UnsupportedRequestError from submit() when images are present but cannot be processed (no processor, or prepare_vision failed), and catch it in BatchGenerator.step() next to PrefillCancelled so the client gets an error while the runner stays up. Text-only requests are unaffected; the text-only fallback is kept for requests without images. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
submit_text_generation registers the task in active_tasks before step() runs. Skipping a rejected task with `continue` left it there with no generator behind it, so the runner loop kept calling step() -- and its per-iteration task-agreement collective -- at full speed and never left RunnerRunning. Emit a terminal FinishedResponse for each rejected task on both return paths of step(), so the runner retires it like any completed one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Verified on a live 4-node pipeline deployment (both commits)Same red 8×8 PNG that previously returned Streaming — explicit error, then the stream terminates normally: Runner health after two rejected requests: all four runners back to One adjacent gap, pre-existing: the non-streaming path returns |
…fore the first token collect_chat_response raised ValueError on an ErrorChunk, but the non-streaming endpoint wraps it in a StreamingResponse whose 200 status is already committed, so the client received an empty body. Yield the same error object the SSE path emits instead, so a rejected request (e.g. image input without a vision processor) is visible to non-streaming clients too. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Keeps the new test free of untyped JSON under strict type checking. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Live verification of all three commits (4-node pipeline deployment)Same red 8×8 PNG that originally returned Non-streaming (previously an empty {"error":{"message":"1 image(s) were provided, but this model instance has no vision processor loaded; image input is not supported here.","type":"InternalServerError","code":500}}Streaming: Runner health: all four runners Checks on the branch: |
Bug
A chat request with an image against a runner whose vision processor is
Nonesucceeds silently and returns a hallucinated answer.Reproduced with a solid red 8×8 PNG on a 4-node pipeline deployment:
HTTP 200, content"Black". The runner log shows the prompt it actually ran ended in<|begin_of_image|><|image|><|end_of_image|>— one bare placeholder token, no vision embeddings.Three things line up to make it silent:
utils_mlx.load_mlx_itemscatches any vision-processor load failure, logs it, and setsvision_processor = None(utils_mlx.py:221-227).ExoBatchGenerator.submitonly prepares imagesif self.vision_processor is not None; otherwise they are dropped (batch_generate.py:137), and aprepare_visionexception also "falls back to text-only".Nothing in the API gates on a vision capability, so the client has no way to know.
Fix
check_vision_support()raises a request-levelUnsupportedRequestError(ValueError)whentask_params.imagesis non-empty and there is no processor;submit()calls it first, and aprepare_visionfailure on a request with images now raises the same error instead of silently degrading. The text-only fallback is kept for requests without images.BatchGenerator.step()catchesUnsupportedRequestErrornext toPrefillCancelled:_send_errorto the client, then emit a terminalFinishedResponsefor the task on both return paths ofstep(). The runner registers a task inactive_tasksbeforestep()runs, so without that terminal result the rejected task would stay active with no generator behind it and the runner loop would spin (I hit exactly that with a first version: four runners stuck inRunnerRunningat ~85% CPU with nothing in flight). The genericexcept Exceptionstill re-raises, so runner-fatal errors behave as before.collect_chat_response()(non-streaming) now yields the same{"error": …}JSON object the SSE path emits instead of raising. The endpoint wraps it in aStreamingResponsewhose 200 status is already committed, so the oldraise ValueErrorleft non-streaming clients with an empty body for anyErrorChunkthat arrived before the first token — a pre-existing gap this bug made visible. Covered bytest_collect_chat_response_error.py; the existingtest_chat_completions_stream.pystill passes.The condition is identical on every rank (same images, same
None), so all pipeline ranks reject and retire the task together and none is left waiting in a collective.Tests
test_vision_gate.py— three pure tests, no model download: images + no processor raises with a clear message; text-only passes; the error is aValueErrorso existing handlers keep working.ruff check,ruff format --check,basedpyrightclean on the changed files.Scope
Covers the default
BatchGeneratorpath.SequentialGeneratorbuilds its generator separately (batch_generator.py:300) and is not changed here.🤖 Generated with Claude Code