Files
Model-Optimizer/examples/vllm_serve/vllm_mlflow_utils.py
T
Chenjie Luo b96841db3e Add optional MLflow tracking to the vLLM fake-quant server (#2120)
### What does this PR do?

Type of change: new feature

Wires `examples/vllm_serve/vllm_serve_fakequant.py` up to
`modelopt.torch.utils.mlflow` via `--mlflow <tracking-uri>`, the same
way #2023 did for `hf_ptq.py`, so a fake-quant serve records **what it
actually quantized** and an evaluation of that endpoint can be traced
back to a recipe. Without the flag, behavior is unchanged — every hook
is gated on it.

Three design points worth review:

1. **The run is recorded in the vLLM worker, not the launcher.**
`vllm_serve_fakequant.py` is the API-server frontend; the engine and its
workers are separate processes whose stdout it never sees, so a run
opened there would capture none of the calibration. The launcher instead
only settles the tracking configuration — validating the URI, naming the
experiment, recording the command the user actually typed — and
publishes it through the environment, which is how every other setting
in this example (`QUANT_CFG`, `RECIPE_PATH`, …) already reaches the
workers. Global rank 0 opens the run, so a TP-8 serve produces one run.

2. **The run covers load-through-warm-up, not the server's lifetime.**
It opens *before the weights load*, so an unreachable server or a
missing token fails in seconds rather than after a load and a full
calibration, and it closes `FINISHED` once the model is quantized and
warmed up. A run that stayed open for the serving lifetime would never
close cleanly on SIGTERM.

3. **`recipe/quant_cfg.yaml` is only written on the preset path.** With
`RECIPE_PATH`, `get_quant_config` returns the recipe's `quantize`
section unchanged and `resolved_recipe.yaml` already carries it. With
`QUANT_CFG`/`KV_QUANT_CFG` it is the *only* record of what ran: the
params carry the preset names, while the config reaching `mtq.quantize`
is those two deep-copied, merged, and — for an MLA model — extended at
runtime with `*kv_c_bmm_quantizer` / `*k_pe_bmm_quantizer` by inspecting
the loaded model.

Uploaded artifacts:

| Artifact | Contents |
| --- | --- |
| `command.txt` | The launcher's invocation, copy-pasteable, credentials
masked |
| `version.txt` | The ModelOpt version that ran |
| `recipe/resolved_recipe.yaml` | `RECIPE_PATH` with its `$import`s
expanded |
| `recipe/quant_cfg.yaml` | Merged `QUANT_CFG`/`KV_QUANT_CFG` + MLA
fixup (preset path only) |
| `logs/<script>.log` | The rank-0 worker's stdout/stderr, including a
crash traceback |
| `summary/quant_summary.txt` | The per-quantizer summary |

Plus the quantization *and* serving settings as searchable params, and
`user` / `hostname` / `modelopt_version` / `git_sha` / `vllm_version`
tags. The `checkpoint_path` tag matches the one `hf_ptq.py` sets, so a
checkpoint's PTQ run and every serve of it join up.

Two small library additions, both consumed by the new example module:

- `command_text(argv=None)` — records another process's invocation,
since a spawned worker's own `sys.argv` is vLLM plumbing rather than
anything a user typed.
- `MlflowRunLogger.log_text()` — uploads a value settled midway through
a run, so a crash during calibration still keeps the config that caused
it.

The example `Dockerfile` installs the `mlflow` extra; the client remains
optional and is imported only once tracking is enabled.

### Usage

```bash
RECIPE_PATH=<recipe.yaml> python vllm_serve_fakequant.py <model_path> -tp 8 \
  --host 0.0.0.0 --port 8000 \
  --mlflow https://<your-mlflow-server>/
```

```
[mlflow] tracking to https://<your-mlflow-server>, experiment $USER/vllm_serve_fakequant/<model>-<recipe>
(Worker_TP0) [mlflow] run: https://<your-mlflow-server>/#/experiments/19/runs/1c6679448f25...
```

`--mlflow-experiment` / `--mlflow-run-name` override the defaults.
`$MLFLOW_TRACKING_URI` enables tracking on its own and is best-effort;
an explicit `--mlflow` overrides it and fails loudly.

> This is the **quantization** tracking server. It is unrelated to any
server an evaluation harness exports its scores to — NeMo Evaluator
Launcher has its own `export.mlflow.tracking_uri`. The README calls this
out.

### Testing

**Unit — 87 passing**
(`tests/examples/vllm_serve/test_vllm_mlflow_utils.py`, 33 new;
`tests/unit/torch/utils/test_mlflow.py`, +5). `vllm_mlflow_utils`
deliberately imports no vLLM, so the whole launcher→worker handover is
covered without a GPU, a server, or the mlflow client.

**End to end on aws-cmh** (4× GB300, `simple_evals.gpqa_diamond`,
Nemotron-3.5-Lightning-30B-A3B-BF16 fake-quantized with
`general/ptq/nvfp4_mlp_only-kv_fp8_cast`): run `FINISHED` in 261.5 s,
opened by `Worker_TP0` only, all artifacts present and verified by
content — `command.txt` held the launcher's invocation rather than the
worker's spawn argv, and `resolved_recipe.yaml` was 6797 B against 1845
B of source. 104 quantizers enabled (92 NVFP4 dynamic block-16 expert
weight/input with calibrated amax, 12 FP8 KV bmm). The eval then ran to
completion against the served endpoint, 22/22 requests HTTP 200.

Two bugs the hardware run caught, both fixed here with regression tests:

- `--mlflow_run_name` was rejected. vLLM's
`FlexibleArgumentParser.parse_args` rewrites **every** `--foo_bar` to
`--foo-bar` before matching, so a flag registered only under the
underscored spelling is unreachable from its CLI. Both spellings are now
registered. A unit test on a plain `ArgumentParser` could not have
caught this.
- `recipe/quant_cfg.yaml` uploaded a Python `repr` blob under a `.yaml`
name: a recipe's `quantize` is a `QuantizeConfig`, `yaml.safe_dump`
raises `RepresenterError` on it, and the old JSON fallback stringified
the object. `_dump_yaml` now unwraps pydantic via
`model_dump(mode="json")` and raises otherwise, with the caller
downgrading that to a warning so a bad config cannot take down a serve.

**Known coverage gap:** the preset (`QUANT_CFG`/`KV_QUANT_CFG`) path —
the only one that now writes `recipe/quant_cfg.yaml` — is covered by
unit test but has not been exercised on hardware; the canary used
`RECIPE_PATH`. Likewise the case where `$MLFLOW_TRACKING_URI` is present
*inside* the deployment container and `--mlflow` overrides it is
unit-tested only: NeMo Evaluator Launcher forwards only declared env
vars, so the eval server's URI never entered the container in the
canary.

### Before your PR is "*Ready for review*"

- Is this change backward compatible?: ✅ — new optional flags only; no
`--mlflow` means no behavior change.
- If you copied code from any other sources or added a new PIP
dependency, did you follow guidance in `CONTRIBUTING.md`: ✅ — no new
dependency. Uses the existing optional `nvidia-modelopt[mlflow]` extra
(`mlflow-skinny`, Apache-2.0) added in #2023; the example `Dockerfile`
now installs it. No code copied from other sources.
- Did you write any new necessary tests?: ✅ — 38 new tests.
- Did you update
[Changelog](https://github.com/NVIDIA/Model-Optimizer/blob/main/CHANGELOG.rst)?:
✅ — 0.47 Misc.
- Did you get Claude approval on this PR?: ❌ — `/claude review` not yet
run.

### Additional Information

Follows #2023, which added `MlflowRunLogger` and the `hf_ptq.py`
integration.

Note for anyone tracking from an OCI cluster:
`mlflow-modelopt.nvidia.com` is unreachable from oci-nrt and oci-hsg.
TCP 443 completes and the connection is then reset on the first
application byte, regardless of SNI or protocol, one RTT away — the PDX
PaaS ingress appears to apply a source-IP policy, and the OCI clusters
egress from Oracle-owned addresses (`155.248.190.0`, `168.110.199.1`)
rather than NVIDIA's. gcp-nrt, aws-cmh and cw-dfw all reach it. This is
an infrastructure matter, not a property of this change, but it
determines where the feature is usable today.

🤖 Generated with [Claude Code](https://claude.com/claude-code)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added optional MLflow tracking for vLLM fake-quantization serving
runs.
* Records serving, quantization, worker, and invocation metadata,
including configuration and summary artifacts.
* Supports tracking URI, credentials, environment, and command-line
configuration.
  * Added command and text artifact logging for active MLflow runs.
* **Documentation**
* Documented setup, configuration, recorded artifacts, lifecycle, and
fallback behavior.
  * Updated the example container to include MLflow support.
* **Tests**
* Added comprehensive coverage for tracking configuration, logging,
failures, and disabled tracking.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Signed-off-by: Chenjie Luo <chenjiel@nvidia.com>
2026-08-13 00:00:21 +00:00

418 lines
18 KiB
Python

# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""MLflow tracking for the vLLM fake-quant server, mirroring ``examples/hf_ptq``.
The quantization this example performs happens inside the vLLM **worker** process, not in
``vllm_serve_fakequant.py``: the launcher is the API-server frontend, and the engine and its
workers are separate processes whose output it never sees. So the launcher only settles the
tracking configuration -- validating the URI, naming the experiment, recording the command
the user actually typed -- and publishes it through the environment, the same way every
other setting in this example reaches the workers. Rank 0 opens the run and uploads the
recipe, the effective quantization config, the calibration log and the quantizer summary.
The run covers weight load through warm-up and closes ``FINISHED`` when the server is ready
to serve, rather than staying open for the server's whole lifetime.
Nothing here imports vLLM, so the tracking can be exercised without it.
"""
import argparse
import contextlib
import os
import shutil
import tempfile
import warnings
from collections.abc import Iterator
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
import yaml
import modelopt.torch.quantization as mtq
from modelopt.recipe import load_recipe
from modelopt.torch.utils.mlflow import (
MlflowRunLogger,
command_text,
default_experiment_name,
validate_tracking_uri,
)
TOOL_NAME = "vllm_serve_fakequant"
# Written by the launcher, read by the workers. The two MLflow-owned names are MLflow's own,
# so a shell that already exports them opts in without touching the command line.
TRACKING_URI_ENV = "MLFLOW_TRACKING_URI"
EXPERIMENT_ENV = "MLFLOW_EXPERIMENT_NAME"
RUN_NAME_ENV = "MODELOPT_MLFLOW_RUN_NAME"
REQUIRED_ENV = "MODELOPT_MLFLOW_REQUIRED"
COMMAND_ENV = "MODELOPT_MLFLOW_COMMAND"
# Everything the rank-0 worker needs in its environment to reach the tracking server. The
# credentials are never set here, only forwarded when the launching shell exported them --
# without that, a Ray worker authenticates as nobody and the run fails to open.
MLFLOW_ENV_VARS = frozenset(
{
TRACKING_URI_ENV,
EXPERIMENT_ENV,
RUN_NAME_ENV,
REQUIRED_ENV,
COMMAND_ENV,
"MLFLOW_TRACKING_TOKEN",
"MLFLOW_TRACKING_USERNAME",
"MLFLOW_TRACKING_PASSWORD",
"MLFLOW_TRACKING_INSECURE_TLS",
"MLFLOW_HTTP_REQUEST_MAX_RETRIES",
}
)
def add_mlflow_args(parser: argparse.ArgumentParser) -> None:
"""Add the MLflow tracking flags to the launcher's parser.
The multi-word flags are registered under both spellings. vLLM's
``FlexibleArgumentParser`` rewrites every ``--foo_bar`` on the command line to
``--foo-bar`` before matching, so the dashed spelling is the one that has to exist for
the flag to be reachable at all; the underscored spelling is what ``hf_ptq`` uses and
keeps these usable with a plain ``argparse.ArgumentParser``.
"""
parser.add_argument(
"--mlflow",
default=None,
help=(
"Track this server's calibration on an MLflow server "
"(e.g. https://<your-mlflow-server>/), uploading the command, the resolved "
"recipe, the quantization config actually applied, the worker log and the "
"quantizer summary. This is the quantization tracking server, which is "
"unrelated to any tracking server an evaluation harness exports its scores to. "
"MLflow's own $MLFLOW_TRACKING_URI enables tracking without this flag, which "
"overrides it. A URI taken from the environment is best-effort: if it is "
"unusable the server warns and serves untracked."
),
)
parser.add_argument(
"--mlflow-experiment",
"--mlflow_experiment",
default=None,
help=(
"MLflow experiment name. Default: "
f"$USER/{TOOL_NAME}/<model basename>-<recipe name, or the quantization config>."
),
)
parser.add_argument(
"--mlflow-run-name",
"--mlflow_run_name",
default=None,
help="MLflow run name. Default: the UTC start time as YYYYmmdd-HHMMSS.",
)
def resolve_mlflow_args(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None:
"""Settle the tracking configuration and publish it to the worker processes.
Validating here rather than in the worker is what makes a typo in the URI fail at launch
instead of after the weights are on the GPUs. As in ``hf_ptq``, only ``--mlflow`` is a
deliberate request and therefore fatal when unusable; ``$MLFLOW_TRACKING_URI`` is
commonly exported for unrelated tooling and must not take a serve down with it.
"""
required = args.mlflow is not None
uri = args.mlflow or os.environ.get(TRACKING_URI_ENV) or None
if uri:
try:
uri = validate_tracking_uri(uri)
except ValueError as e:
if required:
parser.error(f"--mlflow: {e}")
warnings.warn(f"Ignoring ${TRACKING_URI_ENV}, continuing untracked: {e}")
uri = None
if not uri:
# A rejected URI must not reach the workers, which would try it again and fail there.
os.environ.pop(TRACKING_URI_ENV, None)
return
os.environ[TRACKING_URI_ENV] = uri
os.environ[REQUIRED_ENV] = "1" if required else "0"
# The workers' own sys.argv is vLLM's spawn plumbing; this is the command a user ran.
os.environ[COMMAND_ENV] = command_text()
os.environ[EXPERIMENT_ENV] = (
args.mlflow_experiment
or os.environ.get(EXPERIMENT_ENV)
or default_experiment_name(TOOL_NAME, args.model, quant_variant())
)
if args.mlflow_run_name:
os.environ[RUN_NAME_ENV] = args.mlflow_run_name
print(
f"[mlflow] tracking to {_without_credentials(uri)}, experiment {os.environ[EXPERIMENT_ENV]}"
)
def _without_credentials(uri: str) -> str:
"""Strip any ``user:token@`` from *uri*, for printing.
``MlflowRunLogger`` masks the same thing in every URI it prints or uploads, and this
line ends up in the worker log that the run itself uploads, so it has to match.
"""
parsed = urlparse(uri)
return parsed._replace(netloc=parsed.netloc.rpartition("@")[2]).geturl()
def quant_variant() -> str:
"""What distinguishes this serve of the model, for the default experiment name.
Read from the environment rather than taken as an argument because that is where this
example's quantization settings live, and both the launcher and a worker that was
started directly (``vllm serve --worker-cls fakequant_worker.FakeQuantWorker``) need it.
"""
if recipe := os.environ.get("RECIPE_PATH"):
return Path(recipe.rstrip("/")).stem
quant_cfg = os.environ.get("QUANT_CFG")
kv_quant_cfg = os.environ.get("KV_QUANT_CFG")
if quant_cfg or kv_quant_cfg:
return "-".join(cfg for cfg in (quant_cfg, kv_quant_cfg) if cfg)
if os.environ.get("MODELOPT_STATE_PATH"):
return "modelopt_state"
if os.environ.get("QUANT_FILE_PATH"):
return "quantizer_state"
return "unquantized"
class FakeQuantMlflowTracker:
"""Records one vLLM fake-quant worker's calibrate-and-serve as an MLflow run.
Inert unless the launcher published a tracking URI *and* this is the global rank-0
worker, so the worker needs no branching: every method is a no-op otherwise, and the
server behaves exactly as it did before tracking existed.
"""
def __init__(self, worker: Any, quant_config: dict[str, Any]):
"""Configure the run from the environment; nothing contacts the server yet."""
uri = os.environ.get(TRACKING_URI_ENV) or None
self._quant_config = quant_config
self._staging: Path | None = None
self._files: dict[str, Path] = {}
self._closed = False
self._worker = worker
self._logger = MlflowRunLogger(
uri or "",
os.environ.get(EXPERIMENT_ENV) or _fallback_experiment(worker),
run_name=os.environ.get(RUN_NAME_ENV) or None,
enabled=bool(uri) and getattr(worker, "rank", 0) == 0,
required=os.environ.get(REQUIRED_ENV) == "1",
)
@property
def enabled(self) -> bool:
"""Whether this worker records the run."""
return self._logger.enabled
def start(self) -> None:
"""Open the run and upload what is already known.
Called before the weights load so an unreachable server or a missing token fails in
seconds rather than after a multi-minute load followed by a calibration.
"""
if not self._logger.enabled:
return
self._staging = Path(tempfile.mkdtemp(prefix="modelopt-vllm-mlflow-"))
self._files = {"summary/quant_summary.txt": self._staging / ".quant_summary.txt"}
try:
self._logger.start(
params={**self._quant_config, **_vllm_params(self._worker)},
tags=_run_tags(self._worker),
texts=self._start_texts(),
files=self._files,
)
except BaseException:
# An explicit --mlflow is fatal here by design; leave no staging directory behind.
self._discard_staging()
raise
if not self._logger.enabled:
# A URI from the environment is best-effort: start() reports an unusable server
# by disabling itself rather than raising, and every later method -- finish()
# included -- returns before reaching the cleanup. So clean up here instead.
self._discard_staging()
def log_quant_config(self, quant_cfg: Any) -> None:
"""Upload the merged ``QUANT_CFG``/``KV_QUANT_CFG`` config, when that is what ran.
Only for the preset path, where this is the sole record of what was applied: the
params carry the preset *names*, while the config that reaches ``mtq.quantize`` is
those two deep-copied, merged, and -- for an MLA model -- extended at runtime with
``*kv_c_bmm_quantizer`` / ``*k_pe_bmm_quantizer`` by inspecting the loaded model.
A recipe run skips it: ``get_quant_config`` returns the recipe's ``quantize``
section unchanged, which ``recipe/resolved_recipe.yaml`` already carries.
Uploaded as soon as it is known rather than at the end, because a run that dies
during calibration is exactly the one this artifact is wanted for.
"""
if not self._logger.enabled or self._quant_config.get("recipe_path"):
return
try:
text = _dump_yaml(quant_cfg)
except Exception as e:
# Never let an unserializable config take down a serve that would have worked.
print(f"[mlflow] WARNING: could not serialize the quantization config: {e}")
return
self._logger.log_text("recipe/quant_cfg.yaml", text)
def log_quant_summary(self, model: Any) -> None:
"""Stage the per-quantizer summary for upload; a no-op when untracked."""
if not self._logger.enabled or self._staging is None:
return
# Writes .quant_summary.txt and prints only its path, so the console copy the caller
# already printed is not repeated.
mtq.print_quant_summary(model, output_dir=str(self._staging))
def finish(self, status: str) -> None:
"""Upload the log and the summary, and close the run with *status*.
Only the first call has an effect: vLLM drives the worker through several guarded
steps, and a run already closed as ``FINISHED`` must not be reopened or downgraded
by a failure in whatever the server does next.
"""
if not self._logger.enabled or self._closed:
return
self._closed = True
try:
self._logger.finish(status, files=self._files)
finally:
self._discard_staging()
def _discard_staging(self) -> None:
if self._staging is not None:
shutil.rmtree(self._staging, ignore_errors=True)
self._staging = None
@contextlib.contextmanager
def fail_on_error(self) -> Iterator[None]:
"""Close the run as ``FAILED`` if the wrapped step raises, then re-raise.
vLLM drives the worker through several calls; without this a failure in any of them
would leave the run ``RUNNING`` forever, with no log attached.
"""
try:
yield
except BaseException:
self.finish("FAILED")
raise
def _start_texts(self) -> dict[str, str]:
texts = {}
if command := os.environ.get(COMMAND_ENV):
texts["command.txt"] = command
if recipe_path := self._quant_config.get("recipe_path"):
# The resolved recipe, not the source file: a recipe may be a directory or use
# $imports, and only the resolved form stands alone.
texts["recipe/resolved_recipe.yaml"] = _dump_yaml(
load_recipe(recipe_path).model_dump(mode="json")
)
return texts
def _dump_yaml(value: Any) -> str:
"""YAML for an artifact, unwrapping a pydantic model first.
A recipe's ``quantize`` section is a ``QuantizeConfig``, which ``yaml.safe_dump`` cannot
represent. Raising here is deliberate: a ``repr`` fallback would upload an unparseable
one-line blob under a ``.yaml`` name, which looks like a successful artifact until
someone tries to read it. The caller turns the failure into a warning instead.
"""
if hasattr(value, "model_dump"):
value = value.model_dump(mode="json")
return yaml.safe_dump(value, sort_keys=False)
def _fallback_experiment(worker: Any) -> str:
"""Name the experiment from the worker itself, for a worker started without the launcher."""
model = _model_config_value(worker, "model") or "unknown"
return default_experiment_name(TOOL_NAME, str(model), quant_variant())
def _model_config_value(worker: Any, name: str, default: Any = None) -> Any:
config = getattr(getattr(worker, "vllm_config", None), "model_config", None)
return getattr(config, name, default)
def _vllm_params(worker: Any) -> dict[str, Any]:
"""Serving settings worth searching on, best-effort across vLLM versions.
Every field is read through ``getattr`` with a default: these configs are reshuffled
between vLLM releases, and a renamed attribute must not take down a serve that would
otherwise have worked.
"""
vllm_config = getattr(worker, "vllm_config", None)
model_config = getattr(vllm_config, "model_config", None)
parallel_config = getattr(vllm_config, "parallel_config", None)
cache_config = getattr(vllm_config, "cache_config", None)
params = {
"model": _stringify(getattr(model_config, "model", None)),
"served_model_name": _stringify(getattr(model_config, "served_model_name", None)),
"dtype": _stringify(getattr(model_config, "dtype", None)),
"max_model_len": getattr(model_config, "max_model_len", None),
"enforce_eager": getattr(model_config, "enforce_eager", None),
"quantization": _stringify(getattr(model_config, "quantization", None)),
"tensor_parallel_size": getattr(parallel_config, "tensor_parallel_size", None),
"pipeline_parallel_size": getattr(parallel_config, "pipeline_parallel_size", None),
"data_parallel_size": getattr(parallel_config, "data_parallel_size", None),
"world_size": getattr(parallel_config, "world_size", None),
"kv_cache_dtype": _stringify(getattr(cache_config, "cache_dtype", None)),
"vllm_version": _vllm_version(),
}
return {k: v for k, v in params.items() if v is not None}
def _run_tags(worker: Any) -> dict[str, str]:
"""Tags shared with ``hf_ptq``, so a checkpoint's PTQ run and the serves of it join up.
``checkpoint_path`` is the checkpoint being served, which is the same key ``hf_ptq``
tags with the checkpoint it *writes* and the one an evaluation is pointed at
(NEL's ``deployment.checkpoint_path``).
"""
model = _stringify(_model_config_value(worker, "model")) or "unknown"
tags = {
"tool": TOOL_NAME,
"model": Path(model).name,
"checkpoint_path": _resolved(model),
"vllm_version": _vllm_version(),
}
if served := _stringify(_model_config_value(worker, "served_model_name")):
tags["served_model_name"] = served
return tags
def _resolved(model: str) -> str:
"""The served checkpoint as an absolute path, or unchanged for a Hugging Face model id."""
return str(Path(model).resolve()) if os.path.exists(model) else model
def _stringify(value: Any) -> str | None:
"""Flatten a config value to a string; vLLM stores some of these as one-element lists."""
if value is None:
return None
if isinstance(value, (list, tuple)):
return ",".join(str(v) for v in value) or None
return str(value)
def _vllm_version() -> str:
try:
import vllm
return str(vllm.__version__)
except ImportError:
return "unknown"