Files
vllm/rust/src/cmd/src/cli.rs
T
2026-07-27 14:50:08 +08:00

702 lines
26 KiB
Rust

// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
//! CLI argument definitions for the `vllm-rs` binary.
//!
//! Python vLLM references:
//! - Engine args: <https://github.com/vllm-project/vllm/blob/bc2c0c86efb28e77677a3cfb8687e976914a313a/vllm/engine/arg_utils.py#L657-L1311>
//! - Environment variables: <https://github.com/vllm-project/vllm/blob/bc2c0c86efb28e77677a3cfb8687e976914a313a/vllm/envs.py#L472>
mod unsupported;
use std::collections::HashMap;
use std::ffi::{OsStr, OsString};
use std::path::PathBuf;
use std::time::Duration;
use clap::{Args, Parser, Subcommand};
use educe::Educe;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use serde_with::{DefaultOnNull, OneOrMany, serde_as};
use thiserror_ext::AsReport as _;
use uuid::Uuid;
use vllm_chat::ReasoningParserFactory;
use vllm_engine_core_client::TransportMode;
use vllm_managed_engine::ManagedEngineConfig;
use vllm_managed_engine::cli::{ManagedEngineArgs, repartition_managed_engine_args};
use vllm_server::{
ApiServerOptions, ChatTemplateContentFormatOption, Config, CoordinatorMode, CorsConfig,
DEFAULT_KEEP_ALIVE_TIMEOUT, HttpListenerMode, ParserSelection, RendererSelection, TlsConfig,
};
use crate::cli::unsupported::UnsupportedArgs;
/// Top-level parser for the `vllm-rs` binary.
#[derive(Debug, Parser)]
#[command(
name = "vllm-rs",
about = "Rust frontend and managed-engine CLI for vLLM."
)]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
}
impl Cli {
pub fn parse() -> Self {
Self::try_parse_from(std::env::args_os()).unwrap_or_else(|error| error.exit())
}
pub fn try_parse_from<I, T>(itr: I) -> Result<Self, clap::Error>
where
I: IntoIterator<Item = T>,
T: Into<OsString>,
{
let args: Vec<OsString> = itr.into_iter().map(Into::into).collect();
let repartitioned_args = repartition_managed_engine_args::<Self>(&args, Some("serve"))?;
<Self as Parser>::try_parse_from(&repartitioned_args).inspect(|cli| {
if let Command::Serve(serve) = &cli.command
&& serve.debug_cli
{
println!(
"Original CLI args: {}\n",
args.join(OsStr::new(" ")).display()
);
println!(
"Repartitioned CLI args: {}\n",
repartitioned_args.join(OsStr::new(" ")).display()
);
println!(
"Passthrough Python args: {}",
serve.managed_engine.python_args.join(" ")
);
std::process::exit(0);
}
})
}
}
/// Supported top-level CLI commands.
#[derive(Debug, Subcommand)]
pub enum Command {
/// Run the Rust OpenAI frontend as a Python-supervised worker.
Frontend(FrontendArgs),
/// Launch a managed Python headless engine, then run the Rust OpenAI
/// frontend.
Serve(ServeArgs),
/// Run vLLM benchmarks.
#[command(subcommand)]
Bench(BenchCommand),
}
/// Supported benchmark commands.
#[derive(Debug, Subcommand)]
pub enum BenchCommand {
/// Benchmark online serving throughput.
Serve(vllm_bench::BenchServeArgs),
}
/// A JSON-encoded list of strings, matching Python's `json.loads` CLI type for
/// the CORS list arguments (e.g. `--allowed-origins '["*"]'`). Parsing the whole
/// value as one item keeps clap from treating the field as a repeated flag.
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
#[serde(transparent)]
pub struct JsonStringList(pub Vec<String>);
/// Runtime arguments shared by both paths of the Rust frontend:
///
/// - External-engine mode: Python-supervised bootstrap, `vllm serve` -> `vllm-rs frontend`.
/// Arguments are deserialized from a single JSON object and defaults follow `serde` attrs.
/// - Managed-engine mode: Rust-managed Python engine, `vllm-rs serve`.
/// Arguments are parsed from CLI flags and defaults follow `clap` attrs.
#[serde_as]
#[derive(Educe, Clone, Args, PartialEq, Eq, Deserialize)]
#[educe(Debug)]
pub struct SharedRuntimeArgs {
#[serde(rename = "model_tag")]
/// Model identifier or local model directory used for backend loading and
/// public model ID.
pub model: String,
/// Maximum time to wait for the expected engines to register on the
/// frontend transport.
#[arg(
long = "engine-ready-timeout-secs",
env = "VLLM_ENGINE_READY_TIMEOUT_S",
default_value_t = default_engine_ready_timeout_secs()
)]
#[serde(default = "default_engine_ready_timeout_secs")]
pub engine_ready_timeout_secs: u64,
/// Select the tool call parser depending on the model that you're using.
/// Use `auto` to infer from the model or `none` to disable parsing.
#[arg(long, default_value_t)]
#[serde(default = "default_py_bootstrap_parser_selection")]
pub tool_call_parser: ParserSelection,
/// Select the reasoning parser depending on the model that you're using.
/// Use `auto` to infer from the model or `none` to disable parsing.
#[arg(long, default_value_t)]
#[serde(default = "default_py_bootstrap_parser_selection")]
pub reasoning_parser: ParserSelection,
/// Select the chat renderer implementation.
#[arg(long = "tokenizer-mode", default_value_t)]
#[serde(default, rename = "tokenizer_mode")]
pub renderer: RendererSelection,
/// Disable multimodal inputs and treat the model as language-only.
#[arg(long)]
#[serde(default)]
pub language_model_only: bool,
/// Maximum number of log probabilities to return when `logprobs` is
/// specified in sampling parameters. `-1` means no cap.
#[arg(long, value_parser = clap::value_parser!(i32).range(-1..), allow_negative_numbers = true)]
#[serde(default)]
pub max_logprobs: Option<i32>,
/// TCP port for the gRPC Generate service. When not set, no gRPC server is
/// started.
#[arg(long)]
#[serde(default)]
pub grpc_port: Option<u16>,
/// Maximum time to wait for active requests to drain during shutdown.
#[arg(long, default_value_t = 0)]
#[serde(default)]
pub shutdown_timeout: u64,
/// Maximum idle time (seconds) on a keep-alive HTTP connection before the
/// server closes it (default 5).
#[arg(long = "http-timeout-keep-alive", env = "VLLM_HTTP_TIMEOUT_KEEP_ALIVE")]
#[serde(default)]
pub http_timeout_keep_alive: Option<u64>,
/// The file path to the chat template, or the template in single-line form
/// for the specified model.
#[arg(long)]
#[serde(default)]
pub chat_template: Option<String>,
/// Default keyword arguments to pass to the chat template renderer.
///
/// These will be merged with request-level chat_template_kwargs, with
/// request values taking precedence. Useful for setting default
/// behavior for reasoning models.
///
/// Example: `{"enable_thinking": false}` to disable thinking mode by
/// default for Qwen3/DeepSeek models.
#[arg(long, value_parser = parse_json::<HashMap<String, Value>>, value_name = "JSON")]
#[serde(default)]
pub default_chat_template_kwargs: Option<HashMap<String, Value>>,
/// The format to render message content within a chat template.
///
/// * "auto" detects the format from the template
/// * "string" renders content as a string. Example: `"Hello World"`
/// * "openai" renders content as a list of dictionaries, similar to OpenAI schema. Example:
/// `[{"type": "text", "text": "Hello world!"}]`
#[arg(long, default_value_t)]
#[serde(default)]
pub chat_template_content_format: ChatTemplateContentFormatOption,
/// Log a summary line for each completed request, including prompt/output
/// token counts and finish reason.
#[arg(long)]
#[serde(default)]
pub enable_log_requests: bool,
/// Include prompt_tokens_details in usage when cached prompt tokens are
/// present.
#[arg(
long,
default_missing_value = "true",
num_args = 0..=1
)]
#[serde(default)]
pub enable_prompt_tokens_details: bool,
/// If specified, API server will add X-Request-Id header to responses.
#[arg(
long,
default_missing_value = "true",
num_args = 0..=1
)]
#[serde(default)]
pub enable_request_id_headers: bool,
/// If provided, the server will require one of these keys to be presented
/// in the Authorization header.
#[educe(Debug(ignore))]
#[arg(long, env = "VLLM_API_KEY", value_delimiter = ' ')]
#[serde_as(as = "DefaultOnNull<OneOrMany<_>>")]
#[serde(default)]
pub api_key: Vec<String>,
/// Disable periodic logging of engine statistics (throughput, queue depth,
/// cache usage).
#[arg(long)]
#[serde(default)]
pub disable_log_stats: bool,
/// The model name(s) used in the API. If multiple names are provided, the
/// server will respond to any of the provided names. The model name in the
/// model field of a response will be the first name in this list. If not
/// specified, the model name will be the same as the `--model` argument.
/// Noted that this name(s) will also be used in `model_name` tag
/// content of prometheus metrics, if multiple names provided, metrics
/// tag will take the first one.
#[arg(long, num_args = 0..)]
#[serde(default)]
pub served_model_name: Vec<String>,
/// CORS allowed origins as a JSON list. `["*"]` allows any origin.
#[arg(long, value_parser = parse_json::<JsonStringList>, value_name = "JSON", default_value = r#"["*"]"#)]
#[serde(default = "default_cors_wildcard")]
pub allowed_origins: JsonStringList,
/// CORS allowed methods as a JSON list. `["*"]` allows the standard set.
#[arg(long, value_parser = parse_json::<JsonStringList>, value_name = "JSON", default_value = r#"["*"]"#)]
#[serde(default = "default_cors_wildcard")]
pub allowed_methods: JsonStringList,
/// CORS allowed request headers as a JSON list. `["*"]` mirrors the request.
#[arg(long, value_parser = parse_json::<JsonStringList>, value_name = "JSON", default_value = r#"["*"]"#)]
#[serde(default = "default_cors_wildcard")]
pub allowed_headers: JsonStringList,
/// Allow CORS credentials (cookies, authorization headers).
#[arg(
long,
default_missing_value = "true",
num_args = 0..=1
)]
#[serde(default)]
pub allow_credentials: bool,
/// The file path to the SSL key file. When omitted, the key is read from
/// `--ssl-certfile` (combined PEM).
#[arg(long)]
#[serde(default)]
pub ssl_keyfile: Option<String>,
/// The file path to the SSL cert file. Enables TLS when set.
#[arg(long)]
#[serde(default)]
pub ssl_certfile: Option<String>,
/// The CA certificates file used to verify client certificates (mTLS).
#[arg(long)]
#[serde(default)]
pub ssl_ca_certs: Option<String>,
/// Whether a client certificate is required: 0 = none, 1 = optional,
/// 2 = required (mirrors Python's `ssl.CERT_*`).
#[arg(long, default_value_t = 0, value_parser = clap::value_parser!(i32).range(0..=2))]
#[serde(default)]
pub ssl_cert_reqs: i32,
/// OpenSSL cipher string for HTTPS (TLS 1.2 and below).
/// When unset, the linked OpenSSL's default suites are used.
#[arg(long)]
#[serde(default)]
pub ssl_ciphers: Option<String>,
/// Profiler configuration forwarded by the Python supervisor.
///
/// When set with a non-null `profiler` type, the Rust frontend registers
/// the `/start_profile` and `/stop_profile` routes and forwards calls to
/// the engine via the `"profile"` utility RPC.
#[arg(long, value_parser = parse_json::<ProfilerConfig>, value_name = "JSON")]
#[serde(default)]
pub profiler_config: Option<ProfilerConfig>,
/// Unsupported Python vLLM frontend arguments recognized but not yet
/// implemented in Rust.
#[educe(Debug(ignore))]
#[command(flatten)]
#[serde(default, flatten)]
pub unsupported: UnsupportedArgs,
}
impl SharedRuntimeArgs {
/// Maximum time to wait for the expected engines to register on the
/// frontend transport.
pub fn ready_timeout(&self) -> Duration {
Duration::from_secs(self.engine_ready_timeout_secs)
}
/// Maximum time to wait for active requests to drain during shutdown.
pub fn shutdown_timeout(&self) -> Duration {
Duration::from_secs(self.shutdown_timeout)
}
/// Maximum idle time on a keep-alive HTTP connection before the server
/// closes it.
pub fn keep_alive_timeout(&self) -> Duration {
self.http_timeout_keep_alive
.map_or(DEFAULT_KEEP_ALIVE_TIMEOUT, Duration::from_secs)
}
/// Return the configured profiler mode, when profiling is enabled.
pub fn profiler(&self) -> Option<String> {
self.profiler_config.as_ref().and_then(|c| c.profiler.clone())
}
/// Return the profiler config JSON for managed Python engine forwarding.
pub fn profiler_config_json(&self) -> Option<String> {
self.profiler_config
.as_ref()
.map(serde_json::to_string)
.transpose()
.expect("profiler config serialization should not fail")
}
/// Apply fallback logic for API key configuration from env variables.
fn apply_env_api_key_fallback(&mut self) {
if self.api_key.is_empty()
&& let Ok(api_key) = std::env::var("VLLM_API_KEY")
{
self.api_key.push(api_key);
}
}
/// Build the OpenAI-server config for the Python-bootstrap worker contract.
///
/// The resulting config binds the Python-supplied transport addresses and
/// inherits an already open HTTP listener from the supervisor process.
fn into_bootstrapped_config(
self,
listen_fd: i32,
input_address: String,
output_address: String,
coordinator_address: Option<String>,
engine_start_index: u32,
engine_count: usize,
) -> Config {
let ready_timeout = self.ready_timeout();
let shutdown_timeout = self.shutdown_timeout();
let keep_alive_timeout = self.keep_alive_timeout();
let api_server_options = self.api_server_options();
let cors = self.cors_config();
let tls = self.tls_config();
let profiler = self.profiler();
Config {
transport_mode: TransportMode::Bootstrapped {
input_address,
output_address,
engine_start_index,
engine_count,
ready_timeout,
},
coordinator_mode: match coordinator_address {
Some(address) => CoordinatorMode::External { address },
None => CoordinatorMode::None,
},
model: self.model,
served_model_name: self.served_model_name,
listener_mode: HttpListenerMode::InheritedFd { fd: listen_fd },
tool_call_parser: self.tool_call_parser,
reasoning_parser: self.reasoning_parser,
renderer: self.renderer,
language_model_only: self.language_model_only,
chat_template: self.chat_template,
default_chat_template_kwargs: self.default_chat_template_kwargs,
chat_template_content_format: self.chat_template_content_format,
max_logprobs: self.max_logprobs,
api_server_options,
cors,
tls,
api_keys: self.api_key,
disable_log_stats: self.disable_log_stats,
grpc_port: self.grpc_port,
shutdown_timeout,
keep_alive_timeout,
profiler,
}
}
/// Build the OpenAI-server config for the managed `serve` path that still
/// owns the startup handshake and binds its own HTTP listener.
fn into_managed_config(
self,
listener_mode: HttpListenerMode,
handshake_address: String,
advertised_host: String,
engine_count: usize,
local_input_address: Option<String>,
local_output_address: Option<String>,
) -> Config {
let ready_timeout = self.ready_timeout();
let shutdown_timeout = self.shutdown_timeout();
let keep_alive_timeout = self.keep_alive_timeout();
let api_server_options = self.api_server_options();
let cors = self.cors_config();
let tls = self.tls_config();
let profiler = self.profiler();
Config {
transport_mode: TransportMode::HandshakeOwner {
handshake_address,
advertised_host,
engine_count,
ready_timeout,
local_input_address,
local_output_address,
},
coordinator_mode: CoordinatorMode::MaybeInProc,
model: self.model,
served_model_name: self.served_model_name,
listener_mode,
tool_call_parser: self.tool_call_parser,
reasoning_parser: self.reasoning_parser,
renderer: self.renderer,
language_model_only: self.language_model_only,
chat_template: self.chat_template,
default_chat_template_kwargs: self.default_chat_template_kwargs,
chat_template_content_format: self.chat_template_content_format,
max_logprobs: self.max_logprobs,
api_server_options,
cors,
tls,
api_keys: self.api_key,
disable_log_stats: self.disable_log_stats,
grpc_port: self.grpc_port,
shutdown_timeout,
keep_alive_timeout,
profiler,
}
}
fn api_server_options(&self) -> ApiServerOptions {
ApiServerOptions {
enable_log_requests: self.enable_log_requests,
enable_prompt_tokens_details: self.enable_prompt_tokens_details,
enable_request_id_headers: self.enable_request_id_headers,
}
}
fn cors_config(&self) -> CorsConfig {
CorsConfig {
allow_origins: self.allowed_origins.0.clone(),
allow_methods: self.allowed_methods.0.clone(),
allow_headers: self.allowed_headers.0.clone(),
allow_credentials: self.allow_credentials,
}
}
/// Build the TLS config: `Some` when any `ssl_*` argument is set, else
/// `None` (plaintext). The combination is validated in [`Config::validate`].
fn tls_config(&self) -> Option<TlsConfig> {
let tls_requested = self.ssl_certfile.is_some()
|| self.ssl_keyfile.is_some()
|| self.ssl_ca_certs.is_some()
|| self.ssl_cert_reqs != 0
|| self.ssl_ciphers.is_some();
tls_requested.then(|| TlsConfig {
cert_file: self.ssl_certfile.clone(),
key_file: self.ssl_keyfile.clone(),
ca_certs: self.ssl_ca_certs.clone(),
cert_reqs: self.ssl_cert_reqs,
ciphers: self.ssl_ciphers.clone(),
})
}
}
fn default_engine_ready_timeout_secs() -> u64 {
600
}
fn default_cors_wildcard() -> JsonStringList {
JsonStringList(vec!["*".to_string()])
}
fn default_py_bootstrap_parser_selection() -> ParserSelection {
ParserSelection::None
}
/// Minimal profiler configuration parsed from `--profiler-config`.
///
/// Only the `profiler` field is inspected by the Rust frontend to decide
/// whether to register the `/start_profile` and `/stop_profile` routes.
/// All other fields are accepted but ignored — they are consumed by the
/// Python engine layer.
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
pub struct ProfilerConfig {
/// Profiler backend type (e.g. `"torch"`, `"cuda"`). When `null` or
/// absent, profiling is disabled.
#[serde(default)]
pub profiler: Option<String>,
/// Additional Python profiler config fields consumed by the engine layer.
#[serde(flatten)]
pub extra: serde_json::Map<String, Value>,
}
fn parse_json<T: DeserializeOwned>(value: &str) -> Result<T, String> {
serde_json::from_str(value).map_err(|e| format!("invalid JSON object: {}", e.as_report()))
}
fn parse_runtime_args_json(value: &str) -> Result<SharedRuntimeArgs, String> {
let mut args: SharedRuntimeArgs = serde_json::from_str(value)
.map_err(|e| format!("invalid JSON arguments: {}", e.as_report()))?;
// --args-json is parsed with serde, so clap's env support does not run for
// the Python-supervised frontend path.
args.apply_env_api_key_fallback();
args.unsupported.check()?;
Ok(args)
}
/// Arguments for running the Rust frontend as a Python-bootstrapped worker.
#[derive(Educe, Clone, Args, PartialEq, Eq)]
#[educe(Debug)]
pub struct FrontendArgs {
/// Inherited listening socket file descriptor passed by the Python
/// supervisor.
#[arg(long)]
pub listen_fd: i32,
/// Frontend input ROUTER socket address that the Python engines will
/// connect to.
#[arg(long)]
pub input_address: String,
/// Frontend output PULL socket address that the Python engines will push
/// responses to.
#[arg(long)]
pub output_address: String,
/// Optional Python-owned frontend-side DP coordinator socket address for
/// external coordinator mode in the bootstrapped frontend path, i.e.,
/// `stats_update_address`.
#[arg(long)]
pub coordinator_address: Option<String>,
/// First data-parallel engine rank expected to register with this
/// bootstrapped frontend.
#[arg(long, default_value_t = 0)]
pub engine_start_index: u32,
/// Total number of data-parallel engines expected for this frontend.
#[arg(long, default_value_t = 1)]
pub engine_count: usize,
/// Shared frontend arguments as one JSON object.
#[arg(long = "args-json", value_parser = parse_runtime_args_json, value_name = "JSON")]
pub runtime: SharedRuntimeArgs,
}
impl FrontendArgs {
/// Convert the CLI arguments into the OpenAI server's runtime config.
pub fn into_config(self) -> Config {
self.runtime.into_bootstrapped_config(
self.listen_fd,
self.input_address,
self.output_address,
self.coordinator_address,
self.engine_start_index,
self.engine_count,
)
}
}
/// Arguments for the managed-engine mode that spawns Python on behalf of the
/// user.
#[derive(Educe, Clone, Args, PartialEq, Eq)]
#[educe(Debug)]
#[command(override_usage = "vllm-rs serve <MODEL> [OPTIONS] [-- <PYTHON_ARGS>...]")]
pub struct ServeArgs {
/// Only launch the managed Python headless engine and do not start the Rust
/// frontend.
#[arg(long)]
pub headless: bool,
/// HTTP bind host for the OpenAI-compatible server.
#[arg(long, default_value = "127.0.0.1")]
pub host: String,
/// HTTP bind port for the OpenAI-compatible server.
#[arg(long, default_value_t = 8000)]
pub port: u16,
/// Unix domain socket path. If set, host and port arguments are ignored.
#[arg(long)]
pub uds: Option<String>,
/// Flag to print debug information about CLI argument parsing and exit.
#[educe(Debug(ignore))]
#[arg(long, hide = true, env = "VLLM_RS_DEBUG_CLI")]
pub debug_cli: bool,
/// Shared frontend arguments.
#[command(flatten)]
pub runtime: SharedRuntimeArgs,
/// Managed Python headless-engine arguments.
#[command(flatten)]
pub managed_engine: ManagedEngineArgs,
}
impl ServeArgs {
/// Build the OpenAI-server runtime config used after the managed Python
/// engine starts.
pub fn to_frontend_config(&self, handshake_address: String) -> Config {
// Prefer IPC sockets for local engine input/output.
let (local_input_address, local_output_address) =
self.managed_engine.frontend_local_only().then(frontend_ipc_addresses).unzip();
let listener_mode = match &self.uds {
Some(path) => HttpListenerMode::BindUnix { path: path.clone() },
None => HttpListenerMode::BindTcp {
host: self.host.clone(),
port: self.port,
},
};
self.runtime.clone().into_managed_config(
listener_mode,
handshake_address,
self.managed_engine.handshake_host.clone(),
self.managed_engine.data_parallel_size,
local_input_address,
local_output_address,
)
}
/// Build the managed Python-engine spawn configuration with the given
/// handshake port.
pub fn to_managed_engine_config(&self, handshake_port: u16) -> ManagedEngineConfig {
let reasoning_parser =
effective_engine_reasoning_parser(&self.runtime.reasoning_parser, &self.runtime.model);
let profiler_config = self.runtime.profiler_config_json();
self.managed_engine.clone().into_config(
self.runtime.model.clone(),
self.runtime.max_logprobs,
profiler_config,
reasoning_parser.as_deref(),
self.runtime.language_model_only,
self.runtime.disable_log_stats,
self.runtime.shutdown_timeout,
handshake_port,
)
}
}
fn effective_engine_reasoning_parser(selection: &ParserSelection, model: &str) -> Option<String> {
match selection {
ParserSelection::Auto => ReasoningParserFactory::global()
.resolve_name_for_model(model)
.map(str::to_string),
ParserSelection::None => None,
ParserSelection::Explicit(name) => Some(name.clone()),
}
}
/// Allocate fresh IPC endpoints for one managed frontend instance.
fn frontend_ipc_addresses() -> (String, String) {
let preferred_base_path = std::env::var_os("VLLM_RPC_BASE_PATH")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir);
let input_name = format!("vllm-rs-i-{}", Uuid::new_v4().simple());
let output_name = format!("vllm-rs-o-{}", Uuid::new_v4().simple());
let input = preferred_base_path.join(input_name);
let output = preferred_base_path.join(output_name);
(
format!("ipc://{}", input.to_string_lossy()),
format!("ipc://{}", output.to_string_lossy()),
)
}
#[cfg(test)]
mod tests;