Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ host is worth another attempt.

## Model support and sizing (validated 2026-09-02, vllm-omni v0.28.0)

Coverage is an **allowlist of ~72 architectures in diffusers/HF format** —
Coverage is an **allowlist of 48 architectures in diffusers/HF format** —
including FLUX.1/FLUX.2/Kontext, SDXL, and SD3.5. What it does NOT cover:
single-file (.safetensors) community checkpoints, ComfyUI-format repos, and
bare LoRA repos — those stay on the ComfyUI path. Many top models are gated on
Expand Down
5 changes: 5 additions & 0 deletions src/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@

import aiohttp

from startup_errors import classify_runtime_error

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")

OMNI_PORT = os.getenv("VLLM_OMNI_PORT", "8091")
Expand Down Expand Up @@ -164,6 +166,9 @@ async def handler(job: dict) -> Any:

if resp.status >= 400:
text = await resp.text()
runtime_error = classify_runtime_error(text)
if runtime_error:
return {"error": runtime_error, "status": resp.status}
try:
return {"error": json.loads(text), "status": resp.status}
except json.JSONDecodeError:
Expand Down
33 changes: 33 additions & 0 deletions src/startup_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@
_UNSUPPORTED = re.compile(r"No supported model class found|Unsupported model architecture", re.I)
# A checkpoint whose layer counts disagree with the pipeline the engine built
# for it: the tensors it does not recognise are simply never filled.
# A quantisation config the engine cannot parse. `BitsAndBytesConfig.to_dict()`
# emits the private backing fields alongside the public ones, and the engine
# splats the whole dict into a dataclass that declares only the public names.
_QUANT_CONFIG = re.compile(
r"unexpected keyword argument '_load_in|DiffusionBitsAndBytesConfig", re.I
)
_SHAPE_MISMATCH = re.compile(
r"weights were not initialized from checkpoint|size mismatch for", re.I
)
Expand Down Expand Up @@ -67,6 +73,14 @@ def classify(output: str, model: str | None = None) -> str | None:
f"supported-models list for a pipeline that covers it."
)

if _QUANT_CONFIG.search(output):
return (
f"{named} is quantised in a format this engine version cannot read. Its "
f"config stores the bitsandbytes settings under private field names, and "
f"the loader rejects them outright. Deploy the unquantised model it was "
f"derived from, on a GPU large enough to hold it."
)

if _SHAPE_MISMATCH.search(output):
return (
f"{named} does not match the pipeline it declares. Its config names a "
Expand All @@ -78,3 +92,22 @@ def classify(output: str, model: str | None = None) -> str | None:
)

return None


def classify_runtime_error(response_text: str) -> str | None:
"""One actionable sentence for a request the engine could not serve.

Distinct from `classify`: the engine is healthy and only this request
failed, so nothing is exiting and the worker keeps taking jobs. What it
replaces is the raw text, which for an OOM is several paragraphs of
allocator state -- too long to read and, as the platform rejects an
oversized result, sometimes too long to deliver at all.
"""
if _OOM.search(response_text):
return (
"Ran out of GPU memory while generating. The model loaded but left too "
"little room to run: lower the resolution, the frame count or the number "
"of steps, or redeploy on a larger GPU."
)

return None
34 changes: 33 additions & 1 deletion tests/test_startup_errors.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Which engine failures are worth answering, and which are worth a restart."""
import pytest

from startup_errors import classify
from startup_errors import classify, classify_runtime_error

OOM = """
[rank0] torch.OutOfMemoryError: CUDA out of memory. Tried to allocate 108.00 MiB.
Expand Down Expand Up @@ -70,3 +70,35 @@ def test_pruned_variant_of_a_supported_pipeline_is_named_as_such():

def test_a_shape_mismatch_is_reported_rather_than_retried():
assert classify("size mismatch for unet.conv_in.weight", "org/model") is not None


def test_bitsandbytes_config_is_named_rather_than_crash_looped():
# ovedrive/Qwen-Image-Edit-2511-4bit: BitsAndBytesConfig.to_dict() emits the
# private backing fields, and the engine's dataclass declares only the
# public ones, so it exits before serving anything.
output = (
"TypeError: DiffusionBitsAndBytesConfig.__init__() got an unexpected "
"keyword argument '_load_in_4bit'"
)
message = classify(output, "ovedrive/Qwen-Image-Edit-2511-4bit")
assert message is not None
assert "ovedrive/Qwen-Image-Edit-2511-4bit" in message
assert "quantised" in message


def test_runtime_oom_is_shortened_to_something_readable():
# Wan 2.2 I2V A14B loads into 64GB of an 80GB card and then fails here. The
# raw text is several paragraphs of allocator state.
raw = (
"RuntimeError: CUDA out of memory. Tried to allocate 1.94 GiB. GPU 0 has a "
"total capacity of 79.18 GiB of which 1.55 GiB is free. Including non-PyTorch "
"memory, this process has 76.90 GiB memory in use..."
)
message = classify_runtime_error(raw)
assert message is not None
assert len(message) < len(raw)
assert "larger GPU" in message


def test_a_request_that_failed_for_another_reason_is_passed_through():
assert classify_runtime_error('{"error": "size must be WxH"}') is None