Files
LocalAI/backend/python/common/vllm_utils_test.py
Ettore Di Giacinto 59e3304e2a fix(vllm): apply Options[] engine flags before engine init (#11130)
CLI-style flags in a model's `options:` array (`--quantization:gptq_marlin`,
`--enable-prefix-caching`, `--kv-cache-dtype:fp8_e5m2`) were discarded: the
backend only ever read `tool_parser`/`reasoning_parser` out of Options[], and
did so *after* `AsyncLLMEngine.from_engine_args()`, where nothing it set could
still reach the engine.

Map `--` prefixed options onto the AsyncEngineArgs dataclass before the engine
is constructed. Names are normalized the way vLLM's CLI spells them
(`--enable-prefix-caching` -> `enable_prefix_caching`), values are coerced to
the target field's type (bare flag -> True for booleans), and unknown or
uncoercible flags warn and are skipped instead of failing the load, since
Options[] is a bag shared with backend-level settings. Field types come from
the annotation's base so `Literal["auto", "float16"]` (vLLM's dtype) is not
mistaken for a float.

Precedence is typed proto fields -> `options:` -> `engine_args:`. The
production engine_args defaults seeded in hooks_vllm.go therefore skip any key
the user already set as an option, otherwise the later engine_args pass would
silently override it. Parser lookups now accept both spellings, so
`--reasoning-parser:qwen3` selects LocalAI's parser as well.

The helper's tests are stdlib-only and run in the lint workflow's
dependency-light job via `make test-python-helpers`.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-5 golangci-lint
2026-07-29 09:06:39 +00:00

161 lines
6.3 KiB
Python

