Skip to content
Merged
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
fc8efdb
fix(vllm): fail fast when Mistral-tokenizer image input has no OpenCV
glamr-agent Sep 4, 2026
3de7a67
fix(vllm): import cv2 in the preflight and honour a zero image limit
glamr-agent Sep 5, 2026
4d51a9e
test(vllm): scope the fake cv2 to tmp_path and clarify the installer'…
glamr-agent Sep 5, 2026
b10c487
fix(vllm): ask the registry which modalities the model supports
glamr-agent Sep 5, 2026
a6919a6
fix(vllm): ship a codec-free OpenCV in the vLLM runtime image
glamr-agent Sep 11, 2026
0012284
fix(vllm): scope codec-free OpenCV guidance
glamr-agent Sep 11, 2026
c10a95f
merge: sync main into dyn-4286 vllm runtime opencv dependency
glamr-agent Sep 14, 2026
22d2373
merge: sync main into dyn-4286-vllm-runtime-opencv-dependency-660d4b5…
glamr-agent Sep 14, 2026
7d000d6
merge: sync main into dyn-4286-vllm-runtime-opencv-dependency-660d4b5…
glamr-agent Sep 14, 2026
c3b7bb5
fix(vllm): report backendless OpenCV video failures
glamr-agent Sep 14, 2026
d845460
style(vllm): format decoder backend guard
glamr-agent Sep 14, 2026
5bd0c6b
test(vllm): use package codec errors import
glamr-agent Sep 14, 2026
2cbfb5d
test(vllm): satisfy module import lint
glamr-agent Sep 14, 2026
ad7a975
style(vllm): order multimodal test imports
glamr-agent Sep 14, 2026
860bc83
merge: sync main into dyn-4286-vllm-runtime-opencv-dependency-660d4b5…
glamr-agent Sep 14, 2026
4818893
merge: sync main into dyn-4286-vllm-runtime-opencv-dependency-660d4b5…
glamr-agent Sep 14, 2026
dfd0d91
Merge remote-tracking branch 'upstream/main' into dyn-4286-vllm-runti…
glamr-agent Sep 14, 2026
3667279
fix(vllm): detect OpenCV video backends case-insensitively
glamr-agent Sep 14, 2026
ea8d506
docs(vllm): clarify media decoder installer scope
glamr-agent Sep 14, 2026
dda3fcd
merge: update from main
glamr-agent Sep 14, 2026
b231a35
style(multimodal): apply black to video_loader
glamr-agent Sep 14, 2026
9a15ef3
Merge branch 'main' into dyn-4286-vllm-runtime-opencv-dependency-660d…
yunzhoul-nv Sep 14, 2026
d69495b
fix(vllm): preserve source-built OpenCV version hint
glamr-agent Sep 14, 2026
b77c09b
Merge branch 'main' of https://github.com/ai-dynamo/dynamo into dyn-4…
glamr-agent Sep 14, 2026
c862393
Merge branch 'main' of https://github.com/ai-dynamo/dynamo into dyn-4…
glamr-agent Sep 14, 2026
9ec9d77
fix(vllm): satisfy black on the OpenCV version hint
glamr-agent Sep 14, 2026
1950f3d
fix(multimodal): match the OpenCV wheel by version prefix
glamr-agent Sep 14, 2026
623d1d9
fix(multimodal): import the decode carrier before classifying it
glamr-agent Sep 14, 2026
ece20cc
docs(multimodal): fall back when OpenCV has no distribution metadata
glamr-agent Sep 14, 2026
221759c
fix(vllm): replace broken decoder carriers
glamr-agent Sep 14, 2026
d70149e
fix(multimodal): defer fixture import until after collection
glamr-agent Sep 15, 2026
fcd2ee2
fix(vllm): harden codec-free OpenCV build guards
glamr-agent Sep 15, 2026
64014c7
Merge branch 'main' into dyn-4286-vllm-runtime-opencv-dependency-660d…
yunzhoul-nv Sep 15, 2026
e927b6f
fix(vllm): restore software video decoder installation
glamr-agent Sep 15, 2026
d15043a
fix(vllm): align decoder diagnostics and device policy
glamr-agent Sep 15, 2026
da34f7a
docs(multimodal): fall back to the bounded OpenCV spec
glamr-agent Sep 15, 2026
c7e3980
fix(multimodal): keep OpenCV diagnostics usable when cv2 is broken
glamr-agent Sep 15, 2026
be60c13
test(multimodal): assert the wheel-only install hint for a broken cv2
glamr-agent Sep 15, 2026
8345843
fix(multimodal): preserve guidance for broken OpenCV imports
glamr-agent Sep 16, 2026
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
122 changes: 115 additions & 7 deletions components/src/dynamo/common/multimodal/codec_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@

