mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 09:57:57 -04:00
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`. Assisted-by: Claude:claude-opus-5 golangci-lint Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: localai-org-maint-bot <bot-opensource@localaisrl.com>
241 lines
8.1 KiB
Python
241 lines
8.1 KiB
Python
"""vLLM-specific helpers for the vllm and vllm-omni gRPC backends.
|
|
|
|
Generic helpers (``parse_options``, ``messages_to_dicts``) live in
|
|
``python_utils`` and are re-exported here for backwards compatibility with
|
|
existing imports in both backends.
|
|
"""
|
|
import dataclasses
|
|
import difflib
|
|
import json
|
|
import sys
|
|
|
|
from python_utils import messages_to_dicts, parse_options
|
|
|
|
__all__ = [
|
|
"parse_options",
|
|
"messages_to_dicts",
|
|
"setup_parsers",
|
|
"apply_options_to_engine_args",
|
|
"normalize_option_key",
|
|
]
|
|
|
|
# Options[] entries LocalAI itself acts on, in their normalized spelling.
|
|
_BACKEND_LEVEL_OPTIONS = {"tool_parser", "reasoning_parser"}
|
|
|
|
_TRUTHY = {"true", "1", "yes", "on", "t", "y"}
|
|
_FALSY = {"false", "0", "no", "off", "f", "n"}
|
|
|
|
|
|
def normalize_option_key(key):
|
|
"""Normalize an Options[] key to its engine/field spelling.
|
|
|
|
Users copy flags straight out of vLLM's CLI docs (``--reasoning-parser``),
|
|
so accept that spelling everywhere LocalAI looks options up by name.
|
|
"""
|
|
return key.strip().lstrip("-").replace("-", "_")
|
|
|
|
|
|
_BASE_HINTS = {
|
|
"bool": "bool",
|
|
"int": "int",
|
|
"float": "float",
|
|
"str": "str",
|
|
"dict": "dict",
|
|
"mapping": "dict",
|
|
}
|
|
|
|
|
|
def _hint_from_annotation(annotation):
|
|
"""Target type name for a field annotation, or None when it is not a scalar.
|
|
|
|
``dataclasses.fields()`` hands back either a real type, a ``typing``
|
|
construct or - under PEP 563, which vLLM's arg_utils uses - the annotation
|
|
as a plain string, so work on the textual form. Match the *base* of the
|
|
annotation rather than searching it: ``Literal["auto", "float16"]`` (vLLM's
|
|
dtype) contains "float" but is not a float.
|
|
"""
|
|
if annotation is None:
|
|
return None
|
|
if isinstance(annotation, type):
|
|
text = annotation.__name__
|
|
elif isinstance(annotation, str):
|
|
text = annotation
|
|
else:
|
|
text = str(annotation)
|
|
text = text.replace("typing.", "").strip()
|
|
while text.lower().startswith("optional[") and text.endswith("]"):
|
|
text = text[len("optional["):-1].strip()
|
|
if "|" in text:
|
|
parts = [p.strip() for p in text.split("|") if p.strip().lower() not in ("none", "nonetype")]
|
|
if len(parts) != 1:
|
|
return None
|
|
text = parts[0]
|
|
base = text.split("[", 1)[0].strip().lower()
|
|
return _BASE_HINTS.get(base)
|
|
|
|
|
|
def _hint_from_value(current):
|
|
"""Target type name inferred from a field's current value."""
|
|
if isinstance(current, bool):
|
|
return "bool"
|
|
if isinstance(current, dict):
|
|
return "dict"
|
|
if isinstance(current, float):
|
|
return "float"
|
|
if isinstance(current, int):
|
|
return "int"
|
|
if isinstance(current, str):
|
|
return "str"
|
|
return None
|
|
|
|
|
|
def _type_hint(annotation, current):
|
|
"""Best-effort target type name for a dataclass field."""
|
|
hint = _hint_from_annotation(annotation)
|
|
if hint is None:
|
|
hint = _hint_from_value(current)
|
|
return hint
|
|
|
|
|
|
def _coerce_option(raw, hint):
|
|
"""Coerce a CLI-supplied string to the field's type. Raises ValueError."""
|
|
if hint == "bool":
|
|
low = raw.lower()
|
|
if low in _TRUTHY:
|
|
return True
|
|
if low in _FALSY:
|
|
return False
|
|
raise ValueError(f"{raw!r} is not a boolean")
|
|
if hint == "int":
|
|
return int(raw)
|
|
if hint == "float":
|
|
return float(raw)
|
|
if hint == "dict":
|
|
return json.loads(raw)
|
|
if hint == "str":
|
|
return raw
|
|
|
|
# Untyped (or union-typed) field: infer from the literal itself.
|
|
low = raw.lower()
|
|
if low in _TRUTHY:
|
|
return True
|
|
if low in _FALSY:
|
|
return False
|
|
for cast in (int, float):
|
|
try:
|
|
return cast(raw)
|
|
except ValueError:
|
|
pass
|
|
if raw.startswith("{") or raw.startswith("["):
|
|
return json.loads(raw)
|
|
return raw
|
|
|
|
|
|
def apply_options_to_engine_args(engine_args, options):
|
|
"""Apply CLI-style ``--flag[:value]`` entries from Options[] to engine args.
|
|
|
|
``options:`` is a loose bag shared with backend-level settings
|
|
(``tool_parser:``, ``reasoning_parser:``, ``vad_only``, …), so only
|
|
``--`` prefixed entries are treated as engine flags. Names are normalized
|
|
the way vLLM's own CLI does (``--enable-prefix-caching`` →
|
|
``enable_prefix_caching``) and values are coerced to the target field's
|
|
type. Both ``--flag:value`` (LocalAI convention) and ``--flag=value``
|
|
(vLLM CLI convention) are accepted; a bare ``--flag`` sets a boolean field.
|
|
|
|
Unknown or uncoercible flags warn on stderr and are skipped instead of
|
|
failing the load - unlike ``engine_args:``, which is engine-only and
|
|
therefore strict, Options[] carries entries this function knows nothing
|
|
about.
|
|
|
|
Returns a new dataclass instance via ``dataclasses.replace`` so the
|
|
engine's ``__post_init__`` re-runs, or the original when nothing applied.
|
|
"""
|
|
if not options:
|
|
return engine_args
|
|
|
|
fields = {f.name: f for f in dataclasses.fields(type(engine_args))}
|
|
updates = {}
|
|
for opt in options:
|
|
opt = opt.strip()
|
|
if not opt.startswith("--"):
|
|
continue
|
|
body = opt[2:]
|
|
seps = [i for i in (body.find(":"), body.find("=")) if i != -1]
|
|
if seps:
|
|
cut = min(seps)
|
|
name, raw = body[:cut], body[cut + 1:].strip()
|
|
else:
|
|
name, raw = body, None
|
|
name = normalize_option_key(name)
|
|
|
|
if name not in fields:
|
|
# LocalAI reads these itself; whether the engine dataclass carries
|
|
# them too varies by vLLM version, so never flag them as unknown.
|
|
if name in _BACKEND_LEVEL_OPTIONS:
|
|
continue
|
|
suggestion = difflib.get_close_matches(name, fields, n=1)
|
|
hint = f" did you mean {suggestion[0]!r}?" if suggestion else ""
|
|
print(
|
|
f"[vllm_utils] unknown engine option {opt!r} (field {name!r}), skipping.{hint}",
|
|
file=sys.stderr,
|
|
)
|
|
continue
|
|
|
|
target = _type_hint(fields[name].type, getattr(engine_args, name, None))
|
|
if raw is None:
|
|
if target != "bool":
|
|
print(
|
|
f"[vllm_utils] engine option {opt!r} needs a value "
|
|
f"(field {name!r} is not a flag), skipping",
|
|
file=sys.stderr,
|
|
)
|
|
continue
|
|
updates[name] = True
|
|
continue
|
|
|
|
try:
|
|
updates[name] = _coerce_option(raw, target)
|
|
except (ValueError, TypeError) as err:
|
|
print(
|
|
f"[vllm_utils] cannot apply engine option {opt!r} to field "
|
|
f"{name!r}: {err}, skipping",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
if not updates:
|
|
return engine_args
|
|
print(f"[vllm_utils] engine options from Options[]: {updates}", file=sys.stderr)
|
|
return dataclasses.replace(engine_args, **updates)
|
|
|
|
|
|
def setup_parsers(opts):
|
|
"""Return ``(tool_parser_cls, reasoning_parser_cls)`` from an opts dict.
|
|
|
|
Uses vLLM's native ``ToolParserManager`` / ``ReasoningParserManager``.
|
|
Returns ``(None, None)`` if vLLM isn't installed or the requested
|
|
parser name can't be resolved.
|
|
"""
|
|
tool_parser_cls = None
|
|
reasoning_parser_cls = None
|
|
|
|
tool_parser_name = opts.get("tool_parser")
|
|
reasoning_parser_name = opts.get("reasoning_parser")
|
|
|
|
if tool_parser_name:
|
|
try:
|
|
from vllm.tool_parsers import ToolParserManager
|
|
tool_parser_cls = ToolParserManager.get_tool_parser(tool_parser_name)
|
|
print(f"[vllm_utils] Loaded tool_parser: {tool_parser_name}", file=sys.stderr)
|
|
except Exception as e:
|
|
print(f"[vllm_utils] Failed to load tool_parser {tool_parser_name}: {e}", file=sys.stderr)
|
|
|
|
if reasoning_parser_name:
|
|
try:
|
|
from vllm.reasoning import ReasoningParserManager
|
|
reasoning_parser_cls = ReasoningParserManager.get_reasoning_parser(reasoning_parser_name)
|
|
print(f"[vllm_utils] Loaded reasoning_parser: {reasoning_parser_name}", file=sys.stderr)
|
|
except Exception as e:
|
|
print(f"[vllm_utils] Failed to load reasoning_parser {reasoning_parser_name}: {e}", file=sys.stderr)
|
|
|
|
return tool_parser_cls, reasoning_parser_cls
|