[Rust Frontend] Strongly type wire dtypes and multimodal modalities (#56174)
Co-authored-by: Codex <noreply@openai.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com>
Bugen Zhao committed
Sep 10, 2026 at 12:11 UTC
41cffa9af0b7317d71838262004186b68e976773
15 files changed
+453
-270
rust/src/chat/src/multimodal.rs
+20
-41
@@ -60,30 +60,9 @@ pub struct MultimodalModelInfo {
60
/// Per-modality item-count limits configured by `--limit-mm-per-prompt`.
61
///
62
/// Modalities absent from the map are unlimited.
63
-pub type MmLimitPerPrompt = HashMap<MmLimitModality, MmLimitSpec>;
63
+pub type MmLimitPerPrompt = HashMap<MmModality, MmLimitSpec>;
64
65
-/// Modalities that `--limit-mm-per-prompt` can be keyed by.
66
-///
67
-/// Closed on purpose: these are exactly the keys Python accepts, per
68
-/// `MultiModalDummyOptionsBuiltins` in `vllm/config/multimodal.py`.
69
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
70
-#[serde(rename_all = "snake_case")]
71
-pub enum MmLimitModality {
72
- Image,
73
- Audio,
74
- Video,
75
-}
76
-
77
-impl MmLimitModality {
78
- /// The wire name, matching Python's modality strings.
79
- pub fn as_str(self) -> &'static str {
80
- match self {
81
- Self::Image => "image",
82
- Self::Audio => "audio",
83
- Self::Video => "video",
84
- }
85
- }
86
-}
65
+pub use vllm_engine_core_client::protocol::multimodal::MmModality;
66
67
/// One modality's limit, in either of the two shapes Python accepts.
68
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -646,17 +625,17 @@ fn input_audio_data_url(data: &str, format: Option<&str>) -> Result<String> {
625
/// Embedding inputs share their base modality's budget rather than getting one
626
/// of their own, matching Python's `modality.replace("_embeds", "")` in
627
/// `vllm/entrypoints/chat_utils.py`.
649
-fn media_part_limit_modality(part: &MediaContentPart) -> Option<MmLimitModality> {
628
+fn media_part_limit_modality(part: &MediaContentPart) -> Option<MmModality> {
629
match part {
630
MediaContentPart::Text { .. } => None,
631
MediaContentPart::ImageUrl { .. }
632
| MediaContentPart::ImageData { .. }
654
- | MediaContentPart::ImageEmbeds { .. } => Some(MmLimitModality::Image),
633
+ | MediaContentPart::ImageEmbeds { .. } => Some(MmModality::Image),
634
MediaContentPart::AudioUrl { .. } | MediaContentPart::AudioData { .. } => {
656
- Some(MmLimitModality::Audio)
635
+ Some(MmModality::Audio)
636
}
637
MediaContentPart::VideoUrl { .. } | MediaContentPart::VideoData { .. } => {
659
- Some(MmLimitModality::Video)
638
+ Some(MmModality::Video)
639
}
640
}
641
}
@@ -667,7 +646,7 @@ impl MultimodalModelInfo {
646
///
647
/// Modalities without a configured count are unlimited.
648
fn validate_mm_limits(&self, media_parts: &[MediaContentPart]) -> Result<()> {
670
- let mut counts: HashMap<MmLimitModality, usize> = HashMap::new();
649
+ let mut counts: HashMap<MmModality, usize> = HashMap::new();
650
for part in media_parts {
651
if let Some(modality) = media_part_limit_modality(part) {
652
*counts.entry(modality).or_default() += 1;
@@ -738,7 +717,11 @@ impl MultimodalModelInfo {
717
for (item, range) in izip!(media.items, media_ranges) {
718
features.push(MmFeatureSpec {
719
data: Some(item.data),
741
- modality: media.modality.to_string(),
720
+ modality: match media.modality {
721
+ Modality::Image | Modality::ImageEmbeds => MmModality::Image,
722
+ Modality::Video => MmModality::Video,
723
+ Modality::Audio => MmModality::Audio,
724
+ },
725
identifier: item.uuid.unwrap_or_else(|| item.hash.clone()),
726
mm_position: range,
727
mm_hash: Some(item.hash),
@@ -1029,10 +1012,8 @@ mod tests {
1012
1013
#[test]
1014
fn validate_mm_limits_enforces_configured_limit_at_the_boundary() {
1032
- let info = qwen3_vl_info_with_limits(HashMap::from([(
1033
- MmLimitModality::Image,
1034
- MmLimitSpec::Count(1),
1035
- )]));
1015
+ let info =
1016
+ qwen3_vl_info_with_limits(HashMap::from([(MmModality::Image, MmLimitSpec::Count(1))]));
1017
assert!(info.validate_mm_limits(&[image_url_part()]).is_ok());
1018
1019
let error = info.validate_mm_limits(&[image_url_part(), image_url_part()]).unwrap_err();
@@ -1047,10 +1028,8 @@ mod tests {
1028
1029
#[test]
1030
fn validate_mm_limits_counts_image_embeds_against_the_image_limit() {
1050
- let info = qwen3_vl_info_with_limits(HashMap::from([(
1051
- MmLimitModality::Image,
1052
- MmLimitSpec::Count(1),
1053
- )]));
1031
+ let info =
1032
+ qwen3_vl_info_with_limits(HashMap::from([(MmModality::Image, MmLimitSpec::Count(1))]));
1033
let image_embeds_part = MediaContentPart::ImageEmbeds {
1034
payload: serde_json::Value::String("AAAA".to_string()),
1035
uuid: None,
@@ -1068,7 +1047,7 @@ mod tests {
1047
#[test]
1048
fn validate_mm_limits_treats_a_count_less_options_object_as_unlimited() {
1049
let info = qwen3_vl_info_with_limits(HashMap::from([(
1071
- MmLimitModality::Image,
1050
+ MmModality::Image,
1051
MmLimitSpec::Options {
1052
count: None,
1053
extra: BTreeMap::from([("width".to_string(), serde_json::json!(512))]),
@@ -1086,9 +1065,9 @@ mod tests {
1065
fn limit_map_parses_both_shapes_python_accepts() {
1066
let limits = parse_limits(r#"{"image": 16, "video": {"count": 1, "num_frames": 32}}"#);
1067
1089
- assert_eq!(limits[&MmLimitModality::Image].count(), Some(16));
1090
- assert_eq!(limits[&MmLimitModality::Video].count(), Some(1));
1091
- assert_eq!(limits.get(&MmLimitModality::Audio), None);
1068
+ assert_eq!(limits[&MmModality::Image].count(), Some(16));
1069
+ assert_eq!(limits[&MmModality::Video].count(), Some(1));
1070
+ assert_eq!(limits.get(&MmModality::Audio), None);
1071
}
1072
1073
#[test]
rust/src/chat/src/multimodal/audio.rs
+4
-4
@@ -268,14 +268,14 @@ mod tests {
268
assert!(matches!(
269
features.data.as_ref(),
270
Some(MmKwargValue::Tensor(tensor))
271
- if tensor.dtype == "float32" && tensor.shape.first() == Some(&128)
271
+ if tensor.dtype.as_str() == "float32" && tensor.shape.first() == Some(&128)
272
));
273
let lengths = &item.data["audio_feature_lengths"];
274
assert!(matches!(&lengths.field, MmField::Batched(_)));
275
assert!(matches!(
276
lengths.data.as_ref(),
277
Some(MmKwargValue::Tensor(tensor))
278
- if tensor.dtype == "int64" && tensor.shape.is_empty()
278
+ if tensor.dtype.as_str() == "int64" && tensor.shape.is_empty()
279
));
280
}
281
@@ -314,14 +314,14 @@ mod tests {
314
assert!(matches!(
315
features.data.as_ref(),
316
Some(MmKwargValue::Tensor(tensor))
317
- if tensor.dtype == "float32" && tensor.shape.get(1) == Some(&80)
317
+ if tensor.dtype.as_str() == "float32" && tensor.shape.get(1) == Some(&80)
318
));
319
let count = &item.data["num_audio_tokens"];
320
assert!(matches!(&count.field, MmField::Batched(_)));
321
assert!(matches!(
322
count.data.as_ref(),
323
Some(MmKwargValue::Tensor(tensor))
324
- if tensor.dtype == "int64" && tensor.shape.is_empty()
324
+ if tensor.dtype.as_str() == "int64" && tensor.shape.is_empty()
325
));
326
}
327
}
rust/src/chat/src/multimodal/expand.rs
+1
-1
@@ -228,7 +228,7 @@ mod tests {
228
229
fn assert_bool_mask(range: &PlaceholderRange, expected: &[bool]) {
230
let tensor = range.is_embed.as_ref().expect("is_embed mask");
231
- assert_eq!(tensor.dtype, "bool");
231
+ assert_eq!(tensor.dtype.as_str(), "bool");
232
assert_eq!(tensor.shape, vec![expected.len()]);
233
assert_eq!(
234
tensor.data,
rust/src/chat/src/multimodal/tensor.rs
+46
-115
@@ -7,54 +7,17 @@ use std::mem::size_of;
7
use half::{bf16, f16};
8
use llm_multimodal::{ModelSpecificValue, PreprocessedEncoderInputs};
9
use ndarray::ArrayD;
10
-use vllm_engine_core_client::protocol::dtype::ModelDtype;
10
+use vllm_engine_core_client::protocol::dtype::{ModelDtype, TensorDtype};
11
use vllm_engine_core_client::protocol::multimodal::MmKwargValue as ProtocolKwargValue;
12
-use vllm_engine_core_client::protocol::tensor::{ShapeExt as _, WireArrayData, WireTensor};
12
+use vllm_engine_core_client::protocol::tensor::{WireArrayData, WireTensor};
13
14
use crate::error::{Error, Result, bail_multimodal, multimodal};
15
16
-/// Element type retained alongside an encoded tensor during multimodal lowering.
17
-#[derive(Debug, Clone, Copy)]
18
-pub(super) enum TensorKind {
19
- /// 32-bit floating point.
20
- F32,
21
- /// IEEE 16-bit floating point.
22
- F16,
23
- /// Brain floating point.
24
- Bf16,
25
- /// Signed 64-bit integer.
26
- I64,
27
- /// Unsigned 32-bit integer.
28
- U32,
29
-}
30
-
31
-impl TensorKind {
32
- const fn element_size(self) -> usize {
33
- match self {
34
- Self::F32 => size_of::<f32>(),
35
- Self::F16 => size_of::<f16>(),
36
- Self::Bf16 => size_of::<bf16>(),
37
- Self::I64 => size_of::<i64>(),
38
- Self::U32 => size_of::<u32>(),
39
- }
40
- }
41
-
42
- const fn wire_dtype(self) -> &'static str {
43
- match self {
44
- Self::F32 => "float32",
45
- Self::F16 => "float16",
46
- Self::Bf16 => "bfloat16",
47
- Self::I64 => "int64",
48
- Self::U32 => "uint32",
49
- }
50
- }
51
-}
52
-
16
/// Representation for multimodal kwarg values for transformation.
17
#[derive(Debug)]
18
pub(super) enum KwargValue {
19
/// Tensor with row-major flat data and shape.
57
- Tensor { kind: TensorKind, wire: WireTensor },
20
+ Tensor { wire: WireTensor },
21
/// Non-tensor kwarg value that is shared or copied as-is.
22
Passthrough(ProtocolKwargValue),
23
}
@@ -96,11 +59,11 @@ impl KwargValue {
59
}
60
ModelSpecificValue::IntTensor { data, shape } => {
61
let wire = WireTensor::from_i64(shape, data).map_err(Error::Multimodal)?;
99
- Self::tensor(TensorKind::I64, wire)
62
+ Self::Tensor { wire }
63
}
64
ModelSpecificValue::UintTensor { data, shape } => {
65
let wire = WireTensor::from_u32(shape, data).map_err(Error::Multimodal)?;
103
- Self::tensor(TensorKind::U32, wire)
66
+ Self::Tensor { wire }
67
}
68
ModelSpecificValue::Int(value) => Self::Passthrough(Int(value)),
69
ModelSpecificValue::Float(value) => Self::Passthrough(Float(value)),
@@ -148,23 +111,16 @@ impl KwargValue {
111
/// Convert a float tensor to the target float dtype if needed, keeping the
112
/// same shape.
113
fn from_f32_parts(data: Vec<f32>, shape: Vec<usize>, float_dtype: ModelDtype) -> Result<Self> {
151
- let (kind, wire) = match float_dtype {
152
- ModelDtype::Float16 => (
153
- TensorKind::F16,
154
- WireTensor::from_f16(shape, data.into_iter().map(f16::from_f32).collect()),
155
- ),
156
- ModelDtype::BFloat16 => (
157
- TensorKind::Bf16,
158
- WireTensor::from_bf16(shape, data.into_iter().map(bf16::from_f32).collect()),
159
- ),
160
- ModelDtype::Float32 => (TensorKind::F32, WireTensor::from_f32(shape, data)),
114
+ let wire = match float_dtype {
115
+ ModelDtype::Float16 => {
116
+ WireTensor::from_f16(shape, data.into_iter().map(f16::from_f32).collect())
117
+ }
118
+ ModelDtype::BFloat16 => {
119
+ WireTensor::from_bf16(shape, data.into_iter().map(bf16::from_f32).collect())
120
+ }
121
+ ModelDtype::Float32 => WireTensor::from_f32(shape, data),
122
};
162
- wire.map(|wire| Self::tensor(kind, wire)).map_err(Error::Multimodal)
163
- }
164
-
165
- fn tensor(kind: TensorKind, wire: WireTensor) -> Self {
166
- debug_assert_eq!(wire.dtype, kind.wire_dtype());
167
- Self::Tensor { kind, wire }
123
+ wire.map(|wire| Self::Tensor { wire }).map_err(Error::Multimodal)
124
}
125
}
126
@@ -215,9 +171,7 @@ impl KwargValue {
171
drop_axis: bool,
172
) -> Result<ProtocolKwargValue> {
173
let wire = match self {
218
- Self::Tensor { kind, wire } => {
219
- slice_first_axis_range(wire, kind.element_size(), start, end, drop_axis)
220
- }
174
+ Self::Tensor { wire } => slice_first_axis_range(wire, start, end, drop_axis),
175
Self::Passthrough(value) => return Ok(value.clone()),
176
};
177
wire.map(ProtocolKwargValue::Tensor)
@@ -244,10 +198,7 @@ pub(super) fn flat_range_for_index(
198
/// Read a tensor value as per-image sizes for flat slicing.
199
fn tensor_as_usize_vec(tensor: &KwargValue) -> Result<Vec<usize>> {
200
match tensor {
247
- KwargValue::Tensor {
248
- kind: TensorKind::I64,
249
- wire,
250
- } => raw_tensor_bytes(wire, size_of::<i64>())?
201
+ KwargValue::Tensor { wire } if wire.dtype == TensorDtype::I64 => raw_tensor_bytes(wire)?
202
.chunks_exact(size_of::<i64>())
203
.map(|bytes| i64::from_ne_bytes(bytes.try_into().expect("exact int64 chunk")))
204
.map(|value| {
@@ -255,13 +206,14 @@ fn tensor_as_usize_vec(tensor: &KwargValue) -> Result<Vec<usize>> {
206
.map_err(|_| multimodal!("negative flat tensor size `{value}`"))
207
})
208
.collect(),
258
- KwargValue::Tensor {
259
- kind: TensorKind::U32,
260
- wire,
261
- } => Ok(raw_tensor_bytes(wire, size_of::<u32>())?
262
- .chunks_exact(size_of::<u32>())
263
- .map(|bytes| u32::from_ne_bytes(bytes.try_into().expect("exact uint32 chunk")) as usize)
264
- .collect()),
209
+ KwargValue::Tensor { wire } if wire.dtype == TensorDtype::U32 => {
210
+ Ok(raw_tensor_bytes(wire)?
211
+ .chunks_exact(size_of::<u32>())
212
+ .map(|bytes| {
213
+ u32::from_ne_bytes(bytes.try_into().expect("exact uint32 chunk")) as usize
214
+ })
215
+ .collect())
216
+ }
217
_ => Err(multimodal!("flat tensor sizes must be int64 or uint32")),
218
}
219
}
@@ -269,13 +221,13 @@ fn tensor_as_usize_vec(tensor: &KwargValue) -> Result<Vec<usize>> {
221
/// Slice a flat row-major tensor along its first axis.
222
fn slice_first_axis_range(
223
tensor: &WireTensor,
272
- element_size: usize,
224
start: usize,
225
end: usize,
226
drop_axis: bool,
227
) -> Result<WireTensor> {
228
let shape = tensor.shape.as_slice();
278
- raw_tensor_bytes(tensor, element_size)?;
229
+ raw_tensor_bytes(tensor)?;
230
+ let element_size = tensor.dtype.element_size();
231
let first_dim = *shape.first().ok_or_else(|| multimodal!("tensor has no first dimension"))?;
232
if start > end || end > first_dim {
233
bail_multimodal!("invalid tensor slice {start}..{end} for first dimension {first_dim}");
@@ -302,34 +254,15 @@ fn slice_first_axis_range(
254
return Err(multimodal!("cannot slice an aux tensor buffer"));
255
};
256
Ok(WireTensor::from_raw_bytes(
305
- tensor.dtype.clone(),
257
+ tensor.dtype,
258
out_shape,
259
data.slice(data_start..data_end),
260
))
261
}
262
311
-fn raw_tensor_bytes(tensor: &WireTensor, element_size: usize) -> Result<&[u8]> {
312
- let WireArrayData::RawView(data) = &tensor.data else {
313
- return Err(multimodal!("expected an inline tensor buffer"));
314
- };
315
- let expected_bytes = tensor
316
- .shape
317
- .checked_numel()
318
- .and_then(|numel| numel.checked_mul(element_size))
319
- .ok_or_else(|| {
320
- multimodal!(
321
- "tensor shape {:?} byte length overflowed usize",
322
- tensor.shape
323
- )
324
- })?;
325
- if expected_bytes != data.len() {
326
- bail_multimodal!(
327
- "tensor shape {:?} expects {expected_bytes} bytes, got {}",
328
- tensor.shape,
329
- data.len()
330
- );
331
- }
332
- Ok(data)
263
+fn raw_tensor_bytes(tensor: &WireTensor) -> Result<&[u8]> {
264
+ tensor.validate_inline().map_err(Error::Multimodal)?;
265
+ Ok(tensor.data.as_raw_view().expect("validated inline tensor"))
266
}
267
268
#[cfg(test)]
@@ -373,7 +306,7 @@ mod tests {
306
};
307
308
assert_eq!(tensor.shape, shape, "{name} layout shape");
376
- assert_eq!(tensor.dtype, "float32", "{name} layout dtype");
309
+ assert_eq!(tensor.dtype.as_str(), "float32", "{name} layout dtype");
310
assert_eq!(
311
tensor.data.into_raw_view().unwrap(),
312
expected,
@@ -386,10 +319,9 @@ mod tests {
319
fn batched_wire_value_at_drops_first_axis() {
320
let data = vec![1.0_f32, 2.0, 3.0, 4.0];
321
let expected_ptr = data.as_ptr().cast::<u8>().wrapping_add(2 * size_of::<f32>());
389
- let value = KwargValue::tensor(
390
- TensorKind::F32,
391
- WireTensor::from_f32(vec![2, 2], data).unwrap(),
392
- );
322
+ let value = KwargValue::Tensor {
323
+ wire: WireTensor::from_f32(vec![2, 2], data).unwrap(),
324
+ };
325
326
let ProtocolKwargValue::Tensor(tensor) = value.batched_wire_value_at(1).unwrap() else {
327
panic!("expected tensor");
@@ -406,10 +338,9 @@ mod tests {
338
339
#[test]
340
fn flat_wire_value_range_keeps_first_axis() {
409
- let value = KwargValue::tensor(
410
- TensorKind::U32,
411
- WireTensor::from_u32(vec![5, 2], (0..10_u32).collect()).unwrap(),
412
- );
341
+ let value = KwargValue::Tensor {
342
+ wire: WireTensor::from_u32(vec![5, 2], (0..10_u32).collect()).unwrap(),
343
+ };
344
345
let ProtocolKwargValue::Tensor(tensor) = value.flat_wire_value_range(1, 3).unwrap() else {
346
panic!("expected tensor");
@@ -424,10 +355,9 @@ mod tests {
355
356
#[test]
357
fn flat_range_for_index_uses_size_tensor() {
427
- let sizes = KwargValue::tensor(
428
- TensorKind::I64,
429
- WireTensor::from_i64(vec![3], vec![2_i64, 3, 4]).unwrap(),
430
- );
358
+ let sizes = KwargValue::Tensor {
359
+ wire: WireTensor::from_i64(vec![3], vec![2_i64, 3, 4]).unwrap(),
360
+ };
361
362
assert_eq!(
363
flat_range_for_index(&sizes, "image_grid_thw", 1).unwrap(),
@@ -437,11 +367,12 @@ mod tests {
367
368
#[test]
369
fn slice_first_axis_range_errors_on_shape_data_mismatch() {
440
- let tensor = WireTensor::from_raw("float32", vec![2, 2], vec![0; 3 * size_of::<f32>()]);
441
- let error = slice_first_axis_range(&tensor, size_of::<f32>(), 0, 1, true).unwrap_err();
370
+ let tensor =
371
+ WireTensor::from_raw(TensorDtype::F32, vec![2, 2], vec![0; 3 * size_of::<f32>()]);
372
+ let error = slice_first_axis_range(&tensor, 0, 1, true).unwrap_err();
373
374
assert!(
444
- matches!(error, Error::Multimodal(message) if message.contains("expects 16 bytes"))
375
+ matches!(error, Error::Multimodal(message) if message.contains("does not match expected 16"))
376
);
377
}
378
@@ -455,7 +386,7 @@ mod tests {
386
panic!("expected tensor");
387
};
388
458
- assert_eq!(tensor.dtype, "bfloat16");
389
+ assert_eq!(tensor.dtype.as_str(), "bfloat16");
390
assert_eq!(tensor.shape, vec![2]);
391
assert_eq!(tensor.data.into_raw_view().unwrap().len(), 4);
392
}
@@ -470,7 +401,7 @@ mod tests {
401
panic!("expected tensor");
402
};
403
473
- assert_eq!(tensor.dtype, "float16");
404
+ assert_eq!(tensor.dtype.as_str(), "float16");
405
assert_eq!(tensor.shape, vec![2]);
406
assert_eq!(tensor.data.into_raw_view().unwrap().len(), 4);
407
}
rust/src/engine-core-client/src/protocol/dtype.rs
+231
-1
@@ -1,7 +1,191 @@
1
// SPDX-License-Identifier: Apache-2.0
2
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
+use std::fmt;
5
+use std::str::FromStr;
6
+
7
use serde::{Deserialize, Serialize};
8
+use serde_with::{DeserializeFromStr, SerializeDisplay};
9
+
10
+/// Element types accepted by Python's tensor decoder.
11
+#[derive(Debug, Clone, Copy, PartialEq, Eq, SerializeDisplay, DeserializeFromStr)]
12
+pub enum TensorDtype {
13
+ Bool,
14
+ U8,
15
+ I8,
16
+ U16,
17
+ I16,
18
+ U32,
19
+ I32,
20
+ U64,
21
+ I64,
22
+ F16,
23
+ Bf16,
24
+ F32,
25
+ F64,
26
+}
27
+
28
+impl TensorDtype {
29
+ pub const fn as_str(self) -> &'static str {
30
+ match self {
31
+ Self::Bool => "bool",
32
+ Self::U8 => "uint8",
33
+ Self::I8 => "int8",
34
+ Self::U16 => "uint16",
35
+ Self::I16 => "int16",
36
+ Self::U32 => "uint32",
37
+ Self::I32 => "int32",
38
+ Self::U64 => "uint64",
39
+ Self::I64 => "int64",
40
+ Self::F16 => "float16",
41
+ Self::Bf16 => "bfloat16",
42
+ Self::F32 => "float32",
43
+ Self::F64 => "float64",
44
+ }
45
+ }
46
+
47
+ pub const fn element_size(self) -> usize {
48
+ match self {
49
+ Self::Bool | Self::U8 | Self::I8 => 1,
50
+ Self::U16 | Self::I16 | Self::F16 | Self::Bf16 => 2,
51
+ Self::U32 | Self::I32 | Self::F32 => 4,
52
+ Self::U64 | Self::I64 | Self::F64 => 8,
53
+ }
54
+ }
55
+}
56
+
57
+impl fmt::Display for TensorDtype {
58
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59
+ f.write_str(self.as_str())
60
+ }
61
+}
62
+
63
+impl FromStr for TensorDtype {
64
+ type Err = String;
65
+
66
+ fn from_str(value: &str) -> Result<Self, Self::Err> {
67
+ match value {
68
+ "bool" => Ok(Self::Bool),
69
+ "uint8" => Ok(Self::U8),
70
+ "int8" => Ok(Self::I8),
71
+ "uint16" => Ok(Self::U16),
72
+ "int16" => Ok(Self::I16),
73
+ "uint32" => Ok(Self::U32),
74
+ "int32" => Ok(Self::I32),
75
+ "uint64" => Ok(Self::U64),
76
+ "int64" => Ok(Self::I64),
77
+ "float16" => Ok(Self::F16),
78
+ "bfloat16" => Ok(Self::Bf16),
79
+ "float32" => Ok(Self::F32),
80
+ "float64" => Ok(Self::F64),
81
+ _ => Err(format!("unsupported tensor dtype {value:?}")),
82
+ }
83
+ }
84
+}
85
+
86
+/// Byte order carried by a NumPy dtype descriptor.
87
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88
+pub enum Endianness {
89
+ Little,
90
+ Big,
91
+ Native,
92
+}
93
+
94
+/// Numeric array dtype with an explicit byte-order contract.
95
+///
96
+/// Also accepts Torch type names as native-endian dtypes because prompt
97
+/// logprobs use tensor tuples with the same wire shape as NumPy arrays.
98
+/// Serializes byte order explicitly, including `=` for native byte order.
99
+/// Bfloat16 uses the Torch name after the prefix as a nonstandard descriptor.
100
+#[derive(Debug, Clone, Copy, PartialEq, Eq, SerializeDisplay, DeserializeFromStr)]
101
+pub struct NumpyDtype {
102
+ pub scalar: TensorDtype,
103
+ pub endianness: Endianness,
104
+}
105
+
106
+impl NumpyDtype {
107
+ pub const fn little(scalar: TensorDtype) -> Self {
108
+ Self {
109
+ scalar,
110
+ endianness: Endianness::Little,
111
+ }
112
+ }
113
+
114
+ pub const fn native(scalar: TensorDtype) -> Self {
115
+ Self {
116
+ scalar,
117
+ endianness: Endianness::Native,
118
+ }
119
+ }
120
+
121
+ pub const fn big(scalar: TensorDtype) -> Self {
122
+ Self {
123
+ scalar,
124
+ endianness: Endianness::Big,
125
+ }
126
+ }
127
+}
128
+
129
+impl From<TensorDtype> for NumpyDtype {
130
+ fn from(scalar: TensorDtype) -> Self {
131
+ Self::native(scalar)
132
+ }
133
+}
134
+
135
+impl fmt::Display for NumpyDtype {
136
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137
+ let prefix = match self.endianness {
138
+ Endianness::Native => '=',
139
+ Endianness::Little => '<',
140
+ Endianness::Big => '>',
141
+ };
142
+ let code = match self.scalar {
143
+ TensorDtype::Bool => "b1",
144
+ TensorDtype::U8 => "u1",
145
+ TensorDtype::I8 => "i1",
146
+ TensorDtype::U16 => "u2",
147
+ TensorDtype::I16 => "i2",
148
+ TensorDtype::U32 => "u4",
149
+ TensorDtype::I32 => "i4",
150
+ TensorDtype::U64 => "u8",
151
+ TensorDtype::I64 => "i8",
152
+ TensorDtype::F16 => "f2",
153
+ TensorDtype::F32 => "f4",
154
+ TensorDtype::F64 => "f8",
155
+ TensorDtype::Bf16 => "bfloat16",
156
+ };
157
+ write!(f, "{prefix}{code}")
158
+ }
159
+}
160
+
161
+impl FromStr for NumpyDtype {
162
+ type Err = String;
163
+
164
+ fn from_str(dtype: &str) -> Result<Self, Self::Err> {
165
+ let (endianness, body) = match dtype.as_bytes().first().copied() {
166
+ Some(b'<') => (Endianness::Little, &dtype[1..]),
167
+ Some(b'>') => (Endianness::Big, &dtype[1..]),
168
+ Some(b'=' | b'|') => (Endianness::Native, &dtype[1..]),
169
+ _ => (Endianness::Native, dtype),
170
+ };
171
+ let scalar = match body {
172
+ "b1" => TensorDtype::Bool,
173
+ "u1" => TensorDtype::U8,
174
+ "i1" => TensorDtype::I8,
175
+ "u2" => TensorDtype::U16,
176
+ "i2" => TensorDtype::I16,
177
+ "u4" => TensorDtype::U32,
178
+ "i4" => TensorDtype::I32,
179
+ "u8" => TensorDtype::U64,
180
+ "i8" => TensorDtype::I64,
181
+ "f2" => TensorDtype::F16,
182
+ "f4" => TensorDtype::F32,
183
+ "f8" => TensorDtype::F64,
184
+ _ => body.parse().map_err(|_| format!("unsupported dtype string {dtype:?}"))?,
185
+ };
186
+ Ok(Self { scalar, endianness })
187
+ }
188
+}
189
190
/// Effective model dtype reported by the engine after config resolution.
191
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -26,7 +210,53 @@ impl ModelDtype {
210
211
#[cfg(test)]
212
mod tests {
29
- use super::ModelDtype;
213
+ use super::{Endianness, ModelDtype, NumpyDtype, TensorDtype};
214
+
215
+ #[test]
216
+ fn numpy_descriptors_preserve_explicit_byte_order() {
217
+ for (code, scalar) in [
218
+ ("b1", TensorDtype::Bool),
219
+ ("u1", TensorDtype::U8),
220
+ ("i1", TensorDtype::I8),
221
+ ("u2", TensorDtype::U16),
222
+ ("i2", TensorDtype::I16),
223
+ ("u4", TensorDtype::U32),
224
+ ("i4", TensorDtype::I32),
225
+ ("u8", TensorDtype::U64),
226
+ ("i8", TensorDtype::I64),
227
+ ("f2", TensorDtype::F16),
228
+ ("bfloat16", TensorDtype::Bf16),
229
+ ("f4", TensorDtype::F32),
230
+ ("f8", TensorDtype::F64),
231
+ ] {
232
+ for (prefix, endianness) in [("<", Endianness::Little), (">", Endianness::Big)] {
233
+ let wire = serde_json::json!(format!("{prefix}{code}"));
234
+ let dtype: NumpyDtype = serde_json::from_value(wire.clone()).unwrap();
235
+ assert_eq!(dtype, NumpyDtype { scalar, endianness });
236
+ assert_eq!(serde_json::to_value(dtype).unwrap(), wire);
237
+ }
238
+ for wire in [
239
+ format!("={code}"),
240
+ format!("|{code}"),
241
+ code.into(),
242
+ scalar.as_str().into(),
243
+ ] {
244
+ let dtype: NumpyDtype = serde_json::from_value(serde_json::json!(wire)).unwrap();
245
+ assert_eq!(dtype, NumpyDtype::from(scalar));
246
+ assert_eq!(
247
+ serde_json::to_value(dtype).unwrap(),
248
+ serde_json::json!(format!("={code}"))
249
+ );
250
+ }
251
+ }
252
+ }
253
+
254
+ #[test]
255
+ fn rejects_unsupported_numpy_dtypes() {
256
+ for wire in ["", "object", "<c8", "<bf2"] {
257
+ assert!(serde_json::from_value::<NumpyDtype>(serde_json::json!(wire)).is_err());
258
+ }
259
+ }
260
261
#[test]
262
fn serde_uses_protocol_dtype_strings() {
rust/src/engine-core-client/src/protocol/logprobs.rs
+4
-3
@@ -14,6 +14,7 @@ use serde::{Deserialize, Deserializer, Serialize};
14
15
use self::wire::*;
16
use crate::error::{Error, Result, bail_ext_value_decode};
17
+use crate::protocol::dtype::{NumpyDtype, TensorDtype};
18
use crate::protocol::tensor::{WireArrayData, WireNdArray};
19
20
/// One token candidate and its logprob metadata for a single sequence position.
@@ -205,17 +206,17 @@ impl WireLogprobs {
206
207
Ok(Self {
208
logprob_token_ids: WireNdArray {
208
- dtype: "<i8".to_string(),
209
+ dtype: NumpyDtype::little(TensorDtype::I64),
210
shape: vec![rows, cols],
211
data: WireArrayData::RawView(token_ids.into()),
212
},
213
logprobs: WireNdArray {
213
- dtype: "<f4".to_string(),
214
+ dtype: NumpyDtype::little(TensorDtype::F32),
215
shape: vec![rows, cols],
216
data: WireArrayData::RawView(logprobs.into()),
217
},
218
token_ranks: WireNdArray {
218
- dtype: "<i8".to_string(),
219
+ dtype: NumpyDtype::little(TensorDtype::I64),
220
shape: vec![rows],
221
data: WireArrayData::RawView(token_ranks.into()),
222
},
rust/src/engine-core-client/src/protocol/logprobs/array.rs
+17
-55
@@ -8,22 +8,9 @@ use bytes::Bytes;
8
use itertools::Itertools as _;
9
10
use crate::error::{Error, Result, ext_value_decode};
11
+use crate::protocol::dtype::{Endianness, TensorDtype};
12
use crate::protocol::tensor::{ShapeExt as _, WireNdArray};
13
13
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14
-pub(super) enum ScalarType {
15
- I32,
16
- I64,
17
- F32,
18
-}
19
-
20
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21
-pub(super) enum Endianness {
22
- Little,
23
- Big,
24
- Native,
25
-}
26
-
14
#[derive(Debug, Clone, PartialEq)]
15
pub(super) struct DecodedArray2<T> {
16
pub rows: usize,
@@ -37,7 +24,7 @@ pub(super) fn decode_array2_u32(
24
frames: &[Bytes],
25
) -> Result<DecodedArray2<u32>> {
26
let (shape, bytes, scalar, endianness) =
40
- decode_array_metadata(value, field, frames, &[ScalarType::I32, ScalarType::I64])?;
27
+ decode_array_metadata(value, field, frames, &[TensorDtype::I32, TensorDtype::I64])?;
28
if shape.len() != 2 {
29
return Err(decode_error(
30
field,
@@ -46,15 +33,15 @@ pub(super) fn decode_array2_u32(
33
}
34
35
let data = match scalar {
49
- ScalarType::I32 => decode_i32_vec(&bytes, endianness, field)?
36
+ TensorDtype::I32 => decode_i32_vec(&bytes, endianness, field)?
37
.into_iter()
38
.map(|value| convert_to_u32(value, field))
39
.try_collect()?,
53
- ScalarType::I64 => decode_i64_vec(&bytes, endianness, field)?
40
+ TensorDtype::I64 => decode_i64_vec(&bytes, endianness, field)?
41
.into_iter()
42
.map(|value| convert_to_u32(value, field))
43
.try_collect()?,
57
- ScalarType::F32 => unreachable!("scalar validation should reject f32"),
44
+ _ => unreachable!("scalar validation should accept only i32 and i64"),
45
};
46
Ok(DecodedArray2 {
47
rows: shape[0],
@@ -69,7 +56,7 @@ pub(super) fn decode_array1_u32(
56
frames: &[Bytes],
57
) -> Result<Vec<u32>> {
58
let (shape, bytes, scalar, endianness) =
72
- decode_array_metadata(value, field, frames, &[ScalarType::I32, ScalarType::I64])?;
59
+ decode_array_metadata(value, field, frames, &[TensorDtype::I32, TensorDtype::I64])?;
60
if shape.len() != 1 {
61
return Err(decode_error(
62
field,
@@ -78,15 +65,15 @@ pub(super) fn decode_array1_u32(
65
}
66
67
let data = match scalar {
81
- ScalarType::I32 => decode_i32_vec(&bytes, endianness, field)?
68
+ TensorDtype::I32 => decode_i32_vec(&bytes, endianness, field)?
69
.into_iter()
70
.map(|value| convert_to_u32(value, field))
71
.try_collect()?,
85
- ScalarType::I64 => decode_i64_vec(&bytes, endianness, field)?
72
+ TensorDtype::I64 => decode_i64_vec(&bytes, endianness, field)?
73
.into_iter()
74
.map(|value| convert_to_u32(value, field))
75
.try_collect()?,
89
- ScalarType::F32 => unreachable!("scalar validation should reject f32"),
76
+ _ => unreachable!("scalar validation should accept only i32 and i64"),
77
};
78
Ok(data)
79
}
@@ -97,7 +84,7 @@ pub(super) fn decode_array2_f32(
84
frames: &[Bytes],
85
) -> Result<DecodedArray2<f32>> {
86
let (shape, bytes, _, endianness) =
100
- decode_array_metadata(value, field, frames, &[ScalarType::F32])?;
87
+ decode_array_metadata(value, field, frames, &[TensorDtype::F32])?;
88
if shape.len() != 2 {
89
return Err(decode_error(
90
field,
@@ -117,15 +104,16 @@ pub(super) fn decode_array_metadata(
104
value: WireNdArray,
105
field: &str,
106
frames: &[Bytes],
120
- expected_scalars: &[ScalarType],
121
-) -> Result<(Vec<usize>, Bytes, ScalarType, Endianness)> {
107
+ expected_scalars: &[TensorDtype],
108
+) -> Result<(Vec<usize>, Bytes, TensorDtype, Endianness)> {
109
let mut value = value;
123
- let (scalar, endianness) = parse_dtype(&value.dtype, field)?;
110
+ let scalar = value.dtype.scalar;
111
+ let endianness = value.dtype.endianness;
112
if !expected_scalars.contains(&scalar) {
113
return Err(decode_error(
114
field,
115
&format!(
128
- "expected dtype in {:?}, got {}",
116
+ "expected dtype in {:?}, got {:?}",
117
expected_scalars, value.dtype
118
),
119
));
@@ -140,42 +128,16 @@ pub(super) fn decode_array_metadata(
128
Ok((shape, bytes, scalar, endianness))
129
}
130
143
-pub(super) fn parse_dtype(dtype: &str, field: &str) -> Result<(ScalarType, Endianness)> {
144
- let (endianness, body) = match dtype.as_bytes().first().copied() {
145
- Some(b'<') => (Endianness::Little, &dtype[1..]),
146
- Some(b'>') => (Endianness::Big, &dtype[1..]),
147
- Some(b'=') => (Endianness::Native, &dtype[1..]),
148
- Some(b'|') => (Endianness::Native, &dtype[1..]),
149
- _ => (Endianness::Native, dtype),
150
- };
151
-
152
- let scalar = match body {
153
- "i4" | "int32" => ScalarType::I32,
154
- "i8" | "int64" => ScalarType::I64,
155
- "f4" | "float32" => ScalarType::F32,
156
- _ => {
157
- return Err(decode_error(
158
- field,
159
- &format!("unsupported dtype string {dtype:?}"),
160
- ));
161
- }
162
- };
163
- Ok((scalar, endianness))
164
-}
165
-
131
pub(super) fn validate_byte_length(
132
shape: &[usize],
133
byte_len: usize,
134
field: &str,
170
- scalar: ScalarType,
135
+ scalar: TensorDtype,
136
) -> Result<()> {
137
let element_count = shape
138
.checked_numel()
139
.ok_or_else(|| decode_error(field, "shape element count overflowed usize"))?;
175
- let element_size = match scalar {
176
- ScalarType::I32 | ScalarType::F32 => 4,
177
- ScalarType::I64 => 8,
178
- };
140
+ let element_size = scalar.element_size();
141
let expected = element_count
142
.checked_mul(element_size)
143
.ok_or_else(|| decode_error(field, "byte length overflowed usize"))?;
rust/src/engine-core-client/src/protocol/logprobs/tests.rs
+23
@@ -302,6 +302,29 @@ fn decodes_big_endian_payloads() {
302
);
303
}
304
305
+#[test]
306
+fn rejects_supported_array_dtypes_in_incompatible_logprobs_fields() {
307
+ for (ids_dtype, probs_dtype, field) in [
308
+ ("<u4", "<f4", "logprob_token_ids"),
309
+ ("<i4", "<i4", "logprobs"),
310
+ ] {
311
+ let frames = vec![Bytes::from(encode_value(&output_wire_with_custom_fields(
312
+ Some(Value::Array(vec![
313
+ ndarray_value(ids_dtype, &[1, 1], Value::Ext(3, vec![0; 4])),
314
+ ndarray_value(probs_dtype, &[1, 1], Value::Ext(3, vec![0; 4])),
315
+ ndarray_value("<i4", &[1], Value::Ext(3, vec![1, 0, 0, 0])),
316
+ Value::Nil,
317
+ ])),
318
+ None,
319
+ )))];
320
+ let error = decode_engine_core_outputs(&frames).unwrap_err();
321
+ let crate::error::Error::ExtValueDecode { message } = error else {
322
+ panic!("expected ExtValueDecode");
323
+ };
324
+ assert!(message.starts_with(&format!("new_logprobs.{field}: expected dtype")));
325
+ }
326
+}
327
+
328
#[test]
329
fn rejects_non_none_cu_num_generated_tokens() {
330
let frames = vec![Bytes::from(encode_value(&output_wire_with_custom_fields(
rust/src/engine-core-client/src/protocol/multimodal.rs
+21
-1
@@ -9,6 +9,26 @@ use serde_tuple::{Deserialize_tuple, Serialize_tuple};
9
10
use super::tensor::WireTensor;
11
12
+/// Modalities represented by engine-ready multimodal features.
13
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14
+#[serde(rename_all = "snake_case")]
15
+pub enum MmModality {
16
+ Image,
17
+ Audio,
18
+ Video,
19
+}
20
+
21
+impl MmModality {
22
+ /// The wire name, matching Python's modality strings.
23
+ pub fn as_str(self) -> &'static str {
24
+ match self {
25
+ Self::Image => "image",
26
+ Self::Audio => "audio",
27
+ Self::Video => "video",
28
+ }
29
+ }
30
+}
31
+
32
/// Multimodal feature payload accepted from higher-level frontend code.
33
///
34
/// Original Python definition:
@@ -32,7 +52,7 @@ pub struct MmFeatureSpec {
52
pub data: Option<MmKwargsItem>,
53
54
/// The input modality, e.g., `"image"`, `"audio"`, `"video"`.
35
- pub modality: String,
55
+ pub modality: MmModality,
56
57
/// The hash for caching encoder outputs (with LoRA prefix if applicable).
58
pub identifier: String,
rust/src/engine-core-client/src/protocol/request.rs
+7
-5
@@ -158,8 +158,10 @@ mod tests {
158
use rmpv::Value;
159
160
use super::*;
161
+ use crate::protocol::dtype::TensorDtype;
162
use crate::protocol::multimodal::{
162
- MmBatchedField, MmFeatureSpec, MmField, MmFieldElem, MmKwargValue, PlaceholderRange,
163
+ MmBatchedField, MmFeatureSpec, MmField, MmFieldElem, MmKwargValue, MmModality,
164
+ PlaceholderRange,
165
};
166
use crate::protocol::sampling::EngineCoreSamplingParams;
167
use crate::protocol::tensor::{WireArrayData, WireTensor};
@@ -212,7 +214,7 @@ mod tests {
214
"inline".to_string(),
215
MmFieldElem {
216
data: Some(MmKwargValue::Tensor(WireTensor::from_raw(
215
- "uint8",
217
+ TensorDtype::U8,
218
vec![inline.len()],
219
inline,
220
))),
@@ -225,7 +227,7 @@ mod tests {
227
data: Some(MmKwargValue::List(vec![
228
MmKwargValue::Int(7),
229
MmKwargValue::Tensor(WireTensor::from_raw(
228
- "uint8",
230
+ TensorDtype::U8,
231
vec![first_aux.len()],
232
first_aux,
233
)),
@@ -234,13 +236,13 @@ mod tests {
236
},
237
),
238
])),
237
- modality: "image".to_string(),
239
+ modality: MmModality::Image,
240
identifier: "id".to_string(),
241
mm_position: PlaceholderRange {
242
offset: 0,
243
length: second_aux.len(),
244
is_embed: Some(WireTensor::from_raw(
243
- "bool",
245
+ TensorDtype::Bool,
246
vec![second_aux.len()],
247
second_aux,
248
)),
rust/src/engine-core-client/src/protocol/tensor.rs
+69
-36
@@ -6,9 +6,12 @@ use bytes::Bytes;
6
use enum_as_inner::EnumAsInner;
7
use half::{bf16, f16};
8
use rmpv::Value;
9
+use serde::de::DeserializeOwned;
10
use serde::{Deserialize, Deserializer, Serialize, Serializer};
11
use serde_tuple::{Deserialize_tuple, Serialize_tuple};
12
13
+use super::dtype::{NumpyDtype, TensorDtype};
14
+
15
/// Tensors and ndarrays are encoded with this extension type in Python.
16
///
17
/// Original Python definition:
@@ -63,20 +66,20 @@ impl [usize] {
66
/// <https://github.com/vllm-project/vllm/blob/5a0a8fc1ea7542394ff315138bd5677b7b53bca1/vllm/v1/serial_utils.py#L237-L273>
67
/// <https://github.com/vllm-project/vllm/blob/5a0a8fc1ea7542394ff315138bd5677b7b53bca1/vllm/v1/serial_utils.py#L389-L425>
68
#[derive(Debug, Clone, PartialEq, Serialize_tuple, Deserialize_tuple)]
66
-pub struct WireNdArray {
67
- pub dtype: String,
69
+pub struct WireArray<Dtype: Serialize + DeserializeOwned> {
70
+ pub dtype: Dtype,
71
pub shape: Vec<usize>,
72
pub data: WireArrayData,
73
}
74
72
-impl WireNdArray {
75
+impl<Dtype: From<TensorDtype> + Serialize + DeserializeOwned> WireArray<Dtype> {
76
/// Build a float32 tensor/ndarray backed by native-endian raw-view bytes.
77
///
78
/// Takes ownership of the backing buffer without copying its data.
79
pub fn from_f32(shape: Vec<usize>, data: Vec<f32>) -> Result<Self, String> {
80
validate_element_count(&shape, data.len())?;
81
Ok(Self::from_raw_bytes(
79
- "float32",
82
+ TensorDtype::F32,
83
shape,
84
bytes_from_pod_vec(data),
85
))
@@ -88,7 +91,7 @@ impl WireNdArray {
91
pub fn from_f16(shape: Vec<usize>, data: Vec<f16>) -> Result<Self, String> {
92
validate_element_count(&shape, data.len())?;
93
Ok(Self::from_raw_bytes(
91
- "float16",
94
+ TensorDtype::F16,
95
shape,
96
bytes_from_pod_vec(data),
97
))
@@ -100,7 +103,7 @@ impl WireNdArray {
103
pub fn from_bf16(shape: Vec<usize>, data: Vec<bf16>) -> Result<Self, String> {
104
validate_element_count(&shape, data.len())?;
105
Ok(Self::from_raw_bytes(
103
- "bfloat16",
106
+ TensorDtype::Bf16,
107
shape,
108
bytes_from_pod_vec(data),
109
))
@@ -112,7 +115,7 @@ impl WireNdArray {
115
pub fn from_i64(shape: Vec<usize>, data: Vec<i64>) -> Result<Self, String> {
116
validate_element_count(&shape, data.len())?;
117
Ok(Self::from_raw_bytes(
115
- "int64",
118
+ TensorDtype::I64,
119
shape,
120
bytes_from_pod_vec(data),
121
))
@@ -124,7 +127,7 @@ impl WireNdArray {
127
pub fn from_u32(shape: Vec<usize>, data: Vec<u32>) -> Result<Self, String> {
128
validate_element_count(&shape, data.len())?;
129
Ok(Self::from_raw_bytes(
127
- "uint32",
130
+ TensorDtype::U32,
131
shape,
132
bytes_from_pod_vec(data),
133
))
@@ -137,7 +140,7 @@ impl WireNdArray {
140
pub fn from_bool(shape: Vec<usize>, data: Vec<bool>) -> Result<Self, String> {
141
validate_element_count(&shape, data.len())?;
142
Ok(Self {
140
- dtype: "bool".to_string(),
143
+ dtype: TensorDtype::Bool.into(),
144
shape,
145
data: WireArrayData::RawView(Bytes::from(
146
data.into_iter().map(u8::from).collect::<Vec<_>>(),
@@ -149,12 +152,12 @@ impl WireNdArray {
152
///
153
/// Use this as an escape hatch when the caller already owns bytes that
154
/// match the requested `dtype` and `shape`.
152
- pub fn from_raw(dtype: impl Into<String>, shape: Vec<usize>, data: Vec<u8>) -> Self {
155
+ pub fn from_raw(dtype: impl Into<Dtype>, shape: Vec<usize>, data: Vec<u8>) -> Self {
156
Self::from_raw_bytes(dtype, shape, Bytes::from(data))
157
}
158
159
/// Build a tensor/ndarray from an owned immutable raw-view buffer.
157
- pub fn from_raw_bytes(dtype: impl Into<String>, shape: Vec<usize>, data: Bytes) -> Self {
160
+ pub fn from_raw_bytes(dtype: impl Into<Dtype>, shape: Vec<usize>, data: Bytes) -> Self {
161
Self {
162
dtype: dtype.into(),
163
shape,
@@ -192,7 +195,10 @@ fn validate_element_count(shape: &[usize], len: usize) -> Result<(), String> {
195
///
196
/// This is the same wire shape as [`WireNdArray`]; multimodal request payloads
197
/// use it for `torch.Tensor` values.
195
-pub type WireTensor = WireNdArray;
198
+pub type WireTensor = WireArray<TensorDtype>;
199
+
200
+/// Numeric array with a typed NumPy descriptor or native-endian Torch dtype.
201
+pub type WireNdArray = WireArray<NumpyDtype>;
202
203
/// Python array/tensor payload reference inside [`WireNdArray`].
204
///
@@ -211,6 +217,27 @@ pub enum WireArrayData {
217
RawView(Bytes),
218
}
219
220
+impl WireTensor {
221
+ /// Validate the shape and storage of an inline tensor.
222
+ pub fn validate_inline(&self) -> Result<(), String> {
223
+ let expected = self
224
+ .shape
225
+ .checked_numel()
226
+ .and_then(|numel| numel.checked_mul(self.dtype.element_size()))
227
+ .ok_or_else(|| format!("tensor shape or byte length overflows: {:?}", self.shape))?;
228
+ match &self.data {
229
+ WireArrayData::RawView(bytes) if bytes.len() == expected => Ok(()),
230
+ WireArrayData::RawView(bytes) => Err(format!(
231
+ "tensor byte length {} does not match expected {expected}",
232
+ bytes.len()
233
+ )),
234
+ WireArrayData::AuxIndex(index) => {
235
+ Err(format!("inline tensor references auxiliary frame {index}"))
236
+ }
237
+ }
238
+ }
239
+}
240
+
241
impl WireArrayData {
242
/// Resolve an auxiliary-frame reference into owned raw bytes.
243
pub(crate) fn resolve_aux_frame(&mut self, frames: &[Bytes]) -> Result<(), String> {
@@ -288,6 +315,21 @@ impl Serialize for WireArrayData {
315
mod tests {
316
use super::*;
317
318
+ #[test]
319
+ fn tensor_and_ndarray_preserve_their_dtype_wire_contracts() {
320
+ let tensor = WireTensor::from_f32(vec![1], vec![1.0]).unwrap();
321
+ let bytes = rmp_serde::to_vec_named(&tensor).unwrap();
322
+ let wire: Value = rmp_serde::from_slice(&bytes).unwrap();
323
+ assert_eq!(wire.as_array().unwrap()[0], Value::from("float32"));
324
+
325
+ let array: WireNdArray = rmp_serde::from_slice(&bytes).unwrap();
326
+ assert_eq!(array.dtype, NumpyDtype::native(TensorDtype::F32));
327
+ let bytes = rmp_serde::to_vec_named(&array).unwrap();
328
+ let wire: Value = rmp_serde::from_slice(&bytes).unwrap();
329
+ assert_eq!(wire.as_array().unwrap()[0], Value::from("=f4"));
330
+ assert!(rmp_serde::from_slice::<WireTensor>(&bytes).is_err());
331
+ }
332
+
333
#[test]
334
fn raw_view_serializes_as_msgpack_ext() {
335
let bytes = vec![1, 2, 3, 4];
@@ -307,8 +349,8 @@ mod tests {
349
fn constructors_build_raw_view_tensors() {
350
let f32_data = vec![1.0, 2.5];
351
let f32_data_ptr = f32_data.as_ptr().cast::<u8>();
310
- let f32_tensor = WireNdArray::from_f32(vec![2], f32_data).unwrap();
311
- assert_eq!(f32_tensor.dtype, "float32");
352
+ let f32_tensor = WireTensor::from_f32(vec![2], f32_data).unwrap();
353
+ assert_eq!(f32_tensor.dtype, TensorDtype::F32);
354
assert_eq!(f32_tensor.shape, vec![2]);
355
let f32_raw_view = f32_tensor.data.into_raw_view().expect("raw view");
356
assert_eq!(f32_raw_view.as_ptr(), f32_data_ptr);
@@ -318,58 +360,49 @@ mod tests {
360
);
361
362
let f16_tensor =
321
- WireNdArray::from_f16(vec![2], vec![f16::from_f32(1.0), f16::from_f32(2.5)]).unwrap();
322
- assert_eq!(f16_tensor.dtype, "float16");
363
+ WireTensor::from_f16(vec![2], vec![f16::from_f32(1.0), f16::from_f32(2.5)]).unwrap();
364
+ assert_eq!(f16_tensor.dtype, TensorDtype::F16);
365
assert_eq!(f16_tensor.shape, vec![2]);
366
assert_eq!(f16_tensor.data.into_raw_view().expect("raw view").len(), 4);
367
368
let bf16_tensor =
327
- WireNdArray::from_bf16(vec![2], vec![bf16::from_f32(1.0), bf16::from_f32(2.5)])
328
- .unwrap();
329
- assert_eq!(bf16_tensor.dtype, "bfloat16");
369
+ WireTensor::from_bf16(vec![2], vec![bf16::from_f32(1.0), bf16::from_f32(2.5)]).unwrap();
370
+ assert_eq!(bf16_tensor.dtype, TensorDtype::Bf16);
371
assert_eq!(bf16_tensor.shape, vec![2]);
372
assert_eq!(bf16_tensor.data.into_raw_view().expect("raw view").len(), 4);
373
333
- let i64_tensor = WireNdArray::from_i64(vec![1], vec![-7]).unwrap();
334
- assert_eq!(i64_tensor.dtype, "int64");
374
+ let i64_tensor = WireTensor::from_i64(vec![1], vec![-7]).unwrap();
375
+ assert_eq!(i64_tensor.dtype, TensorDtype::I64);
376
assert_eq!(
377
i64_tensor.data.into_raw_view().expect("raw view").as_ref(),
378
(-7_i64).to_ne_bytes().as_ref()
379
);
380
340
- let u32_tensor = WireNdArray::from_u32(vec![1], vec![42]).unwrap();
341
- assert_eq!(u32_tensor.dtype, "uint32");
381
+ let u32_tensor = WireTensor::from_u32(vec![1], vec![42]).unwrap();
382
+ assert_eq!(u32_tensor.dtype, TensorDtype::U32);
383
assert_eq!(
384
u32_tensor.data.into_raw_view().expect("raw view").as_ref(),
385
42_u32.to_ne_bytes().as_ref()
386
);
387
347
- let bool_tensor = WireNdArray::from_bool(vec![2], vec![false, true]).unwrap();
348
- assert_eq!(bool_tensor.dtype, "bool");
388
+ let bool_tensor = WireTensor::from_bool(vec![2], vec![false, true]).unwrap();
389
+ assert_eq!(bool_tensor.dtype, TensorDtype::Bool);
390
assert_eq!(
391
bool_tensor.data.into_raw_view().expect("raw view"),
392
vec![0, 1]
393
);
353
-
354
- let raw_tensor = WireNdArray::from_raw("custom", vec![3], vec![1, 2, 3]);
355
- assert_eq!(raw_tensor.dtype, "custom");
356
- assert_eq!(raw_tensor.shape, vec![3]);
357
- assert_eq!(
358
- raw_tensor.data.into_raw_view().expect("raw view"),
359
- vec![1, 2, 3]
360
- );
394
}
395
396
#[test]
397
fn constructors_validate_shape_product() {
365
- let err = WireNdArray::from_f32(vec![2, 2], vec![1.0, 2.0]).unwrap_err();
398
+ let err = WireTensor::from_f32(vec![2, 2], vec![1.0, 2.0]).unwrap_err();
399
assert!(err.contains("does not match shape"));
400
}
401
402
#[test]
403
fn resolve_aux_frame_shares_bytes_using_one_based_message_index() {
371
- let mut tensor = WireNdArray {
372
- dtype: "float32".to_string(),
404
+ let mut tensor = WireTensor {
405
+ dtype: TensorDtype::F32,
406
shape: vec![2],
407
data: WireArrayData::AuxIndex(1),
408
};
rust/src/engine-core-client/src/tests/client.rs
+3
-3
@@ -22,8 +22,8 @@ use zeromq::{DealerSocket, PushSocket, SocketOptions, SubSocket, XPubSocket, Zmq
22
use crate::protocol::handshake::{EngineCoreReadyResponse, HandshakeInitMessage, ReadyMessage};
23
use crate::protocol::logprobs::MaybeWireLogprobs;
24
use crate::protocol::multimodal::{
25
- MmFeatureSpec, MmField, MmFieldElem, MmFlatField, MmKwargValue, MmSlice, PlaceholderRange,
26
- SliceSpec,
25
+ MmFeatureSpec, MmField, MmFieldElem, MmFlatField, MmKwargValue, MmModality, MmSlice,
26
+ PlaceholderRange, SliceSpec,
27
};
28
use crate::protocol::output::{
29
DpControlMessage, DpControlOutput, EngineCoreFinishReason, EngineCoreOutput, EngineCoreOutputs,
@@ -191,7 +191,7 @@ fn sample_multimodal_request() -> EngineCoreRequest {
191
}),
192
},
193
)])),
194
- modality: "image".to_string(),
194
+ modality: MmModality::Image,
195
identifier: "mm-cache-key".to_string(),
196
mm_position: PlaceholderRange {
197
offset: 1,
rust/src/server/src/grpc/tests.rs
+1
-1
@@ -728,7 +728,7 @@ async fn unary_generate_prepares_multimodal_input_for_engine_core() {
728
assert_eq!(features.len(), 2);
729
730
for (feature, identifier) in features.iter().zip(["image-1", "image-2"]) {
731
- assert_eq!(feature.modality, "image");
731
+ assert_eq!(feature.modality.as_str(), "image");
732
assert_eq!(feature.identifier, identifier);
733
assert!(feature.mm_position.length > 1);
734
assert_eq!(
rust/src/server/src/routes/tests.rs
+2
-2
@@ -2736,7 +2736,7 @@ async fn non_stream_chat_image_url_reaches_engine_mm_features() {
2736
2737
let features = request.mm_features.as_ref().expect("multimodal features");
2738
assert_eq!(features.len(), 1);
2739
- assert_eq!(features[0].modality, "image");
2739
+ assert_eq!(features[0].modality.as_str(), "image");
2740
assert_eq!(features[0].identifier, "image-1");
2741
assert!(features[0].mm_position.length > 0);
2742
assert!(features[0].mm_position.is_embed.is_some());
@@ -2799,7 +2799,7 @@ async fn non_stream_chat_rejects_when_image_count_exceeds_limit_mm_per_prompt()
2799
default_stream_output_specs(),
2800
Arc::new(FakeChatBackend::with_multimodal_model_info(
2801
qwen_multimodal_model_info_with_limits(std::collections::HashMap::from([(
2802
- vllm_chat::multimodal::MmLimitModality::Image,
2802
+ vllm_chat::multimodal::MmModality::Image,
2803
vllm_chat::multimodal::MmLimitSpec::Count(1),
2804
)])),
2805
)),
rust/src/text/src/lower.rs
+4
-2
@@ -318,7 +318,9 @@ mod tests {
318
use std::collections::{BTreeSet, HashMap};
319
320
use serial_test::file_serial;
321
- use vllm_engine_core_client::protocol::multimodal::{MmFeatureSpec, PlaceholderRange};
321
+ use vllm_engine_core_client::protocol::multimodal::{
322
+ MmFeatureSpec, MmModality, PlaceholderRange,
323
+ };
324
use vllm_tokenizer::test_utils::TestTokenizer;
325
326
use super::*;
@@ -720,7 +722,7 @@ mod tests {
722
fn lower_text_request_moves_multimodal_features_to_generate_request() {
723
let features = vec![MmFeatureSpec {
724
data: None,
723
- modality: "image".to_string(),
725
+ modality: MmModality::Image,
726
identifier: "image-1".to_string(),
727
mm_position: PlaceholderRange {
728
offset: 2,