from __future__ import annotations

import importlib
import importlib.metadata

from dynamo.common.multimodal.nvdec_decoder import HW_ROUTED_CODECS, nvdec_available
from dynamo.common.utils.install_media_decoders import VALIDATED_SPECS
from dynamo.common.utils.install_media_decoders import VALIDATED_SPECS, installer_covers

INSTALLER_CMD = "python -m dynamo.common.utils.install_media_decoders"

Expand All @@ -35,14 +38,106 @@ class MissingMediaDecoderError(RuntimeError):
"""


def _install_hint(backend: str, package: str) -> str:
spec = VALIDATED_SPECS[package]
def _carrier_present(module: str) -> bool:
"""Whether the decode carrier imports here, however it was built.

Real import, not ``find_spec``, for the same reason the installer probe in
:mod:`dynamo.common.utils.install_media_decoders` uses one: a carrier whose
files are present but whose native libraries cannot load -- a partially
removed wheel, a missing system library -- still has a spec. Calling that
present puts the source-build diagnosis and its wheel-swap remedy on a
carrier that is simply broken, telling the operator their OpenCV has no
video backend when in truth it does not load at all. The generic branch
serves that case better: it offers the validated install, which replaces
the broken files.

Any failure to import answers False, not just ``ImportError``. This runs
while an error message is being built for a decode that already failed, so
letting an exception out would replace an actionable message with an
unrelated traceback; and whatever the carrier raised, it is not usable
here, which is the whole question.
Comment thread
glamr-agent marked this conversation as resolved.
Resolved
"""
try:
importlib.import_module(module)
except Exception: # noqa: BLE001 - any import failure means unusable here
return False
return True


def _is_vllm_source_built_cv2(backend: str, package: str, module: str) -> bool:
"""Whether this is the codec-free OpenCV build shipped by vLLM."""
return (
f"install the validated decoder with `pip install --no-deps '{spec}'` "
f"(or `{INSTALLER_CMD} {backend}`)"
backend == "vllm"
and package == "opencv-python-headless"
and module == "cv2"
and _carrier_present(module)
)


def _installed_version(package: str) -> str | None:
"""The installed distribution version, or None when it has no metadata."""
try:
return importlib.metadata.version(package)
except importlib.metadata.PackageNotFoundError:
return None


def _install_hint(backend: str, package: str, module: str) -> str:
"""The remedy line: a pip command that works from where the caller is.

Where the carrier is absent, install the validated spec. Where it is
already importable but unusable for this input -- the vLLM images ship an
OpenCV built from source with no video backend -- the remedy is to swap
that build for the binary wheel, so it pins the version already installed.
Reusing the validated spec there would be wrong twice over: it can be
satisfied by what is present, so pip changes nothing, and its upper bound
can be below the version the image ships, so a caller who ran it anyway
would be downgraded off what the backend resolved.

The bundled installer is offered only where it installs this package for
this backend; elsewhere it exits 0 having fixed nothing.
"""
if _is_vllm_source_built_cv2(backend, package, module):
# Replacing a build that is already here, so pin what is installed. The
# validated range would be wrong twice: it can be satisfied by what is
# present, so pip changes nothing, and its upper bound can sit below
# the version the image ships. That version is whatever the backend
# resolved, so this is deliberately not called a validated install.
installed = _installed_version(package)
if installed:
hint = (
"replace the shipped build with the binary wheel of the same "
f"version: `pip install --no-deps --force-reinstall "
f"--only-binary {package} '{package}=={installed}'`"
)
else:
# Nothing to pin from, so the operator reads the version off the
# module -- but the two version strings are not the same string.
# ``cv2.__version__`` is the OpenCV library version and stops at
# three components, while the distribution appends its own
# packaging revision (5.0.0 against 5.0.0.93). An exact pin built
# from the former therefore names a release that was never
# published, and pip fails to find it; the prefix match selects
# the published wheel built from that library version.
hint = (
"first determine the shipped cv2 version with "
'`python -c "import cv2; print(cv2.__version__)"`, then replace '
"it with the binary wheel built from that same version -- the "
"distribution adds a packaging revision, so match on the prefix: "
f"`pip install --no-deps --force-reinstall --only-binary {package} "
f"'{package}==<cv2-version>.*'`"
)
else:
hint = (
"install the validated decoder with "
f"`pip install --no-deps --force-reinstall "
f"'{VALIDATED_SPECS[package]}'`"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
)
if installer_covers(backend, package):
hint += f" (or `{INSTALLER_CMD} {backend}`)"
return hint


def _with_cause(message: str, cause: str | None) -> str:
"""Append the underlying decoder text so diagnostics survive the wrap.

