[Rust Frontend] Add standalone Rust renderer (#50289)
Sage committed
Aug 6, 2026 at 14:23 UTC
865781e62769fc469e5b1859773bb17bc36e5b3c
15 files changed
+720
-40
rust/README.md
+39
-1
@@ -15,6 +15,7 @@ The component is organized as a Cargo workspace with several crates, layered bot
15
│ vllm-cmd / vllm-rs │ CLI entrypoint:
16
│ │ Python vLLM frontend subprocess
17
│ │ Rust managed-engine serve mode
18
+│ │ Engine-free render mode
19
├─────────────────────────────────┤
20
│ vllm-server │ OpenAI-compatible HTTP API (axum)
21
├─────────────────────────────────┤
@@ -74,9 +75,46 @@ To build the `vllm-rs` in isolation:
75
./build_rust.sh
76
```
77
78
+### Engine-free renderer
79
+
80
+`vllm-rs render` serves text-only request preprocessing without starting or
81
+connecting to a Python inference engine:
82
+
83
+```bash
84
+cargo run --manifest-path rust/Cargo.toml -p vllm-cmd --release -- \
85
+ render Qwen/Qwen3-32B \
86
+ --host 127.0.0.1 --max-model-len 32768
87
+```
88
+
89
+It exposes `/v1/chat/completions/render` and `/v1/completions/render`. Only
90
+tokenizer and model configuration files are loaded; model weights, PyTorch,
91
+and vLLM kernels are not required.
92
+
93
+The render endpoints return the public token-in `GenerateRequest` consumed by
94
+the Rust `/inference/v1/generate` endpoint. A chat render response, or one item
95
+from a completion render response, can be submitted to that endpoint without
96
+changing its fields.
97
+
98
+The render and inference paths use the same `vllm-chat` and `vllm-text`
99
+request-preparation logic; render mode stops before engine submission.
100
+Tool-call and reasoning parsers use model-based auto-detection by default. Use
101
+`--tool-call-parser` and `--reasoning-parser` to override either selection;
102
+unified parsers require the same selection for both options.
103
+
104
+```bash
105
+curl http://127.0.0.1:8000/v1/chat/completions/render \
106
+ -H "Content-Type: application/json" \
107
+ -d '{
108
+ "model": "Qwen/Qwen3-32B",
109
+ "messages": [{"role": "user", "content": "Hello"}],
110
+ "max_completion_tokens": 16
111
+ }'
112
+```
113
+
114
### Example Request
115
79
-After either startup path, you can use any OpenAI-compatible client:
116
+After either full-frontend startup path, you can use any OpenAI-compatible
117
+client against the inference endpoints:
118
119
```bash
120
curl http://127.0.0.1:8000/v1/chat/completions \
rust/src/chat/src/lib.rs
+31
-22
@@ -97,6 +97,10 @@ pub struct ChatRequestProcessor {
97
/// Effective model dtype reported by the engine.
98
/// Absent for text-only frontends without an engine handshake.
99
model_dtype: Option<ModelDtype>,
100
+ /// Tool-call parser selection used when preparing generation requests.
101
+ tool_call_parser: ParserSelection,
102
+ /// Reasoning parser selection used when preparing generation requests.
103
+ reasoning_parser: ParserSelection,
104
}
105
106
impl ChatRequestProcessor {
@@ -106,6 +110,8 @@ impl ChatRequestProcessor {
110
Self {
111
backend,
112
model_dtype: Some(model_dtype),
113
+ tool_call_parser: ParserSelection::Auto,
114
+ reasoning_parser: ParserSelection::Auto,
115
}
116
}
117
@@ -114,9 +120,22 @@ impl ChatRequestProcessor {
120
Self {
121
backend,
122
model_dtype: None,
123
+ tool_call_parser: ParserSelection::Auto,
124
+ reasoning_parser: ParserSelection::Auto,
125
}
126
}
127
128
+ /// Configure the parser selections used to prepare generation requests.
129
+ pub fn with_parser_selections(
130
+ mut self,
131
+ tool_call_parser: ParserSelection,
132
+ reasoning_parser: ParserSelection,
133
+ ) -> Self {
134
+ self.tool_call_parser = tool_call_parser;
135
+ self.reasoning_parser = reasoning_parser;
136
+ self
137
+ }
138
+
139
async fn finalize_rendered_prompt(
140
&self,
141
request: &ChatRequest,
@@ -196,10 +215,15 @@ impl ChatRequestProcessor {
215
pub async fn prepare(
216
&self,
217
mut request: ChatRequest,
199
- options: NewChatOutputProcessorOptions<'_>,
218
) -> Result<(TextRequest, DynChatOutputProcessor)> {
219
request.validate()?;
202
- let output_processor = self.backend.new_chat_output_processor(&mut request, options)?;
220
+ let output_processor = self.backend.new_chat_output_processor(
221
+ &mut request,
222
+ NewChatOutputProcessorOptions {
223
+ tool_call_parser: &self.tool_call_parser,
224
+ reasoning_parser: &self.reasoning_parser,
225
+ },
226
+ )?;
227
let text_request = self.prepare_text_request(request).await?;
228
Ok((text_request, output_processor))
229
}
@@ -213,10 +237,6 @@ impl ChatRequestProcessor {
237
pub struct ChatLlm {
238
text: TextLlm,
239
processor: ChatRequestProcessor,
216
- /// Tool-call parser selection.
217
- tool_call_parser: ParserSelection,
218
- /// Reasoning parser selection.
219
- reasoning_parser: ParserSelection,
240
}
241
242
impl ChatLlm {
@@ -228,8 +248,6 @@ impl ChatLlm {
248
Self {
249
text,
250
processor: ChatRequestProcessor::new(backend, model_dtype),
231
- tool_call_parser: ParserSelection::Auto,
232
- reasoning_parser: ParserSelection::Auto,
251
}
252
}
253
@@ -242,13 +260,13 @@ impl ChatLlm {
260
261
/// Set tool-call parser selection.
262
pub fn with_tool_call_parser(mut self, selection: ParserSelection) -> Self {
245
- self.tool_call_parser = selection;
263
+ self.processor.tool_call_parser = selection;
264
self
265
}
266
267
/// Set reasoning parser selection.
268
pub fn with_reasoning_parser(mut self, selection: ParserSelection) -> Self {
251
- self.reasoning_parser = selection;
269
+ self.processor.reasoning_parser = selection;
270
self
271
}
272
@@ -306,7 +324,7 @@ impl ChatLlm {
324
325
/// Effective tool-call parser name for this model, if parsing is enabled.
326
pub fn tool_call_parser_name(&self) -> Option<&str> {
309
- match &self.tool_call_parser {
327
+ match &self.processor.tool_call_parser {
328
ParserSelection::Auto => {
329
ToolParserFactory::global().resolve_name_for_model(self.model_id())
330
}
@@ -317,7 +335,7 @@ impl ChatLlm {
335
336
/// Effective reasoning parser name for this model, if parsing is enabled.
337
pub fn reasoning_parser_name(&self) -> Option<&str> {
320
- match &self.reasoning_parser {
338
+ match &self.processor.reasoning_parser {
339
ParserSelection::Auto => {
340
ReasoningParserFactory::global().resolve_name_for_model(self.model_id())
341
}
@@ -328,16 +346,7 @@ impl ChatLlm {
346
347
/// Render, tokenize, and submit one chat request.
348
pub async fn chat(&self, request: ChatRequest) -> Result<ChatEventStream> {
331
- let (text_request, output_processor) = self
332
- .processor
333
- .prepare(
334
- request,
335
- NewChatOutputProcessorOptions {
336
- tool_call_parser: &self.tool_call_parser,
337
- reasoning_parser: &self.reasoning_parser,
338
- },
339
- )
340
- .await?;
349
+ let (text_request, output_processor) = self.processor.prepare(request).await?;
350
let request_id = text_request.request_id.clone();
351
let decoded_stream = self.text.generate(text_request).await?.map_err(Error::from).boxed();
352
rust/src/cmd/src/cli.rs
+65
-1
@@ -29,7 +29,8 @@ use vllm_managed_engine::ManagedEngineConfig;
29
use vllm_managed_engine::cli::{ManagedEngineArgs, repartition_managed_engine_args};
30
use vllm_server::{
31
ApiServerOptions, ChatTemplateContentFormatOption, Config, CoordinatorMode, CorsConfig,
32
- DEFAULT_KEEP_ALIVE_TIMEOUT, HttpListenerMode, ParserSelection, RendererSelection, TlsConfig,
32
+ DEFAULT_KEEP_ALIVE_TIMEOUT, HttpListenerMode, ParserSelection, RenderConfig, RendererSelection,
33
+ TlsConfig,
34
};
35
36
use crate::cli::unsupported::UnsupportedArgs;
@@ -90,6 +91,8 @@ pub enum Command {
91
/// Run vLLM benchmarks.
92
#[command(subcommand)]
93
Bench(BenchCommand),
94
+ /// Run engine-free request rendering and preprocessing.
95
+ Render(RenderArgs),
96
}
97
98
/// Supported benchmark commands.
@@ -99,6 +102,67 @@ pub enum BenchCommand {
102
Serve(vllm_bench::BenchServeArgs),
103
}
104
105
+/// Arguments for the engine-free text renderer.
106
+#[derive(Debug, Args, PartialEq, Eq)]
107
+pub struct RenderArgs {
108
+ /// Model identifier or local model directory containing tokenizer files.
109
+ model: String,
110
+ /// HTTP bind host.
111
+ #[arg(long, default_value = "127.0.0.1")]
112
+ host: String,
113
+ /// HTTP bind port.
114
+ #[arg(long, default_value_t = 8000)]
115
+ port: u16,
116
+ /// Public model names accepted by the API.
117
+ #[arg(long, num_args = 0..)]
118
+ served_model_name: Vec<String>,
119
+ /// Select the tool-call parser. Use `auto` to infer from the model or
120
+ /// `none` to disable parsing.
121
+ #[arg(long, default_value_t)]
122
+ tool_call_parser: ParserSelection,
123
+ /// Select the reasoning parser. Use `auto` to infer from the model or
124
+ /// `none` to disable parsing.
125
+ #[arg(long, default_value_t)]
126
+ reasoning_parser: ParserSelection,
127
+ /// Select the native chat renderer implementation.
128
+ #[arg(long = "tokenizer-mode", default_value_t)]
129
+ renderer: RendererSelection,
130
+ /// Override the model chat template with a file path or inline template.
131
+ #[arg(long)]
132
+ chat_template: Option<String>,
133
+ /// Default JSON keyword arguments merged into every chat-template render.
134
+ #[arg(long, value_parser = parse_json::<HashMap<String, Value>>, value_name = "JSON")]
135
+ default_chat_template_kwargs: Option<HashMap<String, Value>>,
136
+ /// How message content is exposed to the chat template.
137
+ #[arg(long, default_value_t)]
138
+ chat_template_content_format: ChatTemplateContentFormatOption,
139
+ /// Maximum model context length used for request validation.
140
+ #[arg(long)]
141
+ max_model_len: u32,
142
+ /// Maximum accepted logprobs count; -1 disables the cap.
143
+ #[arg(long, value_parser = clap::value_parser!(i32).range(-1..), allow_negative_numbers = true)]
144
+ max_logprobs: Option<i32>,
145
+}
146
+
147
+impl RenderArgs {
148
+ pub(super) fn into_config(self) -> RenderConfig {
149
+ RenderConfig {
150
+ model: self.model,
151
+ served_model_name: self.served_model_name,
152
+ host: self.host,
153
+ port: self.port,
154
+ tool_call_parser: self.tool_call_parser,
155
+ reasoning_parser: self.reasoning_parser,
156
+ renderer: self.renderer,
157
+ chat_template: self.chat_template,
158
+ default_chat_template_kwargs: self.default_chat_template_kwargs.unwrap_or_default(),
159
+ chat_template_content_format: self.chat_template_content_format,
160
+ max_model_len: self.max_model_len,
161
+ max_logprobs: self.max_logprobs,
162
+ }
163
+ }
164
+}
165
+
166
/// A JSON-encoded list of strings, matching Python's `json.loads` CLI type for
167
/// the CORS list arguments (e.g. `--allowed-origins '["*"]'`). Parsing the whole
168
/// value as one item keeps clap from treating the field as a repeated flag.
rust/src/cmd/src/cli/tests.rs
+35
@@ -27,6 +27,41 @@ fn bench_serve_args_parse_without_managed_engine_repartition() {
27
assert!(args.request_rate.is_infinite());
28
}
29
30
+#[test]
31
+fn render_args_build_supported_config() {
32
+ let cli = Cli::try_parse_from([
33
+ "vllm-rs",
34
+ "render",
35
+ "Qwen/Qwen2.5-0.5B-Instruct",
36
+ "--port",
37
+ "8080",
38
+ "--max-model-len",
39
+ "32768",
40
+ "--served-model-name",
41
+ "qwen",
42
+ "--tokenizer-mode",
43
+ "deepseek_v32",
44
+ "--max-logprobs",
45
+ "-1",
46
+ ])
47
+ .unwrap();
48
+
49
+ let Command::Render(args) = cli.command else {
50
+ panic!("expected render args");
51
+ };
52
+ let config = args.into_config();
53
+
54
+ assert_eq!(config.model, "Qwen/Qwen2.5-0.5B-Instruct");
55
+ assert_eq!(config.host, "127.0.0.1");
56
+ assert_eq!(config.port, 8080);
57
+ assert_eq!(config.max_model_len, 32768);
58
+ assert_eq!(config.served_model_name, ["qwen"]);
59
+ assert_eq!(config.tool_call_parser, ParserSelection::Auto);
60
+ assert_eq!(config.reasoning_parser, ParserSelection::Auto);
61
+ assert_eq!(config.renderer, RendererSelection::DeepSeekV32);
62
+ assert_eq!(config.max_logprobs, Some(-1));
63
+}
64
+
65
#[test]
66
fn serve_args_forward_python_flags_with_separator() {
67
let cli = Cli::try_parse_from([
rust/src/cmd/src/main.rs
+3
@@ -211,5 +211,8 @@ async fn async_main(cli: Cli) -> Result<()> {
211
)),
212
}
213
}
214
+ Command::Render(args) => {
215
+ vllm_server::serve_render(args.into_config(), shutdown_signal()).await
216
+ }
217
}
218
}
rust/src/server/src/lib.rs
+2
@@ -9,6 +9,7 @@ mod grpc;
9
mod listener;
10
mod lora;
11
mod middleware;
12
+mod render;
13
mod routes;
14
mod runtime;
15
mod server_info;
@@ -35,6 +36,7 @@ use hyper::server::conn::http1;
36
use hyper_util::rt::{TokioIo, TokioTimer};
37
use hyper_util::server::graceful::GracefulShutdown;
38
use hyper_util::service::TowerToHyperService;
39
+pub use render::{RenderConfig, serve_render};
40
use tokio::net::TcpListener;
41
use tokio::time::{Instant, sleep_until};
42
use tokio_util::sync::CancellationToken;
rust/src/server/src/render.rs
new
+146
@@ -0,0 +1,146 @@
1
+// SPDX-License-Identifier: Apache-2.0
2
+// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+use std::collections::HashMap;
5
+use std::sync::Arc;
6
+
7
+use anyhow::{Context as _, Result, bail};
8
+use serde_json::Value;
9
+use tokio_util::sync::CancellationToken;
10
+use tracing::info;
11
+use vllm_chat::{
12
+ ChatRequestProcessor, ChatTemplateContentFormatOption, LoadModelBackendsOptions,
13
+ ParserSelection, RendererSelection, load_model_backends,
14
+};
15
+use vllm_text::TextRequestProcessor;
16
+
17
+use crate::{HttpListenerMode, listener::Listener};
18
+
19
+/// Configuration for the engine-free text rendering server.
20
+#[derive(Debug)]
21
+pub struct RenderConfig {
22
+ pub model: String,
23
+ pub served_model_name: Vec<String>,
24
+ pub host: String,
25
+ pub port: u16,
26
+ pub tool_call_parser: ParserSelection,
27
+ pub reasoning_parser: ParserSelection,
28
+ pub renderer: RendererSelection,
29
+ pub chat_template: Option<String>,
30
+ pub default_chat_template_kwargs: HashMap<String, Value>,
31
+ pub chat_template_content_format: ChatTemplateContentFormatOption,
32
+ pub max_model_len: u32,
33
+ pub max_logprobs: Option<i32>,
34
+}
35
+
36
+impl RenderConfig {
37
+ /// Validate configuration before initializing renderer/tokenizer backends
38
+ /// or binding a listener.
39
+ pub fn validate(&self) -> Result<()> {
40
+ vllm_chat::validate_parser_overrides(&self.tool_call_parser, &self.reasoning_parser)?;
41
+ if self.max_logprobs.is_some_and(|value| value < -1) {
42
+ bail!("max_logprobs must be non-negative or -1");
43
+ }
44
+ Ok(())
45
+ }
46
+}
47
+
48
+pub(crate) struct RenderState {
49
+ pub(crate) model: String,
50
+ pub(crate) served_model_names: Vec<String>,
51
+ pub(crate) text: TextRequestProcessor,
52
+ pub(crate) chat: ChatRequestProcessor,
53
+}
54
+
55
+async fn build_state(config: &RenderConfig) -> Result<Arc<RenderState>> {
56
+ let loaded = load_model_backends(
57
+ &config.model,
58
+ LoadModelBackendsOptions {
59
+ renderer: config.renderer,
60
+ language_model_only: true,
61
+ chat_template: config.chat_template.clone(),
62
+ chat_template_content_format: config.chat_template_content_format,
63
+ default_chat_template_kwargs: config.default_chat_template_kwargs.clone(),
64
+ limit_mm_per_prompt: Default::default(),
65
+ },
66
+ )
67
+ .await
68
+ .context("failed to load renderer/tokenizer backends")?;
69
+ let served_model_names =
70
+ crate::effective_served_model_names(&config.model, &config.served_model_name);
71
+ let text = TextRequestProcessor::new(loaded.text_backend, config.max_model_len)
72
+ .with_max_logprobs(config.max_logprobs);
73
+ let chat = ChatRequestProcessor::render_only(loaded.chat_backend).with_parser_selections(
74
+ config.tool_call_parser.clone(),
75
+ config.reasoning_parser.clone(),
76
+ );
77
+ Ok(Arc::new(RenderState {
78
+ model: config.model.clone(),
79
+ served_model_names,
80
+ text,
81
+ chat,
82
+ }))
83
+}
84
+
85
+/// Run the text-only preprocessing server without starting or connecting
86
+/// to an inference engine.
87
+pub async fn serve_render(config: RenderConfig, shutdown: CancellationToken) -> Result<()> {
88
+ config.validate().context("invalid render server configuration")?;
89
+ let state = tokio::select! {
90
+ result = build_state(&config) => result?,
91
+ _ = shutdown.cancelled() => return Ok(()),
92
+ };
93
+ let listener_mode = HttpListenerMode::BindTcp {
94
+ host: config.host.clone(),
95
+ port: config.port,
96
+ };
97
+ let listener = Listener::bind(&listener_mode)
98
+ .await
99
+ .with_context(|| format!("failed to bind {}:{}", config.host, config.port))?;
100
+ let address = listener.local_addr_display()?;
101
+ info!(
102
+ %address,
103
+ model = %config.model,
104
+ "starting engine-free Rust render server"
105
+ );
106
+ axum::serve(listener, crate::routes::render::build_router(state))
107
+ .with_graceful_shutdown(shutdown.cancelled_owned())
108
+ .await
109
+ .context("render server failed")
110
+}
111
+
112
+#[cfg(test)]
113
+mod tests {
114
+ use super::*;
115
+
116
+ #[tokio::test]
117
+ async fn serve_render_rejects_unknown_parser_before_startup() {
118
+ let shutdown = CancellationToken::new();
119
+ shutdown.cancel();
120
+ let error = serve_render(
121
+ RenderConfig {
122
+ model: "test-model".to_string(),
123
+ served_model_name: Vec::new(),
124
+ host: "127.0.0.1".to_string(),
125
+ port: 8000,
126
+ tool_call_parser: ParserSelection::Auto,
127
+ reasoning_parser: ParserSelection::Explicit("typo".to_string()),
128
+ renderer: RendererSelection::Auto,
129
+ chat_template: None,
130
+ default_chat_template_kwargs: HashMap::new(),
131
+ chat_template_content_format: ChatTemplateContentFormatOption::Auto,
132
+ max_model_len: 128,
133
+ max_logprobs: None,
134
+ },
135
+ shutdown,
136
+ )
137
+ .await
138
+ .unwrap_err();
139
+ let report = format!("{error:#}");
140
+
141
+ assert!(
142
+ report.contains("reasoning parser `typo` is not registered"),
143
+ "{report}"
144
+ );
145
+ }
146
+}
rust/src/server/src/routes.rs
+1
@@ -12,6 +12,7 @@ mod metrics;
12
pub(crate) mod openai;
13
mod pause;
14
mod profile;
15
+pub(super) mod render;
16
mod server_info;
17
mod sleep;
18
mod tokenize;
rust/src/server/src/routes/inference/generate.rs
+4
-2
@@ -26,10 +26,12 @@ use vllm_llm::{
26
};
27
28
use self::convert::{ResponseOptions, prepare_generate_request};
29
+pub(crate) use self::types::GenerateRequest;
30
use self::types::{
30
- GenerateLogprob, GenerateRequest, GenerateResponse, GenerateResponseChoice,
31
- GenerateResponseStreamChoice, GenerateStreamResponse,
31
+ GenerateLogprob, GenerateResponse, GenerateResponseChoice, GenerateResponseStreamChoice,
32
+ GenerateStreamResponse,
33
};
34
+pub(crate) use self::validate::validate_request_compat;
35
use crate::config::ApiServerOptions;
36
use crate::error::{ApiError, bail_server_error, server_error, text_submit_error};
37
use crate::routes::openai::utils::logprobs::clamp_logprob;
rust/src/server/src/routes/inference/generate/validate.rs
+1
-1
@@ -6,7 +6,7 @@ use crate::error::{ApiError, bail_invalid_request};
6
7
/// Enforce the minimal compatibility contract for the Rust token generate
8
/// route.
9
-pub(super) fn validate_request_compat(
9
+pub(crate) fn validate_request_compat(
10
request: &GenerateRequest,
11
served_model_names: &[String],
12
) -> Result<(), ApiError> {
rust/src/server/src/routes/openai/chat_completions.rs
+14
-5
@@ -22,17 +22,18 @@ use tracing::{debug, error, info, trace};
22
use tracing_futures::Instrument as _;
23
use vllm_chat::{
24
AssistantBlockKind, AssistantMessageExt as _, ChatEvent, ChatEventStream, ChatEventStreamTrait,
25
- CollectedAssistantMessage, FinishReason,
25
+ ChatRequest, CollectedAssistantMessage, FinishReason,
26
};
27
use vllm_engine_core_client::protocol::output::StopReason;
28
29
use self::convert::{ResponseOptions, prepare_chat_request};
30
+pub(crate) use self::types::ChatCompletionRequest;
31
use crate::config::ApiServerOptions;
32
use crate::error::{ApiError, bail_server_error, chat_submit_error, server_error};
33
+use crate::lora::LoraModelResolution;
34
use crate::routes::openai::chat_completions::types::{
33
- AssistantRole, ChatCompletionChoice, ChatCompletionMessage, ChatCompletionRequest,
34
- ChatCompletionResponse, ChatCompletionStreamChoice, ChatCompletionStreamResponse,
35
- ChatMessageDelta,
35
+ AssistantRole, ChatCompletionChoice, ChatCompletionMessage, ChatCompletionResponse,
36
+ ChatCompletionStreamChoice, ChatCompletionStreamResponse, ChatMessageDelta,
37
};
38
use crate::routes::openai::utils::logprobs::{
39
decoded_logprobs_to_openai_chat, prompt_logprobs_to_maps,
@@ -43,7 +44,15 @@ use crate::routes::openai::utils::types::{
44
use crate::routes::openai::utils::usage::ContinuousUsage;
45
use crate::routes::openai::utils::validated_json::ValidatedJson;
46
use crate::state::AppState;
46
-use crate::utils::{resolve_request_context, unix_timestamp};
47
+use crate::utils::{ResolvedRequestContext, resolve_request_context, unix_timestamp};
48
+
49
+pub(crate) fn lower_chat_request(
50
+ request: ChatCompletionRequest,
51
+ lora_resolution: &LoraModelResolution,
52
+ ctx: ResolvedRequestContext,
53
+) -> Result<ChatRequest, ApiError> {
54
+ prepare_chat_request(request, lora_resolution, ctx).map(|prepared| prepared.chat_request)
55
+}
56
57
/// Validate one chat completion request and proxy it into the shared
58
/// `vllm-chat` stack.
rust/src/server/src/routes/openai/completions.rs
+17
-4
@@ -21,12 +21,14 @@ use thiserror_ext::AsReport as _;
21
use tracing::{debug, error, info, trace};
22
use tracing_futures::Instrument as _;
23
use vllm_engine_core_client::protocol::output::StopReason;
24
+use vllm_text::tokenizer::Tokenizer;
25
use vllm_text::{
26
DecodedPromptLogprobs, DecodedTextEvent, FinishReason, TextOutputStream,
26
- TextOutputStreamExt as _,
27
+ TextOutputStreamExt as _, TextRequest,
28
};
29
30
use self::convert::{ResponseOptions, prepare_completion_request};
31
+pub(crate) use self::types::CompletionRequest;
32
use super::utils::logprobs::{
33
collected_logprobs_to_openai, decoded_logprobs_to_openai, decoded_prompt_logprobs_to_openai,
34
prompt_logprobs_to_maps, text_len,
@@ -34,15 +36,26 @@ use super::utils::logprobs::{
36
use super::utils::types::Usage;
37
use crate::config::ApiServerOptions;
38
use crate::error::{ApiError, bail_server_error, server_error, text_submit_error};
39
+use crate::lora::LoraModelResolution;
40
use crate::routes::openai::completions::types::{
38
- CompletionChoice, CompletionRequest, CompletionResponse, CompletionSseChunk,
39
- CompletionStreamChoice, CompletionStreamResponse,
41
+ CompletionChoice, CompletionResponse, CompletionSseChunk, CompletionStreamChoice,
42
+ CompletionStreamResponse,
43
};
44
use crate::routes::openai::utils::types::LogProbs;
45
use crate::routes::openai::utils::usage::ContinuousUsage;
46
use crate::routes::openai::utils::validated_json::ValidatedJson;
47
use crate::state::AppState;
45
-use crate::utils::{resolve_request_context, unix_timestamp};
48
+use crate::utils::{ResolvedRequestContext, resolve_request_context, unix_timestamp};
49
+
50
+pub(crate) fn lower_completion_request(
51
+ request: CompletionRequest,
52
+ lora_resolution: &LoraModelResolution,
53
+ ctx: ResolvedRequestContext,
54
+ tokenizer: &dyn Tokenizer,
55
+) -> Result<TextRequest, ApiError> {
56
+ prepare_completion_request(request, lora_resolution, ctx, tokenizer)
57
+ .map(|prepared| prepared.text_request)
58
+}
59
60
/// Validate one completions request and proxy it into the shared `vllm-text`
61
/// stack.
rust/src/server/src/routes/openai/mod.rs
+2
@@ -7,5 +7,7 @@ mod models;
7
pub(crate) mod utils;
8
9
pub use chat_completions::chat_completions;
10
+pub(crate) use chat_completions::{ChatCompletionRequest, lower_chat_request};
11
pub use completions::completions;
12
+pub(crate) use completions::{CompletionRequest, lower_completion_request};
13
pub use models::list_models;
rust/src/server/src/routes/render.rs
new
+149
@@ -0,0 +1,149 @@
1
+// SPDX-License-Identifier: Apache-2.0
2
+// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+use std::sync::Arc;
5
+
6
+use axum::Json;
7
+use axum::Router;
8
+use axum::extract::DefaultBodyLimit;
9
+use axum::extract::State;
10
+use axum::http::{HeaderMap, StatusCode};
11
+use axum::routing::{get, post};
12
+use thiserror_ext::AsReport as _;
13
+use vllm_text::TextRequest;
14
+
15
+use crate::DEFAULT_REQUEST_BODY_LIMIT_BYTES;
16
+use crate::error::{ApiError, text_submit_error};
17
+use crate::lora::LoraModelResolution;
18
+use crate::render::RenderState;
19
+use crate::routes::inference::generate::{
20
+ GenerateRequest, validate_request_compat as validate_generate_request,
21
+};
22
+use crate::routes::openai::utils::types::{ListModelsResponse, ModelObject, StreamOptions};
23
+use crate::routes::openai::utils::validated_json::ValidatedJson;
24
+use crate::routes::openai::{
25
+ ChatCompletionRequest, CompletionRequest, lower_chat_request, lower_completion_request,
26
+};
27
+use crate::utils::{resolve_request_context, unix_timestamp};
28
+
29
+pub(crate) fn build_router(state: Arc<RenderState>) -> Router {
30
+ Router::new()
31
+ .route("/health", get(health))
32
+ .route("/ping", get(health).post(health))
33
+ .route("/v1/models", get(list_models))
34
+ .route("/v1/chat/completions/render", post(render_chat))
35
+ .route("/v1/completions/render", post(render_completion))
36
+ .with_state(state)
37
+ .layer(DefaultBodyLimit::max(DEFAULT_REQUEST_BODY_LIMIT_BYTES))
38
+}
39
+
40
+async fn health() -> StatusCode {
41
+ StatusCode::OK
42
+}
43
+
44
+async fn list_models(State(state): State<Arc<RenderState>>) -> Json<ListModelsResponse> {
45
+ let created = unix_timestamp() as i64;
46
+ Json(ListModelsResponse {
47
+ object: "list".to_string(),
48
+ data: state
49
+ .served_model_names
50
+ .iter()
51
+ .map(|model| ModelObject {
52
+ id: model.clone(),
53
+ object: "model".to_string(),
54
+ created,
55
+ owned_by: "vllm".to_string(),
56
+ root: Some(state.model.clone()),
57
+ parent: None,
58
+ max_model_len: Some(state.text.max_model_len()),
59
+ })
60
+ .collect(),
61
+ })
62
+}
63
+
64
+fn model_resolution(state: &RenderState) -> LoraModelResolution {
65
+ LoraModelResolution {
66
+ model_names: state.served_model_names.clone(),
67
+ lora_request: None,
68
+ }
69
+}
70
+
71
+fn lower_render_request(
72
+ state: &RenderState,
73
+ text_request: TextRequest,
74
+ model: String,
75
+ stream: bool,
76
+ stream_options: Option<StreamOptions>,
77
+) -> Result<GenerateRequest, ApiError> {
78
+ let prepared = state
79
+ .text
80
+ .prepare(text_request)
81
+ .map_err(|error| text_submit_error("failed to prepare render request", error))?;
82
+ let token_ids = prepared.generate_request.prompt_token_ids;
83
+ let text_request = prepared.text_request;
84
+
85
+ let request = GenerateRequest {
86
+ request_id: Some(text_request.request_id),
87
+ model: Some(model),
88
+ token_ids,
89
+ sampling_params: text_request.sampling_params,
90
+ stream,
91
+ stream_options,
92
+ cache_salt: text_request.cache_salt,
93
+ priority: text_request.priority,
94
+ kv_transfer_params: None,
95
+ ec_transfer_params: None,
96
+ other: Default::default(),
97
+ };
98
+ validate_generate_request(&request, &state.served_model_names)?;
99
+ Ok(request)
100
+}
101
+
102
+async fn render_chat(
103
+ State(state): State<Arc<RenderState>>,
104
+ headers: HeaderMap,
105
+ ValidatedJson(body): ValidatedJson<ChatCompletionRequest>,
106
+) -> Result<Json<GenerateRequest>, ApiError> {
107
+ let model = body.model.clone();
108
+ let stream = body.stream;
109
+ let stream_options = body.stream_options.clone();
110
+ let request_context = resolve_request_context(&headers, body.request_id.as_deref());
111
+ let chat_request = lower_chat_request(body, &model_resolution(&state), request_context)?;
112
+ let (text_request, _) = state
113
+ .chat
114
+ .prepare(chat_request)
115
+ .await
116
+ .map_err(|error| ApiError::invalid_request(error.to_report_string(), None))?;
117
+ Ok(Json(lower_render_request(
118
+ &state,
119
+ text_request,
120
+ model,
121
+ stream,
122
+ stream_options,
123
+ )?))
124
+}
125
+
126
+async fn render_completion(
127
+ State(state): State<Arc<RenderState>>,
128
+ headers: HeaderMap,
129
+ ValidatedJson(body): ValidatedJson<CompletionRequest>,
130
+) -> Result<Json<Vec<GenerateRequest>>, ApiError> {
131
+ let model = body.model.clone();
132
+ let stream = body.stream;
133
+ let stream_options = body.stream_options.clone();
134
+ let request_context = resolve_request_context(&headers, body.request_id.as_deref());
135
+ let tokenizer = state.text.tokenizer();
136
+ let text_request = lower_completion_request(
137
+ body,
138
+ &model_resolution(&state),
139
+ request_context,
140
+ tokenizer.as_ref(),
141
+ )?;
142
+ Ok(Json(vec![lower_render_request(
143
+ &state,
144
+ text_request,
145
+ model,
146
+ stream,
147
+ stream_options,
148
+ )?]))
149
+}
rust/src/server/src/routes/tests.rs
+211
-4
@@ -23,8 +23,8 @@ use serial_test::serial;
23
use tower::{Service as _, ServiceExt as _};
24
use vllm_chat::{
25
ChatBackend, ChatContent, ChatContentPart, ChatLlm, ChatMessage, ChatRenderer, ChatRequest,
26
- ChatTextBackend, DefaultChatOutputProcessor, DynChatOutputProcessor, DynChatRenderer,
27
- NewChatOutputProcessorOptions,
26
+ ChatRequestProcessor, ChatTextBackend, DefaultChatOutputProcessor, DynChatOutputProcessor,
27
+ DynChatRenderer, NewChatOutputProcessorOptions, ParserSelection,
28
};
29
use vllm_engine_core_client::mock_engine::default_ready_response;
30
use vllm_engine_core_client::protocol::decode_value;
@@ -46,13 +46,17 @@ use vllm_engine_core_client::{
46
use vllm_llm::Llm;
47
use vllm_metrics::METRICS;
48
use vllm_text::tokenizer::DynTokenizer;
49
-use vllm_text::{Prompt, TextBackend};
49
+use vllm_text::{Prompt, TextBackend, TextRequestProcessor};
50
use vllm_tokenizer::test_utils::TestTokenizer;
51
use zeromq::prelude::{SocketRecv, SocketSend};
52
use zeromq::{DealerSocket, PushSocket, ZmqMessage};
53
54
-use super::{build_router, build_router_with_dev_mode, build_router_with_dev_mode_and_lora};
54
+use super::{
55
+ build_router, build_router_with_dev_mode, build_router_with_dev_mode_and_lora,
56
+ render::build_router as build_render_router,
57
+};
58
use crate::config::{ApiServerOptions, CorsConfig};
59
+use crate::render::RenderState;
60
use crate::state::AppState;
61
62
fn request_output(
@@ -682,6 +686,24 @@ async fn test_app() -> axum::Router {
686
test_app_with_dev_mode(false).await
687
}
688
689
+fn test_render_app() -> axum::Router {
690
+ test_render_app_with_parser_selections(ParserSelection::Auto, ParserSelection::Auto)
691
+}
692
+
693
+fn test_render_app_with_parser_selections(
694
+ tool_call_parser: ParserSelection,
695
+ reasoning_parser: ParserSelection,
696
+) -> axum::Router {
697
+ let backend = Arc::new(FakeChatBackend::new());
698
+ build_render_router(Arc::new(RenderState {
699
+ model: "backend-model".to_string(),
700
+ served_model_names: vec!["render-model".to_string()],
701
+ text: TextRequestProcessor::new(backend.clone(), 128),
702
+ chat: ChatRequestProcessor::render_only(backend)
703
+ .with_parser_selections(tool_call_parser, reasoning_parser),
704
+ }))
705
+}
706
+
707
async fn test_app_with_dev_mode(dev_mode_enabled: bool) -> axum::Router {
708
let (chat, _engine_task) = test_models_with_engine_outputs_and_backend(
709
b"engine-openai",
@@ -1043,6 +1065,191 @@ fn metric_delta(
1065
- metric_value(rendered_before, metric, labels).unwrap_or(0.0)
1066
}
1067
1068
+#[tokio::test]
1069
+async fn render_chat_returns_generate_request_with_header_request_id() {
1070
+ let mut app = test_render_app();
1071
+ let response = app
1072
+ .call(
1073
+ Request::builder()
1074
+ .method("POST")
1075
+ .uri("/v1/chat/completions/render")
1076
+ .header("content-type", "application/json")
1077
+ .header("X-Request-Id", "header-req")
1078
+ .body(Body::from(
1079
+ json!({
1080
+ "request_id": "body-req",
1081
+ "model": "render-model",
1082
+ "messages": [{"role": "user", "content": "hello"}],
1083
+ "max_completion_tokens": 8,
1084
+ "stream": true,
1085
+ "stream_options": {"include_usage": true},
1086
+ "cache_salt": "render-cache-salt",
1087
+ "priority": -3
1088
+ })
1089
+ .to_string(),
1090
+ ))
1091
+ .expect("build request"),
1092
+ )
1093
+ .await
1094
+ .expect("call app");
1095
+
1096
+ assert_eq!(response.status(), StatusCode::OK);
1097
+ let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body");
1098
+ let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json");
1099
+
1100
+ assert_eq!(json["request_id"], "chatcmpl-header-req");
1101
+ assert_eq!(json["model"], "render-model");
1102
+ assert_eq!(json["stream"], true);
1103
+ assert_eq!(json["stream_options"]["include_usage"], true);
1104
+ assert_eq!(json["cache_salt"], "render-cache-salt");
1105
+ assert_eq!(json["priority"], -3);
1106
+ assert_eq!(json["sampling_params"]["max_tokens"], 8);
1107
+ assert!(!json["token_ids"].as_array().unwrap().is_empty());
1108
+ assert!(json.get("prompt_token_ids").is_none());
1109
+ assert!(json.get("mm_features").is_none());
1110
+}
1111
+
1112
+#[tokio::test]
1113
+async fn render_chat_uses_processor_owned_parser_selections() {
1114
+ let mut app = test_render_app_with_parser_selections(
1115
+ ParserSelection::Auto,
1116
+ ParserSelection::Explicit("definitely_missing_reasoning_parser".to_string()),
1117
+ );
1118
+ let response = app
1119
+ .call(
1120
+ Request::builder()
1121
+ .method("POST")
1122
+ .uri("/v1/chat/completions/render")
1123
+ .header("content-type", "application/json")
1124
+ .body(Body::from(
1125
+ json!({
1126
+ "model": "render-model",
1127
+ "messages": [{"role": "user", "content": "hello"}],
1128
+ "max_completion_tokens": 8
1129
+ })
1130
+ .to_string(),
1131
+ ))
1132
+ .expect("build request"),
1133
+ )
1134
+ .await
1135
+ .expect("call app");
1136
+
1137
+ assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1138
+ let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body");
1139
+ let body = String::from_utf8(body.to_vec()).expect("decode body");
1140
+ assert!(
1141
+ body.contains("reasoning parser `definitely_missing_reasoning_parser` is not registered")
1142
+ );
1143
+}
1144
+
1145
+#[tokio::test]
1146
+async fn render_completion_returns_generate_request_with_body_request_id() {
1147
+ let mut app = test_render_app();
1148
+ let response = app
1149
+ .call(
1150
+ Request::builder()
1151
+ .method("POST")
1152
+ .uri("/v1/completions/render")
1153
+ .header("content-type", "application/json")
1154
+ .body(Body::from(
1155
+ json!({
1156
+ "request_id": "body-req",
1157
+ "model": "render-model",
1158
+ "prompt": "hello",
1159
+ "max_tokens": 8
1160
+ })
1161
+ .to_string(),
1162
+ ))
1163
+ .expect("build request"),
1164
+ )
1165
+ .await
1166
+ .expect("call app");
1167
+
1168
+ assert_eq!(response.status(), StatusCode::OK);
1169
+ let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body");
1170
+ let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json");
1171
+
1172
+ assert_eq!(json[0]["request_id"], "cmpl-body-req");
1173
+ assert_eq!(json[0]["model"], "render-model");
1174
+ assert_eq!(json[0]["stream"], false);
1175
+ assert_eq!(json[0]["sampling_params"]["max_tokens"], 8);
1176
+ assert!(!json[0]["token_ids"].as_array().unwrap().is_empty());
1177
+ assert!(json[0].get("stream_options").is_none());
1178
+ assert!(json[0].get("prompt_token_ids").is_none());
1179
+ assert!(json[0].get("mm_features").is_none());
1180
+}
1181
+
1182
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1183
+#[serial]
1184
+async fn render_chat_response_can_be_submitted_to_generate_unchanged() {
1185
+ let mut render_app = test_render_app();
1186
+ let render_response = render_app
1187
+ .call(
1188
+ Request::builder()
1189
+ .method("POST")
1190
+ .uri("/v1/chat/completions/render")
1191
+ .header("content-type", "application/json")
1192
+ .body(Body::from(
1193
+ json!({
1194
+ "request_id": "round-trip",
1195
+ "model": "render-model",
1196
+ "messages": [{"role": "user", "content": "hello"}],
1197
+ "max_completion_tokens": 8,
1198
+ "bad_words": ["blocked"],
1199
+ "vllm_xargs": {"custom": 1}
1200
+ })
1201
+ .to_string(),
1202
+ ))
1203
+ .expect("build render request"),
1204
+ )
1205
+ .await
1206
+ .expect("call render app");
1207
+
1208
+ assert_eq!(render_response.status(), StatusCode::OK);
1209
+ let render_body = to_bytes(render_response.into_body(), usize::MAX)
1210
+ .await
1211
+ .expect("read render response");
1212
+ let render_json: serde_json::Value =
1213
+ serde_json::from_slice(&render_body).expect("decode render response");
1214
+ let token_ids: Vec<u32> = serde_json::from_value(render_json["token_ids"].clone())
1215
+ .expect("decode rendered token IDs");
1216
+
1217
+ assert_eq!(
1218
+ render_json["sampling_params"]["bad_words"],
1219
+ json!(["blocked"])
1220
+ );
1221
+ assert_eq!(render_json["sampling_params"]["vllm_xargs"]["custom"], 1);
1222
+
1223
+ let (chat, engine_task) = test_models_with_engine_outputs_and_backend_inner(
1224
+ b"engine-render-generate-round-trip",
1225
+ default_stream_output_specs(),
1226
+ Some(token_ids),
1227
+ Arc::new(FakeChatBackend::new()),
1228
+ )
1229
+ .await;
1230
+ let mut generate_app = build_router(Arc::new(AppState::new(
1231
+ vec!["render-model".to_string()],
1232
+ chat,
1233
+ )));
1234
+ let generate_response = generate_app
1235
+ .call(
1236
+ Request::builder()
1237
+ .method("POST")
1238
+ .uri("/inference/v1/generate")
1239
+ .header("content-type", "application/json")
1240
+ .body(Body::from(render_body))
1241
+ .expect("build generate request"),
1242
+ )
1243
+ .await
1244
+ .expect("call generate app");
1245
+
1246
+ assert_eq!(generate_response.status(), StatusCode::OK);
1247
+ let _ = to_bytes(generate_response.into_body(), usize::MAX)
1248
+ .await
1249
+ .expect("read generate response");
1250
+ engine_task.finish().await;
1251
+}
1252
+
1253
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1254
#[serial]
1255
async fn list_models_returns_configured_model() {