Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions .agents/vllm-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ options:
- reasoning_parser:qwen3
```

## `Options[]` doubles as CLI-style engine flags

Beyond the parser names above, `Options[]` carries `--` prefixed engine flags (`--enable-prefix-caching`, `--kv-cache-dtype:fp8_e5m2`). `apply_options_to_engine_args` in `backend/python/common/vllm_utils.py` maps them onto `AsyncEngineArgs` fields, and it must run **before** `AsyncLLMEngine.from_engine_args()` - applying them afterwards is a silent no-op, which is exactly what issue #11130 was.

Things to keep straight when touching this:

- Precedence is typed proto fields → `options:` → `engine_args:`. `applyEngineArgDefaults` in `core/config/hooks_vllm.go` therefore skips seeding a production default whose key the user already set as an option, otherwise the later `engine_args:` pass would silently override them.
- Only `--` prefixed entries are engine flags; `tool_parser:`/`reasoning_parser:` and friends keep their meaning. Parser lookups accept both spellings via `normalize_option_key`.
- Unknown or uncoercible flags warn and are skipped, unlike `engine_args:` which is strict - `Options[]` is a shared bag and knows entries this mapping doesn't.
- Field types come from the annotation's *base* (`Literal["auto","float16"]` is not a float). The helper's tests are stdlib-only: `make test-python-helpers`.

Auto-defaults for known model families live in `core/config/parser_defaults.json` and are applied:
- at gallery import time by `core/gallery/importers/vllm.go`
- at model load time by the `vllm` / `vllm-omni` backend hook in `core/config/hooks_vllm.go`
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,9 @@ jobs:
node-version: '20'
- name: run CI script tests
run: make test-ci-scripts

# The shared python backend helpers (Options[] parsing, engine-arg
# mapping, model reference resolution) are stdlib-only, so their tests
# ride along here instead of waiting on a multi-GB backend image build.
- name: run shared python backend helper tests
run: make test-python-helpers
8 changes: 8 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,14 @@ test-build-scripts:
test-ci-scripts:
@set -e; for t in scripts/lib/*_test.mjs; do echo "== $$t"; node --test "$$t"; done

## Runs the unit tests for the shared python backend helpers. These modules are
## pure stdlib on purpose so they run without any backend venv; the list is
## explicit because their siblings (model_identity_test) import grpc and the
## generated protobufs, which only exist inside a built backend.
PYTHON_HELPER_TESTS?=python_utils_test vllm_utils_test model_utils_test mlx_utils_test parent_watch_test
test-python-helpers:
cd backend/python/common && python3 -m unittest $(PYTHON_HELPER_TESTS)

## Runs the core suite ($(TEST_PATHS)) with statement-coverage instrumentation
## and writes a merged profile to $(COVERAGE_PROFILE). Deliberately omits
## --fail-fast so a single failure doesn't truncate the coverage number, and
Expand Down
199 changes: 198 additions & 1 deletion backend/python/common/vllm_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,208 @@
``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"]
__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):
Expand Down
Loading
Loading