Files
vllm-omni/tests/engine/test_arg_utils.py
T
2026-08-11 23:00:35 +00:00

555 lines
21 KiB
Python

"""
Tests for Omni config utils. For stability, these tests should largely be
invariant to the specific attributes of vLLM config except in cases where we
explicitly patch values that differ from vLLM.
"""
import argparse
import inspect
import json
import os
import shutil
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from omegaconf import OmegaConf
from pydantic import ValidationError
from transformers import PretrainedConfig, Qwen3OmniMoeConfig
from vllm.engine.arg_utils import EngineArgs
from vllm_omni.config.model import OmniModelConfig
from vllm_omni.engine.arg_utils import OmniEngineArgs
from vllm_omni.engine.async_omni_engine import AsyncOmniEngine
from vllm_omni.engine.stage_init_utils import build_engine_args_dict
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_sync_config_is_omni():
"""Ensure create_model_config gives the right type."""
cfg = OmniEngineArgs().create_model_config()
assert isinstance(cfg, OmniModelConfig)
def test_default_stage_id_is_concrete_int():
"""Ensure `stage_id` stays safe for downstream arithmetic/indexing."""
engine_args = OmniEngineArgs()
assert engine_args.stage_id == 0
assert isinstance(engine_args.stage_id, int)
assert engine_args.log_stats is False
cfg = engine_args.create_model_config()
assert cfg.stage_id == 0
def test_multimodal_kwarg_overrides(mocker):
"""Ensure that overrides in the multimodal config are preserved."""
sig = inspect.signature(OmniEngineArgs)
default_mm_cache = sig.parameters["mm_processor_cache_gb"].default
override_val = default_mm_cache + 1
fake_model_config = SimpleNamespace(
multimodal_config=SimpleNamespace(mm_processor_cache_gb=override_val),
)
def _fake_parent_create_model_config(self):
assert self.mm_processor_cache_gb == override_val
return fake_model_config
mocker.patch.object(EngineArgs, "create_model_config", _fake_parent_create_model_config)
mocker.patch.object(OmniModelConfig, "from_vllm_model_config", side_effect=lambda model_config, **_: model_config)
cfg = OmniEngineArgs(
model="Qwen/Qwen2-VL-2B-Instruct",
mm_processor_cache_gb=override_val,
).create_model_config()
assert cfg.multimodal_config is not None
assert cfg.multimodal_config.mm_processor_cache_gb == override_val
def test_from_vllm_config_validates_invalid_omni_kwargs():
"""Ensure omni-specific field validation catches invalid keys."""
model_config = EngineArgs().create_model_config()
with pytest.raises(ValueError, match="Unexpected omni kwarg"):
OmniModelConfig.from_vllm_model_config(model_config, foo="bar")
def test_from_vllm_config_validates_bad_omni_kwarg_types():
"""Ensure omni-specific field validation catches type errors."""
model_config = EngineArgs().create_model_config()
with pytest.raises(ValidationError):
OmniModelConfig.from_vllm_model_config(model_config, stage_id="not_an_int")
def test_default_all_values_are_initialized():
"""Ensure omni-specific field initializes all fields"""
model_config = EngineArgs().create_model_config()
cfg = OmniModelConfig.from_vllm_model_config(model_config)
# Test a primitive
assert cfg.model_stage == "thinker"
# Test a field initialized with a default factory
assert cfg.stage_connector_config == {
"name": "SharedMemoryConnector",
"extra": {},
}
# Ensure that hf_config is initialized on model_config in the vLLM by ModelConfig's
# __post_init__, and that the hf_config is copied over to the OmniModelConfig;
# we explicitly set this since the field sets init=False
assert isinstance(model_config.hf_config, PretrainedConfig)
assert cfg.hf_config is model_config.hf_config
# Ensure that we can convert it to a string; this will convert
# all attributes, so should raise if we have attributes that are
# not initialized correctly, e.g., due to default factories
str(cfg)
def test_qwen3_tts_codec_frame_rate_patching():
"""Ensure the patch for qwen3 tts is applied correctly when creating the omni config."""
# Create a vLLM ModelConfig
vllm_config = EngineArgs().create_model_config()
# Create a mock talking config with a dummy value for position_id_per_seconds
mock_talker_config = SimpleNamespace()
mock_talker_config.position_id_per_seconds = 12.3
vllm_config.hf_config.talker_config = mock_talker_config
# Ensure creating the config for a Qwen3TTSTalkerForConditionalGenerationARVLLM
# model calls the patch func to apply position_id_per_seconds from the talker
# config to the config's codec_frame_rate_hz
omni_config = OmniModelConfig.from_vllm_model_config(
vllm_config,
model_arch="Qwen3TTSTalkerForConditionalGenerationARVLLM",
)
# Verify codec_frame_rate_hz was patched
assert omni_config.codec_frame_rate_hz == 12.3
def test_qwen3_tts_startup_task_type_is_validated():
vllm_config = EngineArgs().create_model_config()
config = OmniModelConfig.from_vllm_model_config(
vllm_config,
model_arch="Qwen3TTSTalkerForConditionalGenerationARVLLM",
task_type="Base",
)
assert config.task_type == "Base"
with pytest.raises(ValueError, match="Qwen3-TTS --task-type must be one of"):
OmniModelConfig.from_vllm_model_config(
vllm_config,
model_arch="Qwen3TTSTalkerForConditionalGenerationARVLLM",
task_type="fl2va",
)
def test_from_cli_args_picks_up_stage_configs_path():
"""from_cli_args should pick up stage_configs_path from namespace."""
ns = argparse.Namespace(
model="facebook/opt-125m",
stage_configs_path="/some/path.yaml",
custom_pipeline_args=None,
)
args = OmniEngineArgs.from_cli_args(ns)
assert args.stage_configs_path == "/some/path.yaml"
assert args.custom_pipeline_args is None
def test_qwen3_tts_code2wav_injects_max_position_embeddings(monkeypatch):
"""Ensure Code2Wav mirrors stage max_model_len into nested HF overrides.
Qwen3-TTS Code2Wav is a pure decoder stage whose runtime max_model_len can
legitimately exceed the base checkpoint's default text max length. Recent
vLLM validates these values during ModelConfig creation, so we inject
``talker_config.max_position_embeddings`` before delegating to vLLM.
"""
captured: dict[str, object] = {}
baseline_config = Mock()
def fake_create_model_config(self):
captured["hf_overrides"] = self.hf_overrides
return baseline_config
monkeypatch.setattr(EngineArgs, "create_model_config", fake_create_model_config)
monkeypatch.setattr(
OmniModelConfig,
"from_vllm_model_config",
classmethod(lambda cls, model_config, **omni_kwargs: model_config),
)
OmniEngineArgs(
model_arch="Qwen3TTSCode2Wav",
max_model_len=65536,
).create_model_config()
assert captured["hf_overrides"] == {
"architectures": ["Qwen3TTSCode2Wav"],
"talker_config": {
"max_position_embeddings": 65536,
},
}
def test_patch_missing_local_hf_config(tmp_path):
"""Native model bundles may only contain a checkpoints/ directory."""
(tmp_path / "checkpoints").mkdir()
args = object.__new__(OmniEngineArgs)
args.model = str(tmp_path)
args.model_arch = "IndexTTS25TalkerForConditionalGeneration"
args.hf_config_path = None
try:
args._patch_empty_hf_config("indextts2_5")
assert args.hf_config_path is not None
config_path = args.hf_config_path + "/config.json"
with open(config_path, encoding="utf-8") as config_file:
config = json.load(config_file)
assert config == {
"model_type": "indextts2_5",
"architectures": ["IndexTTS25TalkerForConditionalGeneration"],
}
finally:
if hasattr(args, "_temp_config_dir"):
shutil.rmtree(args._temp_config_dir, ignore_errors=True)
@pytest.mark.parametrize(
"config_entry",
["malformed", "directory", "broken_symlink", "permission_error"],
)
def test_non_missing_local_hf_config_error_reaches_parent_loader(tmp_path, monkeypatch, config_entry):
"""Non-missing config errors must reach vLLM's normal loader."""
config_path = tmp_path / "config.json"
if config_entry == "malformed":
config_path.write_text("{not valid json", encoding="utf-8")
loader_error = json.JSONDecodeError("invalid config", "{not valid json", 1)
elif config_entry == "directory":
config_path.mkdir()
loader_error = IsADirectoryError(str(config_path))
elif config_entry == "broken_symlink":
config_path.symlink_to(tmp_path / "missing-target.json")
loader_error = FileNotFoundError(str(config_path))
else:
config_path.write_text("{}", encoding="utf-8")
loader_error = PermissionError(str(config_path))
parent_inputs = {}
def controlled_get_config_dict(cls, model, **kwargs):
if model == str(tmp_path):
raise loader_error
with open(os.path.join(model, "config.json"), encoding="utf-8") as config_file:
return json.load(config_file), {}
def fake_parent_create_model_config(self):
parent_inputs["called"] = True
parent_inputs["model"] = self.model
parent_inputs["hf_config_path"] = self.hf_config_path
config_root = self.hf_config_path or self.model
parent_inputs["config_root"] = config_root
return PretrainedConfig.get_config_dict(config_root)[0]
monkeypatch.setattr(PretrainedConfig, "get_config_dict", classmethod(controlled_get_config_dict))
monkeypatch.setattr(OmniEngineArgs, "_ensure_omni_models_registered", lambda self: True)
monkeypatch.setattr(EngineArgs, "create_model_config", fake_parent_create_model_config)
monkeypatch.setattr(
OmniModelConfig,
"from_vllm_model_config",
classmethod(lambda cls, model_config, **omni_kwargs: model_config),
)
args = OmniEngineArgs(
model=str(tmp_path),
model_arch="IndexTTS25TalkerForConditionalGeneration",
)
with pytest.raises(type(loader_error)) as exc_info:
args.create_model_config()
assert str(exc_info.value) == str(loader_error)
assert parent_inputs["called"] is True
assert parent_inputs["model"] == str(tmp_path)
assert parent_inputs["config_root"] == str(tmp_path)
assert parent_inputs["hf_config_path"] is None
assert args.model == str(tmp_path)
assert args.hf_config_path is None
assert not hasattr(args, "_temp_config_dir")
def test_patch_valid_hf_config_without_model_type_preserves_keys(tmp_path):
"""A valid partial config should be copied before required keys are added."""
original_config = {
"hidden_size": 1024,
"custom_key": {"nested": True},
}
(tmp_path / "config.json").write_text(json.dumps(original_config), encoding="utf-8")
args = object.__new__(OmniEngineArgs)
args.model = str(tmp_path)
args.model_arch = "IndexTTS25TalkerForConditionalGeneration"
args.hf_config_path = None
try:
args._patch_empty_hf_config("indextts2_5")
assert args.hf_config_path is not None
with open(os.path.join(args.hf_config_path, "config.json"), encoding="utf-8") as config_file:
patched_config = json.load(config_file)
assert patched_config["hidden_size"] == original_config["hidden_size"]
assert patched_config["custom_key"] == original_config["custom_key"]
assert patched_config["model_type"] == "indextts2_5"
assert patched_config["architectures"] == ["IndexTTS25TalkerForConditionalGeneration"]
finally:
if hasattr(args, "_temp_config_dir"):
shutil.rmtree(args._temp_config_dir, ignore_errors=True)
def test_remote_hf_config_error_reaches_parent_loader(monkeypatch):
"""Remote resolution failures must stay on vLLM's normal error path."""
loader_error = OSError("remote config resolution failed")
parent_inputs = {}
def controlled_get_config_dict(cls, model, **kwargs):
raise loader_error
def fake_parent_create_model_config(self):
parent_inputs["called"] = True
parent_inputs["model"] = self.model
parent_inputs["hf_config_path"] = self.hf_config_path
return PretrainedConfig.get_config_dict(self.model)[0]
monkeypatch.setattr(PretrainedConfig, "get_config_dict", classmethod(controlled_get_config_dict))
monkeypatch.setattr(OmniEngineArgs, "_ensure_omni_models_registered", lambda self: True)
monkeypatch.setattr(EngineArgs, "create_model_config", fake_parent_create_model_config)
args = OmniEngineArgs(
model="remote/model",
model_arch="IndexTTS25TalkerForConditionalGeneration",
)
with pytest.raises(OSError) as exc_info:
args.create_model_config()
assert str(exc_info.value) == str(loader_error)
assert parent_inputs["called"] is True
assert parent_inputs["model"] == "remote/model"
assert parent_inputs["hf_config_path"] is None
assert args.model == "remote/model"
assert args.hf_config_path is None
assert not hasattr(args, "_temp_config_dir")
def test_stage_specific_text_config_override():
"""Stage swap must refresh hf_text_config, dependent attrs, and model_arch_config."""
vllm_config = EngineArgs().create_model_config()
vllm_config.disable_sliding_window = True
thinker_mac = vllm_config.model_arch_config
talker_num_heads = max(2, thinker_mac.total_num_attention_heads // 2)
talker_num_kv_heads = max(1, talker_num_heads // 8)
talker_head_dim = 128
stage_text_config = SimpleNamespace(
sliding_window=4096,
attention_chunk_size=2048,
max_position_embeddings=4096,
num_attention_heads=talker_num_heads,
num_key_value_heads=talker_num_kv_heads,
head_dim=talker_head_dim,
hidden_size=talker_num_heads * talker_head_dim,
vocab_size=thinker_mac.vocab_size,
num_hidden_layers=4,
)
vllm_config.hf_text_config = SimpleNamespace()
vllm_config.hf_config.thinker_config = SimpleNamespace(get_text_config=lambda: stage_text_config)
omni_config = OmniModelConfig.from_vllm_model_config(
vllm_config,
hf_config_name="thinker_config",
)
assert omni_config.hf_text_config is stage_text_config
assert omni_config.attention_chunk_size == 2048
assert omni_config.max_model_len == 4096
assert omni_config.hf_text_config.sliding_window is None
stage_mac = omni_config.model_arch_config
assert stage_mac is not thinker_mac
assert stage_mac.total_num_attention_heads == talker_num_heads
assert stage_mac.total_num_kv_heads == talker_num_kv_heads
assert stage_mac.head_size == talker_head_dim
parallel_config = SimpleNamespace(
tensor_parallel_size=1,
pipeline_parallel_size=1,
decode_context_parallel_size=1,
)
assert omni_config.get_num_attention_heads(parallel_config) == talker_num_heads
assert omni_config.get_num_kv_heads(parallel_config) == talker_num_kv_heads
assert omni_config.get_head_size() == talker_head_dim
def test_non_override_ar_stage_inputs_embeds_size_matches_hidden_size():
"""A stage without an embedding-size override keeps the default input width."""
vllm_config = EngineArgs().create_model_config()
vllm_config.hf_config = Qwen3OmniMoeConfig(enable_audio_output=True)
omni_config = OmniModelConfig.from_vllm_model_config(
vllm_config,
model_stage="talker",
hf_config_name="talker_config",
)
assert (
getattr(
omni_config.hf_config.talker_config,
"embedding_size",
None,
)
is None
)
assert omni_config.get_inputs_embeds_size() == omni_config.hf_text_config.hidden_size
def test_stage_configs_path_field():
"""OmniEngineArgs with stage_configs_path should construct without error."""
args = OmniEngineArgs(stage_configs_path="/some/path.yaml")
assert args.stage_configs_path == "/some/path.yaml"
def test_strip_single_engine_args():
"""_strip_single_engine_args should remove EngineArgs fields but keep omni fields."""
kwargs = {
# Parent EngineArgs fields — stripped unless explicitly allowlisted
"compilation_config": '{"cudagraph_mode": "FULL_AND_PIECEWISE"}',
"tensor_parallel_size": 4,
"gpu_memory_utilization": 0.9,
"model": "some/model",
# Parent field that should be kept (allowlisted)
"worker_extension_cls": "some.Extension",
# OmniEngineArgs-only / non-engine fields — should pass through
"stage_configs_path": "/path/to/yaml",
"custom_pipeline_args": {"pipeline_class": "my.Pipeline"},
"mode": "text-to-image",
"lora_path": "/some/lora",
}
filtered = AsyncOmniEngine._strip_single_engine_args(kwargs)
# Stripped — parent EngineArgs fields
assert "compilation_config" not in filtered
assert filtered["tensor_parallel_size"] == 4
assert "gpu_memory_utilization" not in filtered
assert "model" not in filtered
# Stripped — orchestrator-level OmniEngineArgs field
assert "stage_configs_path" not in filtered
# Kept
assert filtered["worker_extension_cls"] == "some.Extension"
assert filtered["custom_pipeline_args"] == {"pipeline_class": "my.Pipeline"}
assert filtered["mode"] == "text-to-image"
assert filtered["lora_path"] == "/some/lora"
def test_strip_single_engine_args_model_does_not_trigger_warning(mocker):
"""model is always in kwargs (callers set it via from_cli_args/asdict),
so it should not cause the override warning by itself or appear in it."""
mock_warn = mocker.patch("vllm_omni.engine.async_omni_engine.logger.warning")
# Typical caller kwargs: model is always present, no other parent
# EngineArgs fields are explicitly overridden.
AsyncOmniEngine._strip_single_engine_args(
{
"model": "some/model",
"custom_pipeline_args": {"pipeline_class": "my.Pipeline"},
}
)
mock_warn.assert_not_called()
# When there *are* genuinely surprising overrides alongside model,
# the warning should mention them but not model. Keep-listed fields such as
# tensor_parallel_size are intentionally passed through and should not warn.
AsyncOmniEngine._strip_single_engine_args(
{
"model": "some/model",
"compilation_config": '{"cudagraph_mode": "FULL_AND_PIECEWISE"}',
"tensor_parallel_size": 4,
"custom_pipeline_args": {"pipeline_class": "my.Pipeline"},
}
)
mock_warn.assert_called_once()
warned_args = mock_warn.call_args[0][-1] # the formatted arg list
assert "compilation_config" in warned_args
assert "tensor_parallel_size" not in warned_args
assert "model" not in warned_args
def test_new_format_deploy_yaml_does_not_strip_interleave_cli_overrides(tmp_path):
"""New-format deploy YAMLs must not strip interleave_mm_strings / media_io_kwargs.
Pytest historically passed ``--stage-configs-path`` for deploy YAMLs; stripping
those CLI flags dropped MiniCPM interleaved AV packing and cut Daily-Omni
accuracy from ~78% to ~70%.
"""
from vllm_omni.entrypoints.utils import is_new_format_deploy_config
yaml_path = tmp_path / "minicpm_deploy.yaml"
yaml_path.write_text(
"""
pipeline: minicpmo_4_5
async_chunk: true
stages:
- stage_id: 0
devices: "0"
""".strip(),
encoding="utf-8",
)
path = str(yaml_path)
assert is_new_format_deploy_config(path)
kwargs = {
"stage_configs_path": path,
"trust_remote_code": True,
"interleave_mm_strings": True,
"media_io_kwargs": {"video": {"fps": 1, "num_frames": 128}},
"gpu_memory_utilization": 0.55,
}
# Mirror AsyncOmniEngine._resolve_stage_configs strip decision.
if kwargs.get("stage_configs_path") is not None and not is_new_format_deploy_config(kwargs["stage_configs_path"]):
base = AsyncOmniEngine._strip_single_engine_args(dict(kwargs))
else:
base = dict(kwargs)
assert base.get("interleave_mm_strings") is True
assert base.get("media_io_kwargs") == {"video": {"fps": 1, "num_frames": 128}}
# Parent EngineArgs fields also remain available for deploy merge.
assert base.get("gpu_memory_utilization") == 0.55
legacy = tmp_path / "legacy.yaml"
legacy.write_text("stage_args:\n - stage_id: 0\n", encoding="utf-8")
assert not is_new_format_deploy_config(str(legacy))
legacy_kwargs = dict(kwargs, stage_configs_path=str(legacy))
stripped = AsyncOmniEngine._strip_single_engine_args(legacy_kwargs)
assert "interleave_mm_strings" not in stripped
assert "media_io_kwargs" not in stripped
assert "gpu_memory_utilization" not in stripped
# For https://github.com/vllm-project/vllm-omni/issues/3293
def test_tensor_parallel_size_none_is_handled():
"""Ensure the tensor parallel size of None isn't forwarded."""
engine_args = OmegaConf.create({"stage_id": 0, "engine_args": {"tensor_parallel_size": None}})
args = build_engine_args_dict(
engine_args,
model="snu-aidas/Dynin-Omni",
)
assert isinstance(args, dict)
assert "tensor_parallel_size" not in args