Files
vllm-omni/vllm_omni/entrypoints/utils.py
T
2026-09-05 15:32:30 +08:00

908 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import json
import os
import types
from collections import Counter
from dataclasses import fields, is_dataclass
from pathlib import Path
from typing import Any, get_args, get_origin
from vllm.logger import init_logger
from vllm.sampling_params import RequestOutputKind, SamplingParams
from vllm.transformers_utils.config import get_config, get_hf_file_to_dict
from vllm.transformers_utils.repo_utils import file_or_path_exists
from vllm_omni.config.config_factory import (
StageConfigFactory,
_materialize_object_storage_configs,
_name_match_candidate,
with_trust_remote_code_override,
)
from vllm_omni.config.pipeline_registry import OMNI_PIPELINES
from vllm_omni.config.stage_config import _DEPLOY_DIR
from vllm_omni.config.yaml_util import create_config, load_yaml_config
from vllm_omni.diffusion.utils.hf_utils import (
_looks_like_dreamzero,
get_diffusion_model_index,
resolve_native_diffusion_model_class,
)
from vllm_omni.entrypoints.stage_utils import _to_dict
from vllm_omni.inputs.data import OmniSamplingParams
from vllm_omni.platforms import current_omni_platform
# Get the project root directory (2 levels up from this file)
PROJECT_ROOT = Path(__file__).parent.parent.parent
logger = init_logger(__name__)
_DIFFUSERS_CLASS_TO_CONFIG: dict[str, str] = {
"GlmImagePipeline": "glm_image",
}
def inject_omni_kv_config(stage: Any, omni_conn_cfg: dict[str, Any], omni_from: str, omni_to: str) -> None:
"""Inject connector configuration into stage engine arguments."""
# Prepare omni_kv_config dict
omni_conf_dict = {}
try:
# Access engine_args safely (might be OmegaConf or dict)
existing_args = stage.engine_args
if hasattr(existing_args, "get"):
_oc = existing_args.get("omni_kv_config", None)
if _oc:
if hasattr(_oc, "items"): # dict-like
omni_conf_dict = dict(_oc)
else: # object?
omni_conf_dict = _to_dict(_oc)
except Exception:
omni_conf_dict = {}
# Inject connector info
omni_conf_dict["connector_config"] = omni_conn_cfg
omni_conf_dict["omni_from_stage"] = omni_from
omni_conf_dict["omni_to_stage"] = omni_to
# Write back to engine_args
try:
if hasattr(stage.engine_args, "__setitem__"):
stage.engine_args["omni_kv_config"] = omni_conf_dict
else:
setattr(stage.engine_args, "omni_kv_config", omni_conf_dict)
except Exception as e:
# Fallback for OmegaConf or similar if direct set fails?
logger.error(f"Failed to inject omni connector config into stage: {e}")
def _try_get_class_name_from_diffusers_config(model: str) -> str | None:
"""Try to get class name from diffusers model configuration files.
Args:
model: Model name or path
Returns:
Model type string if found, None otherwise
"""
model_index = get_diffusion_model_index(model)
if model_index and isinstance(model_index, dict) and "_class_name" in model_index:
logger.debug(f"Found Diffusers model type '{model_index['_class_name']}'")
return model_index["_class_name"]
return None
def _filter_dict_like_object(obj: dict | Any) -> dict:
"""Filter dict-like object by removing callables and recursively converting values.
Converts dict-like objects to regular dicts while filtering out callable values
that are incompatible with OmegaConf. Recursively processes values through
_convert_dataclasses_to_dict for nested object conversion.
Args:
obj: Dict or dict-like object to filter
Returns:
Regular dict with callables filtered out and values recursively converted
Raises:
TypeError: If obj doesn't support .items() method
ValueError: If dict conversion fails unexpectedly
"""
def _is_callable_value(value: Any) -> bool:
if callable(value):
return True
return isinstance(
value,
types.FunctionType | types.MethodType | types.BuiltinFunctionType | types.BuiltinMethodType,
)
result = {}
filtered_keys = []
for k, v in obj.items():
# Preserve class objects by converting to a fully qualified name string
# so callers that resolve via import path (e.g. custom_pipeline_args.pipeline_class)
# still work after OmegaConf round-trip.
if isinstance(v, type):
module = getattr(v, "__module__", None)
qualname = getattr(v, "__qualname__", getattr(v, "__name__", None))
if module and qualname and module != "builtins":
result[k] = f"{module}.{qualname}"
else:
result[k] = qualname
elif _is_callable_value(v):
filtered_keys.append(str(k))
else:
result[k] = _convert_dataclasses_to_dict(v)
if filtered_keys:
logger.warning(
f"Filtered out {len(filtered_keys)} callable object(s) from base_engine_args "
f"that are not compatible with OmegaConf: {filtered_keys}. "
)
return result
def _convert_dataclasses_to_dict(obj: Any) -> Any:
"""Recursively convert non-serializable objects to OmegaConf-compatible types.
This is needed because OmegaConf cannot handle:
- Dataclass objects with Literal type annotations (e.g., StructuredOutputsConfig)
- Counter objects (from collections or vllm.utils)
- Set objects
- Callable objects (functions, methods, etc.)
- Other non-primitive types
"""
# IMPORTANT: Check Counter BEFORE dict, since Counter is a subclass of dict
# Handle Counter objects (convert to dict)
# Check by class name first to catch both collections.Counter and vllm.utils.Counter
if hasattr(obj, "__class__") and obj.__class__.__name__ == "Counter":
try:
return dict(obj)
except (TypeError, ValueError):
# If Counter can't be converted to dict, return empty dict
return {}
# Also check isinstance for collections.Counter (must be before dict check)
if isinstance(obj, Counter):
return dict(obj)
# Handle set objects (convert to list)
if isinstance(obj, set):
return list(obj)
# Handle dataclass objects
# Use field iteration instead of asdict() to:
# 1. Only include init fields (non-init fields cause "unexpected kwarg" errors)
# 2. Skip None values matching field defaults (avoids Pydantic validation
# failures when None is explicitly passed for non-Optional typed fields,
# e.g. CompilationConfig.cudagraph_capture_sizes: list[int] = None)
if is_dataclass(obj) and not isinstance(obj, type):
result = {}
for f in fields(obj):
if not f.init:
continue
value = getattr(obj, f.name)
if value is None and f.default is None:
continue
result[f.name] = _convert_dataclasses_to_dict(value)
return result
# Handle dictionaries (recurse into values) and filter out callables(cause error in OmegaConf.create)
# Note: This must come AFTER Counter check since Counter is a dict subclass
if isinstance(obj, dict):
return _filter_dict_like_object(obj)
# Preserve class objects by converting to a fully qualified name string.
if isinstance(obj, type):
module = getattr(obj, "__module__", None)
qualname = getattr(obj, "__qualname__", getattr(obj, "__name__", None))
if module and qualname and module != "builtins":
return f"{module}.{qualname}"
return qualname
# Handle callable objects (functions, methods, etc.) - skip them
# Note: This comes after dict/list checks to avoid misclassifying dict-like objects
if callable(obj):
return None
# Handle lists and tuples (recurse into items)
if isinstance(obj, list | tuple):
return type(obj)(_convert_dataclasses_to_dict(item) for item in obj if not callable(item))
# Try to convert any dict-like object (has keys/values methods) to dict
if hasattr(obj, "keys") and hasattr(obj, "values") and not isinstance(obj, str | bytes):
try:
return _filter_dict_like_object(obj)
except (TypeError, ValueError, AttributeError):
# If conversion fails, return as-is
return obj
# Primitive types and other objects that OmegaConf can handle
return obj
def _try_resolve_omni_model_type(model: str) -> str | None:
"""Try to resolve model_type for omni models with empty config.json.
Searches both the legacy ``stage_configs/*.yaml`` directory and the
migrated ``deploy/*.yaml`` directory for a stem that substring-matches
the model path basename (e.g. ``cosyvoice3`` in
``FunAudioLLM/Fun-CosyVoice3-0.5B-2512``). Only the basename is scanned
so URI segments such as the bucket name cannot select an unrelated
pipeline. The longest match wins so ``cosyvoice3`` beats ``cosyvoice``
and ``bagel_single_stage`` beats ``bagel``.
"""
model_lower = _name_match_candidate(model).lower().replace("-", "").replace("_", "")
best_match: str | None = None
best_len = 0
for subdir in ("model_executor/stage_configs", "deploy"):
config_dir = PROJECT_ROOT / "vllm_omni" / subdir
if not config_dir.exists():
continue
for config_file in sorted(config_dir.glob("*.yaml")):
candidate = config_file.stem.replace("-", "").replace("_", "")
if candidate and candidate in model_lower and len(candidate) > best_len:
best_match = config_file.stem
best_len = len(candidate)
return best_match
def _registry_default_deploy_path(model: str) -> str | None:
"""Return the registered pipeline's default deploy YAML path, if any.
A checkpoint's HF ``model_type`` is not always the registered Omni pipeline
key. MiniCPM-o 4.5, for example, reports ``minicpmo`` while its
predicate-selected pipeline is ``minicpmo_4_5``. Stage construction already
loads that pipeline's default deploy config, so connector discovery must
resolve the same path.
"""
pipeline_config = StageConfigFactory.get_pipeline_config(
model=model,
trust_remote_code=True,
)
if pipeline_config is None or pipeline_config.default_deploy_config_name is None:
return None
default_deploy_path = _DEPLOY_DIR / pipeline_config.default_deploy_config_name
if default_deploy_path.is_file():
return str(default_deploy_path)
return None
def resolve_model_config_path(model: str) -> str | None:
"""Resolve the stage/deploy config file path from the model name.
Resolves configuration path based on the model type and device type.
Order:
1. Device-specific stage config under ``stage_configs/{device_type}/``
2. If HF ``model_type`` is not an ``OMNI_PIPELINES`` key, the registered
pipeline's ``default_deploy_config_name`` (keeps connectors aligned with
stage construction when HF type and pipeline key differ)
3. ``deploy/{model_type}.yaml``
4. Legacy ``stage_configs/{model_type}.yaml``
5. Registered pipeline default deploy config (final fallthrough)
Args:
model: Model name or path (used to determine model_type)
Returns:
String path to the stage/deploy configuration file, or ``None`` if no
matching config file exists.
Raises:
ValueError: If model_type cannot be determined
"""
# Object-storage URIs are streamed by vLLM's Run:AI streamer only after
# each stage builds its ModelConfig, so config resolution here reads the
# local copy that vLLM-Omni materializes for such URIs. Name-based
# fallbacks keep the original string (the materialized path is a hash).
config_source = _materialize_object_storage_configs(model)
# Try to get config from standard transformers format first
try:
hf_config = get_config(config_source, trust_remote_code=True)
model_type = hf_config.model_type
except (ValueError, Exception):
# If standard transformers format fails, try diffusers format
native_model_class = resolve_native_diffusion_model_class(config_source)
if native_model_class is not None:
model_type = native_model_class
elif get_diffusion_model_index(config_source) is not None:
model_type = _try_get_class_name_from_diffusers_config(config_source)
if model_type is None:
raise ValueError(
f"Could not determine model_type for diffusers model: {model}. "
"Please ensure its Diffusers pipeline index contains '_class_name'"
)
elif file_or_path_exists(config_source, "config.json", revision=None):
# Try to read config.json manually for custom models like Bagel that fail get_config
# but have a valid config.json with model_type
try:
config_dict = get_hf_file_to_dict("config.json", config_source, revision=None)
if config_dict and "model_type" in config_dict:
model_type = config_dict["model_type"]
else:
# For models with empty config.json (e.g. CosyVoice3),
# try matching against registered omni stage configs.
model_type = _try_resolve_omni_model_type(model)
if model_type is None:
raise ValueError(f"config.json found but missing 'model_type' for model: {model}")
except Exception as e:
raise ValueError(f"Failed to read config.json for model: {model}. Error: {e}") from e
else:
# No config.json at repo root (e.g. GLM-TTS stores configs in
# subdirectories only). Try matching against registered deploy
# YAML filenames before giving up.
model_type = _try_resolve_omni_model_type(model)
if model_type is None:
raise ValueError(
f"Could not determine model_type for model: {model}. "
"Model is not in standard transformers or Diffusers format. "
f"Please ensure the model has proper configuration files with 'model_type' field"
)
default_config_path = current_omni_platform.get_default_stage_config_path()
if model_type == "vla" and _looks_like_dreamzero(config_source):
model_type = "dreamzero"
if model_type in _DIFFUSERS_CLASS_TO_CONFIG:
normalized_model_type = _DIFFUSERS_CLASS_TO_CONFIG[model_type]
else:
normalized_model_type = model_type.replace("-", "_")
model_type_str = f"{normalized_model_type}.yaml"
complete_config_path = PROJECT_ROOT / default_config_path / model_type_str
if os.path.exists(complete_config_path):
return str(complete_config_path)
# Prefer the registry default before deploy/<hf_model_type>.yaml when the
# HF type is not itself a pipeline key. Otherwise a future
# deploy/minicpmo.yaml would desync connectors from stages for MiniCPM-o 4.5.
if normalized_model_type not in OMNI_PIPELINES:
registry_path = _registry_default_deploy_path(model)
if registry_path is not None:
return registry_path
deploy_config_path = _DEPLOY_DIR / model_type_str
if os.path.exists(deploy_config_path):
return str(deploy_config_path)
stage_config_file = f"vllm_omni/model_executor/stage_configs/{normalized_model_type}.yaml"
stage_config_path = PROJECT_ROOT / stage_config_file
if os.path.exists(stage_config_path):
return str(stage_config_path)
return _registry_default_deploy_path(model)
def load_stage_configs_from_model(
model: str,
*,
trust_remote_code: bool | None,
base_engine_args: dict | None = None,
deploy_config_path: str | None = None,
stage_overrides: dict[str, dict[str, Any]] | None = None,
strategy_config_path: str | None = None,
) -> tuple[list, str | None]:
"""Load stage configurations from model's default config file.
For models registered in the pipeline registry, uses
``StageConfigFactory.create_legacy_stage_configs_from_model()`` which merges
PipelineConfig + DeployConfig + CLI overrides.
Models that cannot be resolved through the registry return no stages so the
caller can apply its default stage configuration, when available.
Args:
model: Model name or path (used to determine model_type)
trust_remote_code: Whether to trust remote code while resolving the model config.
base_engine_args: Base engine args to merge as CLI overrides.
deploy_config_path: Optional explicit deploy config path.
stage_overrides: Per-stage overrides from --stage-overrides.
strategy_config_path: Optional path to a composable-parallel
``strategy.yaml`` whose derived sizing is overlaid onto the
registry-merged stages.
Returns:
``(stage_configs, omni_lb_policy)``: the list of stage configuration
dictionaries plus the strategy-derived pipeline-wide ``omni_lb_policy``
(``None`` when no strategy set one). The policy is returned rather than
written into a caller-provided mutable dict.
"""
if base_engine_args is None:
base_engine_args = {}
cli_overrides = _convert_dataclasses_to_dict(dict(base_engine_args))
# A False inherited from the engine-args dump is the store_true flag's
# default, not an explicit choice — drop it so only the tri-state
# parameter below decides (see with_trust_remote_code_override).
if not cli_overrides.get("trust_remote_code"):
cli_overrides.pop("trust_remote_code", None)
cli_overrides = with_trust_remote_code_override(cli_overrides, trust_remote_code)
if stage_overrides:
for stage_id_str, overrides in stage_overrides.items():
for key, val in overrides.items():
cli_overrides[f"stage_{stage_id_str}_{key}"] = val
# Current runtime initialization still consumes legacy OmegaConf stage
# configs. ``StageConfigFactory.create_from_model`` now produces the
# structured ``VllmOmniConfig`` object; future RFC #4021 changes will
# migrate the engine/runtime consumers and replace this legacy resolver.
strategy_specs = None
if strategy_config_path is not None:
from vllm_omni.config.composable_parallel.strategy_loader import load_strategy_specs
strategy_specs = load_strategy_specs(strategy_config_path)
stages, omni_lb_policy = StageConfigFactory.create_legacy_stage_configs_from_model(
model,
trust_remote_code=trust_remote_code,
cli_overrides=cli_overrides,
deploy_config_path=deploy_config_path,
strategy_specs=strategy_specs,
)
if stages is not None:
# Convert StageConfig objects to OmegaConf for backward compat
return [stage.to_omegaconf() for stage in stages], omni_lb_policy
strategy_note = ""
if strategy_config_path is not None:
strategy_note = f" Strategy config {strategy_config_path!r} was not applied."
logger.warning(
"No registered PipelineConfig resolved for model %r. Legacy `stage_args` "
"YAMLs are no longer supported; register the pipeline and provide deployment "
"overrides through `deploy_config`.%s",
model,
strategy_note,
)
return [], None
def filter_stages(
config_path: str | None,
stage_configs: list,
kwargs: dict | None,
) -> list:
"""Filter stage configs by mode when YAML defines a `modes` section.
The YAML can define, e.g.:
modes:
- mode: text-to-image
stages: [1]
- mode: image-to-text
stages: [0]
When users pass `mode="image-to-text"` into Omni(**kwargs), only the stages
listed for that mode are returned. If no mode is provided, defaults to
"text-to-image". If no modes are defined or filtering fails, returns the
original stage_configs unchanged.
Args:
config_path: Path to the YAML config (used to read `modes`).
stage_configs: Loaded list of stage configs.
kwargs: Engine/caller kwargs; may contain "mode".
Returns:
Filtered list of stage configs (or original list if filtering not applied).
"""
if not stage_configs or config_path is None:
return stage_configs
try:
cfg = load_yaml_config(config_path)
yaml_modes = getattr(cfg, "modes", None)
if yaml_modes is None:
return stage_configs
mode_to_stage_ids: dict[str, list[int]] = {}
if yaml_modes is not None:
for entry in yaml_modes:
mode_name = None
stages = None
if hasattr(entry, "mode") or hasattr(entry, "stages"):
mode_name = getattr(entry, "mode", None)
stages = getattr(entry, "stages", None)
elif isinstance(entry, dict):
mode_name = entry.get("mode")
stages = entry.get("stages")
if mode_name is None or stages is None:
continue
if isinstance(stages, int):
stage_list = [stages]
else:
stage_list = list(stages)
mode_to_stage_ids[str(mode_name)] = [int(sid) for sid in stage_list]
# No modes section or empty mapping: use all stages and return early.
active_mode: str | None = None
if isinstance(kwargs, dict):
active_mode = kwargs.get("mode")
if active_mode is None:
active_mode = "text-to-image"
if active_mode not in mode_to_stage_ids:
logger.warning(
"Requested mode '%s' not found in config '%s'; available modes: %s. Using all stages.",
active_mode,
config_path,
sorted(mode_to_stage_ids.keys()),
)
return stage_configs
allowed_ids = set(mode_to_stage_ids[active_mode])
filtered_stage_configs = [sc for sc in stage_configs if getattr(sc, "stage_id", None) in allowed_ids]
if not filtered_stage_configs:
logger.warning(
"Mode '%s' in config '%s' resolved to stage ids %s, but none matched loaded stage_args. "
"Falling back to all stages.",
active_mode,
config_path,
sorted(allowed_ids),
)
return stage_configs
return filtered_stage_configs
except Exception as e:
logger.warning("Failed to apply mode-based stage filtering: %s", e)
return stage_configs
def parse_stage_overrides(value: Any) -> dict[str, dict[str, Any]] | None:
"""Parse the ``--stage-overrides`` value into a per-stage override dict.
``value`` may be a raw JSON string (as supplied on the CLI) or an
already-parsed mapping. Returns ``None`` when no overrides are given.
Only the **shape** of the override mapping is validated here:
- top-level must be a dict
- keys must be non-negative ASCII integer strings (stage ids)
- values must be dicts
Field-name / type / range validation is intentionally not done at this
layer. The downstream resolver forwards every surviving key as
``stage_<id>_<key>`` into ``cli_overrides`` (see
``load_stage_configs_from_model``); field semantics live in
``StageConfigFactory`` for registered pipelines, and the
default-diffusion fallback in ``async_omni_engine.py`` reads
``stage_zero_overrides["extras"]`` for unregistered ones. Unknown
engine-arg keys that don't match ``OmniEngineArgs`` are dropped with
a warning at ``filter_dataclass_kwargs`` rather than failing here,
so a typo surfaces in the log instead of being silently lost.
Raises:
ValueError: when ``value`` is not a valid per-stage override
mapping (invalid JSON, non-dict top level, non-ASCII-digit
stage id, non-dict value).
"""
if not value:
return None
if isinstance(value, str):
try:
parsed = json.loads(value)
except json.JSONDecodeError as exc:
raise ValueError(f"--stage-overrides is not valid JSON: {exc}. Got: {value!r}") from exc
else:
parsed = value
if not isinstance(parsed, dict):
raise ValueError(
f"--stage-overrides must be a JSON object mapping stage_id -> overrides, "
f"got {type(parsed).__name__}: {parsed!r}"
)
if not parsed:
return None
for stage_id_str, overrides in parsed.items():
# ``str.isdigit()`` accepts Unicode digit classes (e.g. fullwidth
# ``"0"``); downstream's ``_STAGE_OVERRIDE_PATTERN`` is ASCII-only
# (``^stage_(\d+)_(.+)$``), so a non-ASCII-digit key would parse
# here and silently misroute as a global key. ``isascii()`` closes
# that hole.
if not isinstance(stage_id_str, str) or not stage_id_str.isdigit() or not stage_id_str.isascii():
raise ValueError(
f"--stage-overrides keys must be non-negative integer stage ids (as strings), got {stage_id_str!r}"
)
if not isinstance(overrides, dict):
raise ValueError(
f"--stage-overrides[{stage_id_str!r}] must be an object, got {type(overrides).__name__}: {overrides!r}"
)
return parsed
def load_and_resolve_stage_configs(
model: str,
kwargs: dict | None,
*,
trust_remote_code: bool | None,
default_stage_cfg_factory: Any = None,
deploy_config_path: str | None = None,
stage_overrides: dict[str, dict[str, Any]] | None = None,
strategy_config_path: str | None = None,
) -> tuple[str, list, str | None]:
"""Load stage configurations from a deploy YAML or model defaults.
Args:
model: Model name or path
kwargs: Engine arguments to merge with stage configs
trust_remote_code: Whether to trust remote code while resolving the model config.
default_stage_cfg_factory: Optional callable that takes no args and returns
default stage config list when no configs are found
deploy_config_path: Optional path to a deploy YAML.
stage_overrides: Per-stage overrides from ``--stage-overrides`` JSON.
Keys are stage_id strings, values are dicts of overrides.
strategy_config_path: Optional path to a composable-parallel
``strategy.yaml`` overlaid onto the registry-merged stages.
Returns:
Tuple of ``(config_path, stage_configs, omni_lb_policy)`` — the last is
the strategy-derived pipeline-wide load-balancer policy (``None`` when no
strategy set one), returned for the engine to apply.
"""
config_path = deploy_config_path if deploy_config_path is not None else resolve_model_config_path(model)
stage_configs, omni_lb_policy = load_stage_configs_from_model(
model,
trust_remote_code=trust_remote_code,
base_engine_args=kwargs,
deploy_config_path=deploy_config_path,
stage_overrides=stage_overrides,
strategy_config_path=strategy_config_path,
)
if not stage_configs:
if default_stage_cfg_factory is not None:
default_stage_cfg = default_stage_cfg_factory()
stage_configs = create_config(_convert_dataclasses_to_dict(default_stage_cfg))
else:
stage_configs = []
stage_configs = filter_stages(config_path, stage_configs, kwargs)
# ``revision`` selects the model source for every stage. Keep the global
# CLI pin on each stage's engine args so diffusion config construction can
# pass it through to all component loaders. The registry-to-legacy-stage
# conversion currently drops this inherited vLLM EngineArgs field.
revision = (kwargs or {}).get("revision")
if revision is not None:
for stage_config in stage_configs:
engine_args = stage_config.get("engine_args") if hasattr(stage_config, "get") else None
if engine_args is not None and engine_args.get("revision") is None:
engine_args["revision"] = revision
logger.debug(f"stage_configs: {stage_configs}")
return config_path, stage_configs, omni_lb_policy
def get_final_stage_id_for_e2e(
output_modalities: list[str] | None, default_modalities: list[str], stage_list: list
) -> int:
"""Get the final stage id for e2e.
Args:
stage_list: List of stage configurations
Returns:
Final stage id for e2e
"""
last_stage_id = len(stage_list) - 1
if output_modalities is not None:
prompt_modalities = []
for modality in output_modalities:
if modality not in default_modalities:
logger.warning(f"Invalid output modality: {modality}, ignoring it")
# TODO: if user specifies unsupported modalities, invalid it and raise an error
continue
prompt_modalities.append(modality)
output_modalities = prompt_modalities
else:
output_modalities = default_modalities
try:
final_stage_id_for_e2e = last_stage_id
for _sid in range(last_stage_id, -1, -1):
if (
getattr(stage_list[_sid], "final_output", False)
and stage_list[_sid].final_output_type in output_modalities
):
final_stage_id_for_e2e = _sid
break
except Exception as e:
logger.debug(
"[Orchestrator] Failed to determine final stage for E2E; \
falling back to last: %s",
e,
exc_info=True,
)
final_stage_id_for_e2e = last_stage_id
return final_stage_id_for_e2e
def filter_dataclass_kwargs(cls: Any, kwargs: dict) -> dict:
"""Filter kwargs to only include fields defined in the dataclass.
Args:
cls: Dataclass type
kwargs: Keyword arguments to filter
Returns:
Filtered keyword arguments containing only valid dataclass fields
"""
if not is_dataclass(cls):
raise ValueError(f"{cls} is not a dataclass")
if not isinstance(kwargs, dict):
raise ValueError("kwargs must be a dictionary")
def _filter_value(value: Any, annotation: Any) -> Any:
"""Recursively filter nested dict/list values based on dataclass annotations."""
if annotation is None:
return value
origin = get_origin(annotation)
if origin is None:
if isinstance(annotation, type) and is_dataclass(annotation) and isinstance(value, dict):
return filter_dataclass_kwargs(annotation, value)
return value
if origin in (list, tuple, set):
args = get_args(annotation)
inner = args[0] if args else None
if isinstance(value, list | tuple | set):
return type(value)(_filter_value(v, inner) for v in value)
return value
if origin is dict:
args = get_args(annotation)
val_type = args[1] if len(args) > 1 else None
if isinstance(value, dict):
return {k: _filter_value(v, val_type) for k, v in value.items()}
return value
if origin is types.UnionType or origin is getattr(types, "UnionType", None):
for arg in get_args(annotation):
if isinstance(arg, type) and is_dataclass(arg) and isinstance(value, dict):
return filter_dataclass_kwargs(arg, value)
# Try container-style filtering for union members
filtered = _filter_value(value, arg)
if filtered is not value:
return filtered
return value
return value
valid_fields = {f.name: f for f in fields(cls) if f.init}
filtered_kwargs = {}
for k, v in kwargs.items():
if k not in valid_fields:
logger.warning(
"Dropping unknown %s field %r (not declared on the dataclass)",
cls.__name__,
k,
)
continue
field = valid_fields[k]
filtered_kwargs[k] = _filter_value(v, field.type)
return filtered_kwargs
# The following code detects if the process is running in a container and if
# PID host is available. If so, we can use process-scoped memory tracking;
# otherwise we need sequential init locks.
def _read_text(path: str) -> str | None:
try:
with open(path, encoding="utf-8", errors="replace") as f:
return f.read()
except (FileNotFoundError, PermissionError, OSError):
return None
def in_container() -> bool:
# Common Docker signal
if os.path.exists("/.dockerenv"):
return True
# cgroup markers (works for Docker/containerd/K8s/Podman in many setups)
cg = _read_text("/proc/1/cgroup") or ""
markers = ("docker", "containerd", "kubepods", "libpod", "podman")
return any(m in cg for m in markers)
def has_pid_host() -> bool | None:
"""
Returns:
True -> very likely running with --pid=host (host PID namespace)
False -> very likely isolated PID namespace (default)
None -> cannot determine
"""
# Strong signal: in host pid namespace, PID 2 is usually kthreadd
comm2 = _read_text("/proc/2/comm")
if comm2 is not None:
comm2 = comm2.strip()
if comm2 == "kthreadd":
return True
# If PID 2 exists and is NOT kthreadd, we're almost certainly not in host pid ns
return False
# Fallback: check for other low-numbered kernel threads (best-effort)
for pid, name in [(3, "rcu_gp"), (4, "rcu_par_gp"), (10, "ksoftirqd/0")]:
comm = _read_text(f"/proc/{pid}/comm")
if comm is not None:
if comm.strip() == name:
return True
else:
return False
return False
def detect_pid_host() -> bool:
ic = in_container()
if not ic:
return True
return has_pid_host()
### Helpers for handling delta messages
def coerce_param_message_types(params: list[OmniSamplingParams], is_streaming: bool):
"""Iterate over the sampling params and convert to the message types
to DELTA messages, if streaming is enabled, or FINAL_ONLY if
it's disabled, while respecting `.skip_clone` on the params.
This is needed to avoid emitting redundant multimodal data.
"""
# Coerce vLLM's default output kinds as needed to handle streaming
# (i.e., DELTA output kind). Note that this is only applied to non
# Diffusion sampling params.
#
# NOTE: Hidden states will still be passed between stages.
for idx, sp in enumerate(params):
# For OmniDiffusionParams don't set output kind
if isinstance(sp, SamplingParams):
params[idx] = maybe_coerce_to_message_type(sp, is_streaming)
return params
def maybe_coerce_to_message_type(params: SamplingParams, is_streaming: bool):
"""If this is a CUMULATIVE message, coerce it to DELTA if streaming, otherwise FINAL_ONLY."""
target_type = RequestOutputKind.DELTA if is_streaming else RequestOutputKind.FINAL_ONLY
if params.output_kind == target_type:
return params
elif is_streaming and params.output_kind == RequestOutputKind.FINAL_ONLY:
logger.warning("Request appears to be streaming, but got request type final only!")
elif not is_streaming and params.output_kind == RequestOutputKind.DELTA:
logger.warning("Request appears to not be streaming, but got request type delta!")
if not params.skip_clone:
params = params.clone()
params.skip_clone = True
params.output_kind = target_type
return params
class PureDiffusionLauncherAdapter:
"""vLLM launcher compatibility shim for pure-diffusion mode.
The upstream launcher's shutdown path reads
``app.state.engine_client.vllm_config.shutdown_timeout``
(vllm/entrypoints/launcher.py), but ``AsyncOmni.vllm_config`` returns
``None`` when the pipeline has no comprehension stage (pure diffusion),
which crashes ``handle_shutdown`` with AttributeError and hangs server
teardown (workers force-killed, spurious resource_tracker noise).
This adapter only overrides the ``vllm_config`` property with a minimal
fallback carrying ``shutdown_timeout`` and forwards every other attribute
to the wrapped engine client, so the pure-diffusion detection
(``get_vllm_config()`` still returns ``None``) is unaffected.
"""
def __init__(self, engine_client: Any, shutdown_timeout: float) -> None:
object.__setattr__(self, "_wrapped", engine_client)
object.__setattr__(self, "_shutdown_timeout", float(shutdown_timeout))
def __getattr__(self, name: str) -> Any:
return getattr(self._wrapped, name)
@property
def vllm_config(self) -> Any:
return types.SimpleNamespace(shutdown_timeout=self._shutdown_timeout)