Expand Down Expand Up @@ -79,6 +174,16 @@ def video_decoder_missing(
"but NVDEC is unavailable in this container. Grant the 'video' "
"driver capability (NVIDIA_DRIVER_CAPABILITIES) to enable it, or "
)
elif _is_vllm_source_built_cv2(backend, package, module):
# Present but useless for video: the vLLM images ship an OpenCV built
# from source with no video backend. Saying it is "not installed"
# would send the reader looking for a package that is already there.
lead = (
f"this video ({codec_desc}) has no decoder in this image: shipped "
"images decode only H.264/H.265 (in hardware, via NVDEC), and the "
f"'{module}' they ship is built without a video backend. "
"Re-encode the input to H.264/H.265, or "
)
else:
lead = (
f"this video ({codec_desc}) has no decoder in this image: shipped "
Expand All @@ -88,7 +193,10 @@ def video_decoder_missing(
)
return MissingMediaDecoderError(
_with_cause(
"Cannot decode video: " + lead + _install_hint(backend, package) + ".",
"Cannot decode video: "
+ lead
+ _install_hint(backend, package, module)
+ ".",
cause,
)
)
Expand All @@ -106,7 +214,7 @@ def audio_decoder_missing(
_with_cause(
"Cannot decode audio: this input needs the PyAV decoder ('av'), which "
"this image deliberately does not ship, and NVDEC does not decode "
"audio. To enable audio input, " + _install_hint(backend, "av") + ".",
"audio. To enable audio input, " + _install_hint(backend, "av", "av") + ".",
cause,
)
)
76 changes: 69 additions & 7 deletions components/src/dynamo/common/multimodal/video_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@
# limitations under the License.

import asyncio
import functools
import logging
import os
import re
from typing import Any, Awaitable, Dict, Final, List
from urllib.parse import urlparse

Expand Down Expand Up @@ -45,6 +47,47 @@
logger = logging.getLogger(__name__)


def _attributable_to_cv2(exc: BaseException, media_io: Any | None = None) -> bool:
"""Whether this decode failure is OpenCV's.

vLLM picks among several video backends. A media_io configured for PyAV or
DeepStream raises its own ImportError, and reinstalling OpenCV would not
repair it, so only errors that name cv2 earn the OpenCV remedy. The
OpenCV backend's failed-open error is the exception: it is a bare
``ValueError``. Its selected backend identifies that otherwise anonymous
error without relabeling another backend's failure.
``ModuleNotFoundError`` carries the module in ``name``; the SystemError a
backendless build raises names the class in its text.
"""
if getattr(exc, "name", None) == "cv2" or "cv2" in str(exc):
return True
return (
isinstance(exc, ValueError)
and str(exc) == "Could not open video stream"
and (getattr(media_io, "kwargs", None) or {}).get("backend", "opencv")
== "opencv"
)


@functools.lru_cache(maxsize=1)
def _cv2_lacks_video_backend() -> bool:
"""Whether the installed OpenCV imports but can open no video.

Such a build fails through ``SystemError`` from ``VideoCapture`` rather
than the ``ImportError`` the decode path converts. An absent cv2 answers
False: the ImportError path names that case more precisely.
"""
try:
import cv2
except ImportError:
return False
build_info = cv2.getBuildInformation()
return not any(
re.search(rf"^\s*{backend}:\s*YES", build_info, re.MULTILINE | re.IGNORECASE)
for backend in ("FFMPEG", "GSTREAMER")
)


URL_VARIANT_KEY: Final = "Url"
DECODED_VARIANT_KEY: Final = "Decoded"

Expand Down Expand Up @@ -165,13 +208,15 @@ async def _decode_video_bytes(
) -> tuple[np.ndarray, Dict[str, Any]]:
"""Decode video bytes: H.264/H.265 on NVDEC, all else software.

The runtime images purge the software decode wheels (opencv/av/decord/
torchcodec) for codec compliance, so the software fallback only
resolves where a decoder was installed separately. When it is absent,
vLLM's lazy import surfaces a bare ``No module named 'cv2'`` with no
codec and no remedy -- convert that into the actionable
unsupported-codec error, which can name the codec because the probe
already ran here.
The runtime images carry no software video decoder: av, decord and
torchcodec are purged, and OpenCV is rebuilt without a video backend so
mistral_common can resize still images. The software fallback therefore
only resolves where a decoder was installed separately. Absent one, vLLM
surfaces either a bare ``No module named 'cv2'`` or a ``SystemError``
from ``VideoCapture``, neither carrying a codec or a remedy -- both
become the actionable unsupported-codec error, which can name the codec
because the probe already ran here. Both are translated on failure
rather than pre-empted, so a configured non-OpenCV backend still runs.

The NVDEC path honors the two ``media_io_kwargs`` that decide *which*
frames come back -- ``num_frames`` and ``fps`` -- because it samples
Expand All @@ -198,9 +243,26 @@ async def _decode_video_bytes(
try:
return await asyncio.to_thread(media_io.load_bytes, content)
except ImportError as exc:
if not _attributable_to_cv2(exc):
raise
raise video_decoder_missing(
"vllm", "opencv-python-headless", "cv2", codec, cause=str(exc)
) from exc
except (SystemError, ValueError) as exc:
Comment thread
glamr-agent marked this conversation as resolved.
Resolved
# A cv2 with no video backend fails inside VideoCapture either as
# a SystemError naming cv2 or as OpenCV's bare failed-open
# ValueError. Require the selected backend as well as the build to
# lack a backend: a configured non-OpenCV decoder must keep its
# own failure and remediation.
if not (_attributable_to_cv2(exc, media_io) and _cv2_lacks_video_backend()):
raise
raise video_decoder_missing(
"vllm",
"opencv-python-headless",
"cv2",
codec,
cause="the image's OpenCV is built without a video backend",
) from exc
Comment thread
glamr-agent marked this conversation as resolved.
Resolved
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

def _extract_nvdec_args(self, media_io: Any) -> dict[str, Any]:
num_frames = getattr(media_io, "num_frames", self._num_frames)
Expand Down
42 changes: 42 additions & 0 deletions components/src/dynamo/common/tests/multimodal/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,15 @@

import importlib

import pytest
import pytest_asyncio

from dynamo.common.http import close_http_client

# The decode carriers ``codec_errors`` probes. Only these names are answered by
# the ``carrier_imports`` fixture; every other import runs for real.
_MEDIA_CARRIERS = ("cv2", "av", "decord")

# Cached results of probing optional deps used by multimodal unit tests.
# `None` = not attempted, `True` = importable, `False` = raised.
_torch_importable: bool | None = None
Expand Down Expand Up @@ -70,6 +75,43 @@ def pytest_ignore_collect(collection_path, config) -> bool | None:
return None


@pytest.fixture
def carrier_imports(monkeypatch):
"""Pin what ``codec_errors`` sees when it probes a decode carrier.

The probe is a real import, so the expected message would otherwise depend
on whether the machine running the tests happens to have cv2 or av
installed. Only the carrier names are answered here; anything else the
interpreter imports during the test still imports for real, which a blanket
replacement of ``importlib.import_module`` would break.

Call it with the carriers that should import, and optionally the error a
carrier that does not import raises. A string produces ``ImportError``;
an exception instance models a native-loader failure such as ``OSError``.
"""
# Import after collection's optional-dependency guard: the multimodal
# package imports torch, which CPU-only runtime images may not ship.
from dynamo.common.multimodal import codec_errors

def _set(
present: tuple[str, ...] = (), error: str | BaseException | None = None
) -> None:
real = importlib.import_module

def _fake(name: str, *args, **kwargs):
if name in _MEDIA_CARRIERS:
if name in present:
return object()
if isinstance(error, BaseException):
raise error
raise ImportError(error or f"No module named '{name}'")
Comment thread
glamr-agent marked this conversation as resolved.
Resolved
return real(name, *args, **kwargs)

monkeypatch.setattr(codec_errors.importlib, "import_module", _fake)

return _set


@pytest_asyncio.fixture(autouse=True)
async def _close_shared_http_client():
yield
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
import numpy as np
import pytest

import dynamo.common.multimodal.audio_loader as audio_loader_module
from dynamo.common.http import HttpStatusError
from dynamo.common.http.url_validator import UrlValidationError, UrlValidationPolicy
from dynamo.common.multimodal import audio_loader as audio_loader_module
from dynamo.common.multimodal.audio_loader import AudioLoader
from dynamo.common.multimodal.codec_errors import MissingMediaDecoderError
from dynamo.common.utils.install_media_decoders import VALIDATED_SPECS
Expand Down Expand Up @@ -187,11 +187,14 @@ async def test_load_audio_batch_reads_decoded_variant(monkeypatch):


@pytest.mark.asyncio
async def test_load_audio_missing_decoder_is_actionable():
async def test_load_audio_missing_decoder_is_actionable(carrier_imports):
"""vLLM's own hint here is `pip install vllm[audio]`, which drags in an
unpinned stack; the wrap must point at the validated bounded install and
say there is no hardware alternative for audio."""
loader = AudioLoader()
# 'av' genuinely absent, independent of what the test machine has
# installed: _install_hint asks the running interpreter.
carrier_imports()
loader._load_audio_with_vllm = AsyncMock( # type: ignore[method-assign]
side_effect=ImportError("Please install vllm[audio] for audio support")
)
Expand Down
Loading
Loading