Skip to main content

vllm_cpp/
engine.rs

1use std::ffi::{CStr, CString};
2use std::mem::MaybeUninit;
3use std::os::raw::c_char;
4use std::path::{Path, PathBuf};
5use std::ptr::{self, NonNull};
6use std::sync::Arc;
7
8use vllm_cpp_sys as ffi;
9
10use crate::callback::{
11    callback_trampoline, CallbackState, StreamControl, StreamEvent, StreamOutcome,
12};
13use crate::error::{invalid_configuration, status_result, Error};
14use crate::params::{SamplingParams, SchedulerPolicy, Toggle};
15
16/// A cloneable vllm.cpp serving engine.
17#[derive(Clone)]
18pub struct Engine {
19    pub(crate) inner: Arc<EngineInner>,
20}
21
22pub(crate) struct EngineInner {
23    pub(crate) raw: NonNull<ffi::vllm_engine>,
24}
25
26impl std::fmt::Debug for Engine {
27    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        formatter
29            .debug_struct("Engine")
30            .field("raw", &self.inner.raw)
31            .finish_non_exhaustive()
32    }
33}
34
35/// Builder for one complete serving engine.
36#[derive(Clone, Debug)]
37pub struct EngineBuilder {
38    model_path: PathBuf,
39    tokenizer_config_path: Option<PathBuf>,
40    block_size: Option<u32>,
41    num_blocks: Option<u32>,
42    max_model_len: Option<u32>,
43    max_num_seqs: Option<u32>,
44    tool_parser: Option<String>,
45    reasoning_parser: Option<String>,
46    speculative_config: Option<String>,
47    prefix_caching: Toggle,
48    max_num_batched_tokens: Option<u32>,
49    scheduler: SchedulerPolicy,
50    kv_transfer_config: Option<String>,
51    jump_forward: Toggle,
52}
53
54/// Why native generation finished.
55#[derive(Clone, Debug, Eq, PartialEq)]
56#[non_exhaustive]
57pub enum FinishReason {
58    Stop,
59    Length,
60    Abort,
61    Error,
62    Repetition,
63    Unknown,
64    Other(String),
65}
66
67/// A Rust-owned blocking completion result.
68#[derive(Clone, Debug, Eq, PartialEq)]
69pub struct Completion {
70    pub text: String,
71    pub finish_reason: Option<FinishReason>,
72    pub prompt_tokens: u32,
73    pub completion_tokens: u32,
74}
75
76impl Engine {
77    /// Starts configuring an engine for a model directory or GGUF file.
78    pub fn builder(model_path: impl Into<PathBuf>) -> EngineBuilder {
79        EngineBuilder::new(model_path)
80    }
81
82    /// Loads an engine using native defaults.
83    pub fn load(model_path: impl Into<PathBuf>) -> Result<Self, Error> {
84        Self::builder(model_path).load()
85    }
86
87    /// Runs one blocking text completion.
88    pub fn complete(&self, prompt: &str, params: &SamplingParams) -> Result<Completion, Error> {
89        let prompt = to_cstring(prompt, "prompt")?;
90        let params = params.marshal()?;
91        let mut raw = MaybeUninit::<ffi::vllm_completion>::uninit();
92        // SAFETY: the engine is owned and live, all pointers remain valid for the
93        // call, and out storage is initialized by native code on success.
94        let status = unsafe {
95            ffi::vllm_complete(
96                self.inner.raw.as_ptr(),
97                prompt.as_ptr(),
98                params.raw(),
99                raw.as_mut_ptr(),
100            )
101        };
102        if status != ffi::vllm_status_VLLM_OK {
103            if let Some(error) = params.logits_processor_error() {
104                return Err(error);
105            }
106            status_result(status)?;
107            unreachable!("non-OK native status unexpectedly succeeded");
108        }
109        // SAFETY: VLLM_OK initializes every completion field.
110        let raw = unsafe { raw.assume_init() };
111        let guard = CompletionGuard(raw);
112        if let Some(error) = params.logits_processor_error() {
113            return Err(error);
114        }
115        completion_from_raw(&guard.0)
116    }
117
118    /// Runs one blocking streaming text completion.
119    ///
120    /// A callback panic is resumed only after native code has aborted the request
121    /// and returned across the FFI boundary.
122    pub fn complete_stream<F>(
123        &self,
124        prompt: &str,
125        params: &SamplingParams,
126        mut callback: F,
127    ) -> Result<StreamOutcome, Error>
128    where
129        F: FnMut(StreamEvent) -> StreamControl,
130    {
131        let prompt = to_cstring(prompt, "prompt")?;
132        let params = params.marshal()?;
133        let mut state = CallbackState::new(&mut callback);
134        // SAFETY: state has a stable stack address for this blocking call; the C
135        // API does not retain user_data after returning.
136        let status = unsafe {
137            ffi::vllm_complete_stream(
138                self.inner.raw.as_ptr(),
139                prompt.as_ptr(),
140                params.raw(),
141                Some(callback_trampoline::<F>),
142                ptr::from_mut(&mut state).cast(),
143            )
144        };
145        if let Some(payload) = state.take_panic() {
146            std::panic::resume_unwind(payload);
147        }
148        if let Some(error) = state.take_error() {
149            return Err(error);
150        }
151        if let Some(error) = params.logits_processor_error() {
152            return Err(error);
153        }
154        status_result(status)?;
155        Ok(StreamOutcome {
156            stopped_by_callback: state.stopped(),
157        })
158    }
159
160    /// Runs one blocking OpenAI-style chat request and returns response JSON.
161    pub fn chat_json(&self, request_json: &str) -> Result<String, Error> {
162        let request = to_cstring(request_json, "chat request JSON")?;
163        let mut output: *mut c_char = ptr::null_mut();
164        // SAFETY: the engine and request pointers are valid for the call and the
165        // returned string is released by NativeStringGuard.
166        let status =
167            unsafe { ffi::vllm_chat(self.inner.raw.as_ptr(), request.as_ptr(), &mut output) };
168        status_result(status)?;
169        let output = NonNull::new(output).ok_or_else(|| Error::Runtime {
170            message: "vllm_chat succeeded without a response".to_owned(),
171        })?;
172        let guard = NativeStringGuard(output);
173        c_string_to_owned(guard.0.as_ptr(), "chat response")
174    }
175
176    /// Runs one blocking OpenAI-style streaming chat request.
177    pub fn chat_stream_json<F>(
178        &self,
179        request_json: &str,
180        mut callback: F,
181    ) -> Result<StreamOutcome, Error>
182    where
183        F: FnMut(StreamEvent) -> StreamControl,
184    {
185        let request = to_cstring(request_json, "chat request JSON")?;
186        let mut state = CallbackState::new(&mut callback);
187        // SAFETY: state remains valid for this blocking call and native code does
188        // not retain it after returning.
189        let status = unsafe {
190            ffi::vllm_chat_stream(
191                self.inner.raw.as_ptr(),
192                request.as_ptr(),
193                Some(callback_trampoline::<F>),
194                ptr::from_mut(&mut state).cast(),
195            )
196        };
197        if let Some(payload) = state.take_panic() {
198            std::panic::resume_unwind(payload);
199        }
200        if let Some(error) = state.take_error() {
201            return Err(error);
202        }
203        status_result(status)?;
204        Ok(StreamOutcome {
205            stopped_by_callback: state.stopped(),
206        })
207    }
208
209    #[cfg(feature = "serde")]
210    pub fn chat(&self, request: &serde_json::Value) -> Result<serde_json::Value, Error> {
211        let request_json = serde_json::to_string(request).map_err(|error| Error::Json {
212            context: "failed to serialize chat request",
213            message: error.to_string(),
214        })?;
215        let response = self.chat_json(&request_json)?;
216        serde_json::from_str(&response).map_err(|error| Error::Json {
217            context: "failed to parse chat response",
218            message: error.to_string(),
219        })
220    }
221}
222
223impl Drop for EngineInner {
224    fn drop(&mut self) {
225        // SAFETY: EngineInner exclusively owns this live handle. Native teardown
226        // joins engine workers before returning.
227        unsafe { ffi::vllm_engine_free(self.raw.as_ptr()) };
228    }
229}
230
231// SAFETY: vllm.cpp documents concurrent completion submissions as thread-safe,
232// and EngineInner keeps the engine alive until the last shared owner is dropped.
233unsafe impl Send for EngineInner {}
234// SAFETY: shared references may submit concurrently through native AsyncLLM;
235// destruction cannot race because Arc retains the handle for each active owner.
236unsafe impl Sync for EngineInner {}
237
238impl EngineBuilder {
239    #[must_use]
240    pub fn new(model_path: impl Into<PathBuf>) -> Self {
241        Self {
242            model_path: model_path.into(),
243            tokenizer_config_path: None,
244            block_size: None,
245            num_blocks: None,
246            max_model_len: None,
247            max_num_seqs: None,
248            tool_parser: None,
249            reasoning_parser: None,
250            speculative_config: None,
251            prefix_caching: Toggle::Default,
252            max_num_batched_tokens: None,
253            scheduler: SchedulerPolicy::Fcfs,
254            kv_transfer_config: None,
255            jump_forward: Toggle::Default,
256        }
257    }
258
259    #[must_use]
260    pub fn tokenizer_config_path(mut self, value: impl Into<PathBuf>) -> Self {
261        self.tokenizer_config_path = Some(value.into());
262        self
263    }
264
265    #[must_use]
266    pub fn block_size(mut self, value: u32) -> Self {
267        self.block_size = Some(value);
268        self
269    }
270
271    #[must_use]
272    pub fn num_blocks(mut self, value: u32) -> Self {
273        self.num_blocks = Some(value);
274        self
275    }
276
277    #[must_use]
278    pub fn max_model_len(mut self, value: u32) -> Self {
279        self.max_model_len = Some(value);
280        self
281    }
282
283    #[must_use]
284    pub fn max_num_seqs(mut self, value: u32) -> Self {
285        self.max_num_seqs = Some(value);
286        self
287    }
288
289    #[must_use]
290    pub fn tool_parser(mut self, value: impl Into<String>) -> Self {
291        self.tool_parser = Some(value.into());
292        self
293    }
294
295    #[must_use]
296    pub fn reasoning_parser(mut self, value: impl Into<String>) -> Self {
297        self.reasoning_parser = Some(value.into());
298        self
299    }
300
301    #[must_use]
302    pub fn speculative_config(mut self, value: impl Into<String>) -> Self {
303        self.speculative_config = Some(value.into());
304        self
305    }
306
307    #[must_use]
308    pub fn prefix_caching(mut self, value: Toggle) -> Self {
309        self.prefix_caching = value;
310        self
311    }
312
313    #[must_use]
314    pub fn max_num_batched_tokens(mut self, value: u32) -> Self {
315        self.max_num_batched_tokens = Some(value);
316        self
317    }
318
319    /// Selects the native admission queue policy.
320    ///
321    /// Raw and serde chat request JSON can carry a `priority` field that the native
322    /// OpenAI-compatible path parses and submits. Direct completion, completion
323    /// streaming, and `Request` submissions currently default to priority zero and
324    /// tie by arrival; caller-selected priorities for those direct APIs require a
325    /// future C ABI/API change.
326    #[must_use]
327    pub fn scheduler(mut self, value: SchedulerPolicy) -> Self {
328        self.scheduler = value;
329        self
330    }
331
332    #[must_use]
333    pub fn kv_transfer_config(mut self, value: impl Into<String>) -> Self {
334        self.kv_transfer_config = Some(value.into());
335        self
336    }
337
338    #[must_use]
339    pub fn jump_forward(mut self, value: Toggle) -> Self {
340        self.jump_forward = value;
341        self
342    }
343
344    pub fn load(self) -> Result<Engine, Error> {
345        ensure_abi()?;
346        let model_path = path_to_cstring(&self.model_path, "model path")?;
347        let tokenizer_config_path = self
348            .tokenizer_config_path
349            .as_deref()
350            .map(|path| path_to_cstring(path, "tokenizer config path"))
351            .transpose()?;
352        let tool_parser = optional_cstring(self.tool_parser.as_deref(), "tool parser")?;
353        let reasoning_parser =
354            optional_cstring(self.reasoning_parser.as_deref(), "reasoning parser")?;
355        let speculative_config = optional_cstring(
356            self.speculative_config.as_deref(),
357            "speculative configuration",
358        )?;
359        let scheduling_policy = to_cstring(self.scheduler.as_str(), "scheduler policy")?;
360        let kv_transfer_config = optional_cstring(
361            self.kv_transfer_config.as_deref(),
362            "KV transfer configuration",
363        )?;
364
365        // ABI equality is checked before this struct-returning native call.
366        let mut raw = unsafe { ffi::vllm_model_params_default() };
367        raw.model_path = model_path.as_ptr();
368        raw.tokenizer_config_path = optional_pointer(tokenizer_config_path.as_ref());
369        raw.block_size = optional_u32_to_i32(self.block_size, "block_size")?;
370        raw.num_blocks = optional_u32_to_i32(self.num_blocks, "num_blocks")?;
371        raw.max_model_len = optional_u32_to_i32(self.max_model_len, "max_model_len")?;
372        raw.max_num_seqs = optional_u32_to_i32(self.max_num_seqs, "max_num_seqs")?;
373        raw.tool_parser = optional_pointer(tool_parser.as_ref());
374        raw.reasoning_parser = optional_pointer(reasoning_parser.as_ref());
375        raw.speculative_config = optional_pointer(speculative_config.as_ref());
376        raw.enable_prefix_caching = self.prefix_caching.as_native();
377        raw.max_num_batched_tokens =
378            optional_u32_to_i32(self.max_num_batched_tokens, "max_num_batched_tokens")?;
379        raw.scheduling_policy = scheduling_policy.as_ptr();
380        raw.kv_transfer_config = optional_pointer(kv_transfer_config.as_ref());
381        raw.enable_jump_forward = self.jump_forward.as_native();
382
383        let mut output = ptr::null_mut();
384        // SAFETY: all string storage remains live for the call and output points
385        // to writable handle storage.
386        let status = unsafe { ffi::vllm_engine_load(&raw, &mut output) };
387        status_result(status)?;
388        let raw = NonNull::new(output).ok_or_else(|| Error::ModelLoad {
389            message: "vllm_engine_load succeeded without a handle".to_owned(),
390        })?;
391        Ok(Engine {
392            inner: Arc::new(EngineInner { raw }),
393        })
394    }
395}
396
397fn ensure_abi() -> Result<(), Error> {
398    // SAFETY: this base ABI function takes no pointers or versioned structs.
399    let actual = unsafe { ffi::vllm_abi_version() };
400    let expected = ffi::VLLM_ABI_VERSION as i32;
401    if actual == expected {
402        Ok(())
403    } else {
404        Err(Error::AbiMismatch { expected, actual })
405    }
406}
407
408fn completion_from_raw(raw: &ffi::vllm_completion) -> Result<Completion, Error> {
409    if raw.text.is_null() {
410        return Err(Error::Runtime {
411            message: "vllm_complete succeeded without text".to_owned(),
412        });
413    }
414    let text = c_string_to_owned(raw.text, "completion text")?;
415    let finish_reason = if raw.finish_reason.is_null() {
416        None
417    } else {
418        Some(parse_finish_reason(c_string_to_owned(
419            raw.finish_reason,
420            "finish reason",
421        )?))
422    };
423    Ok(Completion {
424        text,
425        finish_reason,
426        prompt_tokens: count_to_u32(raw.prompt_tokens, "prompt token count")?,
427        completion_tokens: count_to_u32(raw.completion_tokens, "completion token count")?,
428    })
429}
430
431fn parse_finish_reason(value: String) -> FinishReason {
432    match value.as_str() {
433        "stop" => FinishReason::Stop,
434        "length" => FinishReason::Length,
435        "abort" => FinishReason::Abort,
436        "error" => FinishReason::Error,
437        "repetition" => FinishReason::Repetition,
438        "unknown" => FinishReason::Unknown,
439        _ => FinishReason::Other(value),
440    }
441}
442
443fn count_to_u32(value: i32, field: &'static str) -> Result<u32, Error> {
444    u32::try_from(value).map_err(|_| invalid_configuration(format!("native {field} was negative")))
445}
446
447fn optional_u32_to_i32(value: Option<u32>, field: &'static str) -> Result<i32, Error> {
448    match value {
449        Some(0) | None => Ok(0),
450        Some(value) => i32::try_from(value)
451            .map_err(|_| invalid_configuration(format!("{field} exceeds native i32 range"))),
452    }
453}
454
455fn optional_cstring(value: Option<&str>, field: &'static str) -> Result<Option<CString>, Error> {
456    value.map(|value| to_cstring(value, field)).transpose()
457}
458
459fn optional_pointer(value: Option<&CString>) -> *const c_char {
460    value.map_or(ptr::null(), |value| value.as_ptr())
461}
462
463fn to_cstring(value: &str, field: &'static str) -> Result<CString, Error> {
464    CString::new(value).map_err(|_| Error::InteriorNul { field })
465}
466
467#[cfg(unix)]
468fn path_to_cstring(path: &Path, field: &'static str) -> Result<CString, Error> {
469    use std::os::unix::ffi::OsStrExt;
470
471    CString::new(path.as_os_str().as_bytes()).map_err(|_| Error::InteriorNul { field })
472}
473
474#[cfg(not(unix))]
475fn path_to_cstring(path: &Path, field: &'static str) -> Result<CString, Error> {
476    path.to_str()
477        .ok_or(Error::PathEncoding)
478        .and_then(|value| to_cstring(value, field))
479}
480
481fn c_string_to_owned(pointer: *const c_char, field: &'static str) -> Result<String, Error> {
482    if pointer.is_null() {
483        return Err(Error::InvalidUtf8 { field });
484    }
485    // SAFETY: callers pass a live native NUL-terminated string.
486    unsafe { CStr::from_ptr(pointer) }
487        .to_str()
488        .map(str::to_owned)
489        .map_err(|_| Error::InvalidUtf8 { field })
490}
491
492struct CompletionGuard(ffi::vllm_completion);
493
494impl Drop for CompletionGuard {
495    fn drop(&mut self) {
496        // SAFETY: the native function initialized this completion and the guard
497        // releases its owned members exactly once.
498        unsafe { ffi::vllm_completion_free(&mut self.0) };
499    }
500}
501
502struct NativeStringGuard(NonNull<c_char>);
503
504impl Drop for NativeStringGuard {
505    fn drop(&mut self) {
506        // SAFETY: vllm_chat allocated this string and this guard owns it once.
507        unsafe { ffi::vllm_string_free(self.0.as_ptr()) };
508    }
509}