"""Unit tests for the shared vLLM backend helpers (vllm_utils.py).
Run standalone (Python standard library only, no backend venv needed):
cd backend/python/common && python3 -m unittest vllm_utils_test
``vllm_utils`` imports vLLM lazily (inside functions), so the module is
importable without the vLLM wheel. ``AsyncEngineArgs`` is stood in for by a
local dataclass that mirrors the field shapes that matter: plain scalars,
``Optional[...]`` scalars (vLLM's tri-state flags) and dict-valued fields.
"""
import contextlib
import dataclasses
import io
import unittest
from typing import Dict, Literal, Optional, Union
from vllm_utils import apply_options_to_engine_args, normalize_option_key
@dataclasses.dataclass
class FakeEngineArgs:
model: str = ""
quantization: Optional[str] = None
kv_cache_dtype: str = "auto"
enable_prefix_caching: Optional[bool] = None
enforce_eager: bool = False
max_model_len: Optional[int] = None
tensor_parallel_size: int = 1
gpu_memory_utilization: float = 0.9
limit_mm_per_prompt: Optional[Dict[str, int]] = None
# vLLM types dtype as a Literal of strings, several of which contain the
# word "float" - a naive match reads that as a float field.
dtype: Literal["auto", "float16", "bfloat16"] = "auto"
seed: Union[int, str, None] = None
# vLLM's arg_utils is annotated under PEP 563, where dataclasses.fields() hands
# back annotations as plain strings rather than types.
StringAnnotatedEngineArgs = dataclasses.make_dataclass(
"StringAnnotatedEngineArgs",
[
("max_model_len", "int | None", dataclasses.field(default=None)),
("enable_prefix_caching", "Optional[bool]", dataclasses.field(default=None)),
("kv_cache_dtype", "str", dataclasses.field(default="auto")),
],
)
def _apply(options, **overrides):
"""Apply options to a fresh FakeEngineArgs, returning (result, stderr)."""
err = io.StringIO()
with contextlib.redirect_stderr(err):
out = apply_options_to_engine_args(FakeEngineArgs(**overrides), options)
return out, err.getvalue()
class TestApplyOptionsToEngineArgs(unittest.TestCase):
def test_string_option_is_applied(self):
out, _ = _apply(["--quantization:gptq_marlin"])
self.assertEqual(out.quantization, "gptq_marlin")
def test_valueless_flag_enables_boolean_field(self):
out, _ = _apply(["--enable-prefix-caching"])
self.assertIs(out.enable_prefix_caching, True)
def test_boolean_field_accepts_explicit_false(self):
out, _ = _apply(["--enable-prefix-caching:false"], enable_prefix_caching=True)
self.assertIs(out.enable_prefix_caching, False)
def test_integer_field_is_coerced(self):
out, _ = _apply(["--max-model-len:4096", "--tensor-parallel-size:2"])
self.assertEqual(out.max_model_len, 4096)
self.assertEqual(out.tensor_parallel_size, 2)
def test_float_field_is_coerced(self):
out, _ = _apply(["--gpu-memory-utilization:0.85"])
self.assertAlmostEqual(out.gpu_memory_utilization, 0.85)
def test_equals_separator_is_accepted(self):
out, _ = _apply(["--kv-cache-dtype=fp8_e5m2"])
self.assertEqual(out.kv_cache_dtype, "fp8_e5m2")
def test_dict_field_is_parsed_as_json(self):
out, _ = _apply(['--limit-mm-per-prompt:{"image": 4}'])
self.assertEqual(out.limit_mm_per_prompt, {"image": 4})
def test_non_flag_options_are_left_alone(self):
out, err = _apply(["tool_parser:hermes", "reasoning_parser:qwen3", "vad_only"])
self.assertEqual(out, FakeEngineArgs())
self.assertEqual(err, "")
def test_unknown_flag_warns_and_is_skipped(self):
out, err = _apply(["--not-a-real-flag:1", "--quantization:awq"])
self.assertEqual(out.quantization, "awq")
self.assertIn("not_a_real_flag", err)
self.assertIn("unknown", err.lower())
def test_parser_flags_are_not_reported_as_unknown(self):
# LocalAI consumes these itself; whether the engine dataclass also has
# the field depends on the vLLM version, and warning about them would
# send users chasing a non-problem.
out, err = _apply(["--tool-parser:hermes", "--reasoning-parser:qwen3"])
self.assertEqual(out, FakeEngineArgs())
self.assertEqual(err, "")
def test_unknown_flag_hints_at_the_closest_field(self):
_, err = _apply(["--max-model-length:4096"])
self.assertIn("max_model_len", err)
def test_uncoercible_value_warns_and_is_skipped(self):
out, err = _apply(["--max-model-len:lots"])
self.assertIsNone(out.max_model_len)
self.assertIn("max_model_len", err)
def test_valueless_flag_on_non_boolean_field_warns_and_is_skipped(self):
out, err = _apply(["--quantization"])
self.assertIsNone(out.quantization)
self.assertIn("quantization", err)
def test_empty_options_returns_the_same_engine_args(self):
original = FakeEngineArgs(model="m")
self.assertIs(apply_options_to_engine_args(original, []), original)
class TestFieldTypeInference(unittest.TestCase):
def test_literal_typed_field_keeps_its_string_value(self):
out, err = _apply(["--dtype:bfloat16"])
self.assertEqual(out.dtype, "bfloat16")
self.assertEqual(err.count("skipping"), 0)
def test_ambiguous_union_falls_back_to_literal_inference(self):
out, _ = _apply(["--seed:42"])
self.assertEqual(out.seed, 42)
def test_string_annotations_are_understood(self):
err = io.StringIO()
with contextlib.redirect_stderr(err):
out = apply_options_to_engine_args(
StringAnnotatedEngineArgs(),
["--max-model-len:4096", "--enable-prefix-caching", "--kv-cache-dtype:fp8_e5m2"],
)
self.assertEqual(out.max_model_len, 4096)
self.assertIs(out.enable_prefix_caching, True)
self.assertEqual(out.kv_cache_dtype, "fp8_e5m2")
class TestNormalizeOptionKey(unittest.TestCase):
def test_cli_flag_becomes_a_field_name(self):
self.assertEqual(normalize_option_key("--reasoning-parser"), "reasoning_parser")
def test_plain_key_is_untouched(self):
self.assertEqual(normalize_option_key("tool_parser"), "tool_parser")
def test_surrounding_whitespace_is_stripped(self):
self.assertEqual(normalize_option_key(" --tool-parser "), "tool_parser")
if __name__ == "__main__":
unittest.main()