sglang-server remove opaque type (#38095)

This commit is contained in:
Rain Jiang
2026-09-08 00:41:31 -07:00
committed by GitHub
parent 5aab054ec8
commit b83a59835d
7 changed files with 638 additions and 326 deletions
+36 -35
View File
@@ -18,7 +18,7 @@ use sglang_mm::driver::{MAX_ITEMS_PER_REQUEST, MAX_REQUEST_BYTES};
use tokio::sync::Semaphore;
use crate::message::request::{GenerateRequest, MmData};
use crate::multi_modality::payload::{io_sources, item_count};
use crate::multi_modality::payload::io_sources;
/// Global bound on concurrent media fetches across all in-flight requests;
/// excess acquisitions queue on the semaphore without holding a thread.
@@ -40,23 +40,22 @@ pub async fn prefetch_all(
let Some(mm) = mm.as_deref() else {
return Ok(Vec::new());
};
let values = [
("image", mm.image_data.as_ref()),
("video", mm.video_data.as_ref()),
("audio", mm.audio_data.as_ref()),
let modalities = [
("image", &mm.image_data),
("video", &mm.video_data),
("audio", &mm.audio_data),
];
let items = values
let items = modalities
.iter()
.filter_map(|(_, value)| *value)
.map(item_count)
.map(|(_, items)| items.len())
.sum::<usize>();
if items > MAX_ITEMS_PER_REQUEST {
return Err(format!(
"multimodal request exceeds {MAX_ITEMS_PER_REQUEST} media items"
));
}
for (modality, value) in values {
let count = value.map(item_count).unwrap_or_default();
for (modality, items) in modalities {
let count = items.len();
if let Some(limit) = modality_limits.get(modality)
&& count > *limit
{
@@ -66,10 +65,9 @@ pub async fn prefetch_all(
));
}
}
Ok(values
Ok(modalities
.iter()
.filter_map(|(_, value)| *value)
.flat_map(io_sources)
.flat_map(|(_, items)| io_sources(items))
.collect())
};
let plans = requests
@@ -120,9 +118,12 @@ async fn fetch_ordered(sources: Vec<String>, total_bytes: u64) -> Result<Vec<Byt
#[cfg(test)]
mod tests {
use rmpv::Value;
use super::*;
use crate::message::multimodal::MmItem;
fn src(s: impl Into<String>) -> MmItem {
MmItem::Source(s.into())
}
fn serve(bodies: Vec<Vec<u8>>) -> std::net::SocketAddr {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
@@ -149,10 +150,10 @@ mod tests {
addr
}
fn mm_request(image_data: Value) -> GenerateRequest {
fn mm_request(image_data: Vec<MmItem>) -> GenerateRequest {
GenerateRequest {
mm: Some(Box::new(MmData {
image_data: Some(image_data),
image_data,
..Default::default()
})),
..Default::default()
@@ -173,9 +174,9 @@ mod tests {
}
let mut requests = vec![GenerateRequest {
mm: Some(Box::new(MmData {
image_data: Some(Value::from(paths[0].display().to_string())),
video_data: Some(Value::from(paths[1].display().to_string())),
audio_data: Some(Value::from(paths[2].display().to_string())),
image_data: vec![src(paths[0].display().to_string())],
video_data: vec![src(paths[1].display().to_string())],
audio_data: vec![src(paths[2].display().to_string())],
..Default::default()
})),
..Default::default()
@@ -197,12 +198,12 @@ mod tests {
let path = std::env::temp_dir().join(format!("sglang-prefetch-{}", std::process::id()));
std::fs::write(&path, b"zzz").unwrap();
let mut requests = vec![
mm_request(Value::Array(vec![
Value::from(format!("http://{addr}/a.png")),
Value::from("data:image/png;base64,x"),
Value::from(format!("http://{addr}/b.png")),
Value::from(path.display().to_string()),
])),
mm_request(vec![
src(format!("http://{addr}/a.png")),
src("data:image/png;base64,x"),
src(format!("http://{addr}/b.png")),
src(path.display().to_string()),
]),
GenerateRequest::default(),
];
prefetch_all(&mut requests, &BTreeMap::new()).await.unwrap();
@@ -218,7 +219,7 @@ mod tests {
#[tokio::test]
async fn failed_download_rejects() {
let mut requests = vec![mm_request(Value::from("http://127.0.0.1:1/nope.png"))];
let mut requests = vec![mm_request(vec![src("http://127.0.0.1:1/nope.png")])];
let err = prefetch_all(&mut requests, &BTreeMap::new())
.await
.err()
@@ -230,10 +231,10 @@ mod tests {
/// fail to fetch, so a fetch error would prove fetching started.
#[tokio::test]
async fn item_budget_rejects_before_fetching() {
let sources: Vec<Value> = (0..=MAX_ITEMS_PER_REQUEST)
.map(|i| Value::from(format!("/definitely/not/here-{i}.png")))
let sources: Vec<MmItem> = (0..=MAX_ITEMS_PER_REQUEST)
.map(|i| src(format!("/definitely/not/here-{i}.png")))
.collect();
let mut requests = vec![mm_request(Value::Array(sources))];
let mut requests = vec![mm_request(sources)];
let err = prefetch_all(&mut requests, &BTreeMap::new())
.await
.err()
@@ -249,11 +250,11 @@ mod tests {
async fn per_modality_budget_rejects_before_fetching() {
let mut requests = vec![GenerateRequest {
mm: Some(Box::new(MmData {
image_data: Some(Value::Array(vec![
Value::from("/definitely/not/here-0.png"),
Value::from("/definitely/not/here-1.png"),
])),
video_data: Some(Value::Array(vec![Value::from("/definitely/not/here.mp4")])),
image_data: vec![
src("/definitely/not/here-0.png"),
src("/definitely/not/here-1.png"),
],
video_data: vec![src("/definitely/not/here.mp4")],
..Default::default()
})),
..Default::default()
+1
View File
@@ -7,6 +7,7 @@ pub mod detok;
pub mod finish_reason;
pub mod ids;
pub mod io_struct;
pub mod multimodal;
pub mod request;
pub mod response;
pub mod sampling;
@@ -0,0 +1,424 @@
//! Typed multimodal inputs of the `/generate` body — the Rust form of Python
//! `MultimodalDataInputFormat` (`io_struct.py`) — and their per-request fan-out.
use std::fmt;
use serde::de::value::{MapAccessDeserializer, SeqAccessDeserializer};
use serde::de::{MapAccess, SeqAccess, Visitor};
use serde::{Deserialize, Deserializer};
use super::request::{HeapBytes, check_broadcast_budget};
use crate::utils::error::Error;
/// One media item: Python `MultimodalDataInputItem` as it can arrive over JSON.
/// `bytes` and PIL images exist only on the in-process Engine path, so they have
/// no variant here.
#[derive(Debug, Clone, PartialEq)]
pub enum MmItem {
/// URL, `file://` / absolute path, `data:` URI, or bare base64 (Python `str`).
Source(String),
/// Python `ImageData` / `VideoData` (`{"url": …, …}`). Only `url` is kept:
/// the hint keys (`detail`, `max_dynamic_patch`, `preprocess_kwargs`, ...)
/// are read by model families this pipeline does not run, and Python's
/// `load_image` itself reduces the item to `.url`.
Ref { url: String },
/// A preprocessed item (`{"format": "processor_output" | "precomputed_embedding", …}`).
/// Parsed only far enough to be rejected by name at the MM stage; Python
/// ignores it the same way on a text-only model.
Preprocessed { format: String },
}
impl MmItem {
/// The raw source string for the modality pipeline, `None` for a
/// preprocessed item.
pub fn source(&self) -> Option<&str> {
match self {
MmItem::Source(source) | MmItem::Ref { url: source } => Some(source),
MmItem::Preprocessed { .. } => None,
}
}
}
impl HeapBytes for MmItem {
fn heap_bytes(&self) -> usize {
match self {
MmItem::Source(s) | MmItem::Ref { url: s } | MmItem::Preprocessed { format: s } => {
s.len()
}
}
}
}
/// The object form of an item, as Python's `Dict[str, Any]`: `format` marks a
/// preprocessed item (checked first, as `glm4v` does), `url` an `ImageData`.
#[derive(Deserialize)]
struct ItemObject {
#[serde(default)]
url: Option<String>,
#[serde(default)]
format: Option<String>,
}
impl TryFrom<ItemObject> for MmItem {
type Error = &'static str;
fn try_from(object: ItemObject) -> Result<Self, Self::Error> {
match (object.format, object.url) {
(Some(format), _) => Ok(MmItem::Preprocessed { format }),
(None, Some(url)) => Ok(MmItem::Ref { url }),
(None, None) => Err("a multimodal item object needs a `url` or a `format` key"),
}
}
}
/// Hand-written rather than `#[serde(untagged)]` so a bad item is reported as
/// what it is ("expected a source string or an item object"), not as "did not
/// match any variant".
impl<'de> Deserialize<'de> for MmItem {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct ItemVisitor;
impl<'de> Visitor<'de> for ItemVisitor {
type Value = MmItem;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a media source string or a multimodal item object")
}
fn visit_str<E: serde::de::Error>(self, value: &str) -> Result<Self::Value, E> {
Ok(MmItem::Source(value.to_owned()))
}
fn visit_string<E: serde::de::Error>(self, value: String) -> Result<Self::Value, E> {
Ok(MmItem::Source(value))
}
fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<Self::Value, A::Error> {
ItemObject::deserialize(MapAccessDeserializer::new(map))?
.try_into()
.map_err(serde::de::Error::custom)
}
}
deserializer.deserialize_any(ItemVisitor)
}
}
/// One `image_data` / `video_data` / `audio_data` field as sent: Python
/// `MultimodalDataInputFormat`, whose three shapes read differently for a single
/// request and a batch (see [`fan_out`]).
#[derive(Debug, Clone, PartialEq)]
pub enum MmDataInput {
/// One item: a single request's whole input, or a broadcast to every batch entry.
One(MmItem),
/// A flat list: a single request's items, or one item per batch entry.
Many(Vec<Option<MmItem>>),
/// One item list per batch entry.
Nested(Vec<Option<Vec<Option<MmItem>>>>),
}
/// One element of a list-form field, before the list is known to be flat or nested.
enum ListElement {
Null,
Item(MmItem),
List(Vec<Option<MmItem>>),
}
impl<'de> Deserialize<'de> for ListElement {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct ElementVisitor;
impl<'de> Visitor<'de> for ElementVisitor {
type Value = ListElement;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("null, a media source string, an item object, or a list of items")
}
fn visit_unit<E: serde::de::Error>(self) -> Result<Self::Value, E> {
Ok(ListElement::Null)
}
fn visit_none<E: serde::de::Error>(self) -> Result<Self::Value, E> {
Ok(ListElement::Null)
}
fn visit_str<E: serde::de::Error>(self, value: &str) -> Result<Self::Value, E> {
Ok(ListElement::Item(MmItem::Source(value.to_owned())))
}
fn visit_string<E: serde::de::Error>(self, value: String) -> Result<Self::Value, E> {
Ok(ListElement::Item(MmItem::Source(value)))
}
fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<Self::Value, A::Error> {
ItemObject::deserialize(MapAccessDeserializer::new(map))?
.try_into()
.map(ListElement::Item)
.map_err(serde::de::Error::custom)
}
fn visit_seq<A: SeqAccess<'de>>(self, seq: A) -> Result<Self::Value, A::Error> {
Vec::<Option<MmItem>>::deserialize(SeqAccessDeserializer::new(seq))
.map(ListElement::List)
}
}
deserializer.deserialize_any(ElementVisitor)
}
}
impl<'de> Deserialize<'de> for MmDataInput {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct InputVisitor;
impl<'de> Visitor<'de> for InputVisitor {
type Value = MmDataInput;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a media item, a list of items, or a list of item lists")
}
fn visit_str<E: serde::de::Error>(self, value: &str) -> Result<Self::Value, E> {
Ok(MmDataInput::One(MmItem::Source(value.to_owned())))
}
fn visit_string<E: serde::de::Error>(self, value: String) -> Result<Self::Value, E> {
Ok(MmDataInput::One(MmItem::Source(value)))
}
fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<Self::Value, A::Error> {
ItemObject::deserialize(MapAccessDeserializer::new(map))?
.try_into()
.map(MmDataInput::One)
.map_err(serde::de::Error::custom)
}
fn visit_seq<A: SeqAccess<'de>>(self, seq: A) -> Result<Self::Value, A::Error> {
let elements = Vec::<ListElement>::deserialize(SeqAccessDeserializer::new(seq))?;
let nested = elements
.iter()
.any(|element| matches!(element, ListElement::List(_)));
if !nested {
return Ok(MmDataInput::Many(
elements
.into_iter()
.map(|element| match element {
ListElement::Item(item) => Some(item),
ListElement::Null => None,
ListElement::List(_) => unreachable!("checked above"),
})
.collect(),
));
}
elements
.into_iter()
.map(|element| match element {
ListElement::List(items) => Ok(Some(items)),
ListElement::Null => Ok(None),
ListElement::Item(_) => Err(serde::de::Error::custom(
"a nested list cannot mix bare items with item lists",
)),
})
.collect::<Result<_, _>>()
.map(MmDataInput::Nested)
}
}
deserializer.deserialize_any(InputVisitor)
}
}
/// The items of one modality for one request, `null` entries dropped.
fn present(items: Vec<Option<MmItem>>) -> Vec<MmItem> {
items.into_iter().flatten().collect()
}
/// Fan one field into per-request item lists (empty = no input for that
/// request), mirroring Python `_normalize_{image,video,audio}_data`:
/// * absent, `[]`, or all-`null` → no input (Python `has_valid_data`);
/// * single request → one item or a flat list, taken as is;
/// * batch + one item → broadcast to every entry;
/// * batch + list → per entry, length must equal the batch size.
///
/// The Python image path wraps a broadcast as `[[img]] * num` while video and
/// audio broadcast bare; the difference vanishes here because every request's
/// input is already an item list.
pub fn fan_out(
value: Option<MmDataInput>,
n: usize,
is_batch: bool,
name: &str,
) -> Result<Vec<Vec<MmItem>>, Error> {
let Some(value) = value else {
return Ok(vec![Vec::new(); n]);
};
if !is_batch {
return match value {
MmDataInput::One(item) => Ok(vec![vec![item]]),
MmDataInput::Many(items) => Ok(vec![present(items)]),
MmDataInput::Nested(_) => Err(Error::Validation(format!(
"{name}: a nested list is the batch form; a single request takes one item or a flat list"
))),
};
}
match value {
MmDataInput::One(item) => {
// A broadcast deep-clones once per prompt — same blow-up as
// sampling_params, so bound the product before any clone.
check_broadcast_budget(item.heap_bytes(), n, name)?;
Ok(vec![vec![item]; n])
}
MmDataInput::Many(items) if items.is_empty() => Ok(vec![Vec::new(); n]),
MmDataInput::Many(items) => {
check_len(items.len(), n, name)?;
Ok(items
.into_iter()
.map(|item| item.into_iter().collect())
.collect())
}
MmDataInput::Nested(lists) => {
check_len(lists.len(), n, name)?;
Ok(lists
.into_iter()
.map(|items| items.map(present).unwrap_or_default())
.collect())
}
}
}
fn check_len(len: usize, n: usize, name: &str) -> Result<(), Error> {
if len != n {
return Err(Error::Validation(format!(
"{name}: list length {len} does not match batch size {n}"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(json: &str) -> Result<MmDataInput, serde_json::Error> {
serde_json::from_str(json)
}
fn src(s: &str) -> MmItem {
MmItem::Source(s.to_owned())
}
/// The three Python shapes parse to their own variants, with `null`
/// entries kept in place so batch fan-out can index them.
#[test]
fn parses_python_shapes() {
assert_eq!(parse(r#""u""#).unwrap(), MmDataInput::One(src("u")));
assert_eq!(
parse(r#"["a", null, "b"]"#).unwrap(),
MmDataInput::Many(vec![Some(src("a")), None, Some(src("b"))])
);
assert_eq!(parse("[]").unwrap(), MmDataInput::Many(vec![]));
assert_eq!(
parse(r#"[["a", null], null, []]"#).unwrap(),
MmDataInput::Nested(vec![Some(vec![Some(src("a")), None]), None, Some(vec![])])
);
}
/// Object items: `format` wins over `url` (a preprocessed item may carry
/// both), and an object with neither is named in the error.
#[test]
fn parses_item_objects() {
assert_eq!(
parse(r#"{"url": "u", "detail": "high"}"#).unwrap(),
MmDataInput::One(MmItem::Ref { url: "u".into() })
);
assert_eq!(
parse(r#"[{"format": "processor_output", "url": "u", "pixel_values": [1]}]"#).unwrap(),
MmDataInput::Many(vec![Some(MmItem::Preprocessed {
format: "processor_output".into()
})])
);
let err = parse(r#"{"detail": "high"}"#).unwrap_err().to_string();
assert!(err.contains("`url` or a `format`"), "{err}");
}
/// Anything Python's item union does not cover is rejected up front, with
/// the expected shape in the message.
#[test]
fn rejects_non_items() {
for (json, expect) in [
("5", "expected a media item, a list of items"),
(r#"["a", 5]"#, "expected null, a media source string"),
(r#"["a", ["b"]]"#, "cannot mix"),
(
r#"[[["a"]]]"#,
"expected a media source string or a multimodal item object",
),
] {
let err = parse(json).unwrap_err().to_string();
assert!(err.contains(expect), "{json}: {err}");
}
}
#[test]
fn single_request_takes_item_or_flat_list() {
assert_eq!(fan_out(None, 1, false, "image_data").unwrap(), vec![vec![]]);
assert_eq!(
fan_out(Some(MmDataInput::One(src("u"))), 1, false, "image_data").unwrap(),
vec![vec![src("u")]]
);
assert_eq!(
fan_out(
Some(parse(r#"["a", null, "b"]"#).unwrap()),
1,
false,
"image_data"
)
.unwrap(),
vec![vec![src("a"), src("b")]]
);
assert_eq!(
fan_out(Some(parse("[null]").unwrap()), 1, false, "image_data").unwrap(),
vec![vec![]]
);
let err = fan_out(Some(parse(r#"[["a"]]"#).unwrap()), 1, false, "image_data").unwrap_err();
assert!(err.to_string().contains("batch form"), "{err}");
}
#[test]
fn batch_broadcasts_scalar_and_splits_lists() {
let one = fan_out(Some(MmDataInput::One(src("u"))), 2, true, "video_data").unwrap();
assert_eq!(one, vec![vec![src("u")], vec![src("u")]]);
let flat = fan_out(
Some(parse(r#"["a", null]"#).unwrap()),
2,
true,
"image_data",
)
.unwrap();
assert_eq!(flat, vec![vec![src("a")], vec![]]);
let nested = fan_out(
Some(parse(r#"[["a", "b"], null, [null]]"#).unwrap()),
3,
true,
"image_data",
)
.unwrap();
assert_eq!(nested, vec![vec![src("a"), src("b")], vec![], vec![]]);
// `[]` is "no input", not a length-0 per-entry list.
assert_eq!(
fan_out(Some(parse("[]").unwrap()), 2, true, "image_data").unwrap(),
vec![vec![], vec![]]
);
for json in [r#"["a"]"#, r#"[["a"]]"#] {
let err = fan_out(Some(parse(json).unwrap()), 2, true, "image_data").unwrap_err();
assert!(
err.to_string().contains("does not match batch size"),
"{json}: {err}"
);
}
}
}
+94 -160
View File
@@ -9,6 +9,7 @@ use itertools::izip;
use serde::Deserialize;
use super::io_struct::{ControlRequest, TokenizedGenerateReqInput};
use super::multimodal::{self, MmDataInput, MmItem};
use super::response::ResponseSink;
use super::sampling::{SamplingParams, SamplingParamsInput};
use super::types::{OneOrMany, OneOrManyItem, TokenIds};
@@ -93,19 +94,17 @@ pub struct GenerateBody {
/// DP routing hints — per-request scalars even for batches, as in Python.
pub routed_dp_rank: Option<i64>,
pub disagg_prefill_dp_rank: Option<i64>,
// Multimodal inputs, permissive `Value` so any shape Python's
// `GenerateReqInput` accepts (URL / base64 / list / list-of-lists) parses.
// `into_requests` fans them out per the Python
// `_normalize_{image,video,audio}_data` batch rules.
pub image_data: Option<rmpv::Value>,
// Multimodal inputs (Python `MultimodalDataInputFormat`), fanned out per
// request by `multimodal::fan_out`.
pub image_data: Option<MmDataInput>,
/// Caller-supplied per-item content hashes (hex) overriding the computed
/// ones, so an external router's keys align with the prefix cache. Single
/// requests only: Python declares the batched shapes but `__getitem__` never
/// forwards them, so a batch is rejected here rather than answered with
/// hashes it did not ask for.
pub mm_hashes: Option<rmpv::Value>,
pub video_data: Option<rmpv::Value>,
pub audio_data: Option<rmpv::Value>,
/// requests only: Python declares the batched (nested) shape but
/// `__getitem__` never forwards it, so a batch is rejected here rather than
/// answered with hashes it did not ask for.
pub mm_hashes: Option<OneOrMany<Vec<String>>>,
pub video_data: Option<MmDataInput>,
pub audio_data: Option<MmDataInput>,
}
impl GenerateBody {
@@ -340,18 +339,26 @@ impl GenerateBody {
// servers different prefix-cache keys for the same body. Reject instead of
// dropping it silently as Python does — the field exists to align a
// caller's keys, so ignoring it returns subtly wrong ones.
if is_batch && mm_value_present(&mm_hashes) {
return Err(Error::Validation(
"mm_hashes is not supported for batch requests; send one request per prompt".into(),
));
}
// Multimodal columns; see `split_mm_column` for the Python parity rules.
let images = split_mm_column(image_data, n, is_batch, MmBroadcast::WrapInList)
.map_err(|e| Error::Validation(format!("image_data: {e}")))?;
let videos = split_mm_column(video_data, n, is_batch, MmBroadcast::AsIs)
.map_err(|e| Error::Validation(format!("video_data: {e}")))?;
let audios = split_mm_column(audio_data, n, is_batch, MmBroadcast::AsIs)
.map_err(|e| Error::Validation(format!("audio_data: {e}")))?;
let mm_hashes: Vec<String> = match mm_hashes {
None => Vec::new(),
Some(OneOrMany::One(hashes)) if hashes.is_empty() => Vec::new(),
Some(_) if is_batch => {
return Err(Error::Validation(
"mm_hashes is not supported for batch requests; send one request per prompt"
.into(),
));
}
Some(OneOrMany::One(hashes)) => hashes,
Some(OneOrMany::Many(_)) => {
return Err(Error::Validation(
"mm_hashes must be a flat list of hex strings for a single request".into(),
));
}
};
// Multimodal columns; see `multimodal::fan_out` for the Python parity rules.
let images = multimodal::fan_out(image_data, n, is_batch, "image_data")?;
let videos = multimodal::fan_out(video_data, n, is_batch, "video_data")?;
let audios = multimodal::fan_out(audio_data, n, is_batch, "audio_data")?;
// Every column above is exactly `n` long, so zip them by value: each
// request takes ownership of its cell, with no indexing or bounds checks.
@@ -425,14 +432,8 @@ impl GenerateBody {
.collect();
// Single requests only (batches rejected above). Malformed entries are
// dropped here and warned about in `mm::apply_caller_hashes`, never a 400.
if !is_batch
&& let (Some(rmpv::Value::Array(vals)), Some(req)) = (mm_hashes, requests.first_mut())
&& let Some(mm) = req.mm.as_deref_mut()
{
mm.mm_hashes = vals
.iter()
.filter_map(|v| v.as_str().map(str::to_owned))
.collect();
if let Some(mm) = requests.first_mut().and_then(|req| req.mm.as_deref_mut()) {
mm.mm_hashes = mm_hashes;
}
Ok((requests, is_batch))
}
@@ -441,11 +442,11 @@ impl GenerateBody {
/// Box the per-item mm values, `None` when the item has none — the common
/// text-only case keeps `GenerateRequest` slim.
fn pack_mm(
image_data: Option<rmpv::Value>,
video_data: Option<rmpv::Value>,
audio_data: Option<rmpv::Value>,
image_data: Vec<MmItem>,
video_data: Vec<MmItem>,
audio_data: Vec<MmItem>,
) -> Option<Box<MmData>> {
if image_data.is_none() && video_data.is_none() && audio_data.is_none() {
if image_data.is_empty() && video_data.is_empty() && audio_data.is_empty() {
return None;
}
Some(Box::new(MmData {
@@ -456,60 +457,6 @@ fn pack_mm(
}))
}
/// How a scalar mm value broadcasts across a batch: images become a one-image
/// list per item (`[[img]] * num` in Python `_normalize_image_data`),
/// video/audio broadcast bare (`[v] * num` in `_normalize_video_data`).
#[derive(Clone, Copy)]
enum MmBroadcast {
WrapInList,
AsIs,
}
/// Fan one mm field into per-item values, mirroring Python's
/// `_normalize_{image,video,audio}_data`:
/// * `None` / empty list → `None` for every item;
/// * single request → the raw value passes through (the processor wraps a
/// non-list into a one-element list);
/// * batch + scalar → broadcast to every item, per `MmBroadcast`;
/// * batch + list → per item, length must equal the batch size.
fn split_mm_column(
v: Option<rmpv::Value>,
n: usize,
is_batch: bool,
broadcast: MmBroadcast,
) -> Result<Vec<Option<rmpv::Value>>, String> {
let Some(v) = v else {
return Ok(vec![None; n]);
};
if v.is_nil() {
return Ok(vec![None; n]);
}
if !is_batch {
return Ok(vec![Some(v)]);
}
match v {
rmpv::Value::Array(items) if items.is_empty() => Ok(vec![None; n]),
rmpv::Value::Array(items) => {
if items.len() != n {
return Err(format!(
"list length {} does not match batch size {n}",
items.len()
));
}
Ok(items.into_iter().map(Some).collect())
}
scalar => {
// A broadcast deep-clones once per prompt — same blow-up as
// sampling_params above, so bound the product before any clone.
check_broadcast_budget(scalar.heap_bytes(), n, "value").map_err(|e| e.to_string())?;
Ok(match broadcast {
MmBroadcast::WrapInList => vec![Some(rmpv::Value::Array(vec![scalar])); n],
MmBroadcast::AsIs => vec![Some(scalar); n],
})
}
}
}
/// One request handed to the MM worker pool: the rid to correlate the result,
/// plus the owned inputs from [`GenerateRequest::take_mm_work`].
#[derive(Debug)]
@@ -524,22 +471,15 @@ pub struct MmRequest {
pub struct MmWorkItem {
pub text: Option<String>,
pub input_ids: Option<Vec<i32>>,
pub image_data: Option<rmpv::Value>,
pub video_data: Option<rmpv::Value>,
pub audio_data: Option<rmpv::Value>,
pub image_data: Vec<MmItem>,
pub video_data: Vec<MmItem>,
pub audio_data: Vec<MmItem>,
/// See [`MmData::prefetched`].
pub prefetched: Vec<Bytes>,
/// See [`GenerateBody::mm_hashes`].
pub mm_hashes: Vec<String>,
}
/// Whether an optional mm field counts as multimodal input, via the same
/// `value_present` the MM worker's payload parser uses.
fn mm_value_present(v: &Option<rmpv::Value>) -> bool {
v.as_ref()
.is_some_and(crate::multi_modality::payload::value_present)
}
/// The owned request as it travels request stages (single owner, so `state` is
/// mutated lock-free). Common fields here; variant data in [`RequestKind`].
#[derive(Debug)]
@@ -649,22 +589,23 @@ pub struct GenerateRequest {
/// so these are pure passthrough for the scheduler/LB protocol.
pub routed_dp_rank: Option<i64>,
pub disagg_prefill_dp_rank: Option<i64>,
/// Multimodal inputs, carried opaquely. Consumed by the Encoding stage,
/// which ships them to the MM worker pool; never read by the tokenizer or
/// serialized onto the scheduler header. Boxed so the common text-only
/// request doesn't grow every `Request` moved between stages.
/// Multimodal inputs. Consumed by the Encoding stage, which ships them to
/// the MM worker pool; never read by the tokenizer or serialized onto the
/// scheduler header. Boxed so the common text-only request doesn't grow
/// every `Request` moved between stages.
pub mm: Option<Box<MmData>>,
}
/// The opaque multimodal fields of one request (see [`GenerateRequest::mm`]).
/// The multimodal fields of one request (see [`GenerateRequest::mm`]), each
/// modality already fanned out to this request's own item list.
///
/// Constructed directly only by tests: `api_server::prefetch` fills its
/// `prefetched` field, everything else gets it packed inside a `GenerateRequest`.
#[derive(Debug, Default)]
pub struct MmData {
pub image_data: Option<rmpv::Value>,
pub video_data: Option<rmpv::Value>,
pub audio_data: Option<rmpv::Value>,
pub image_data: Vec<MmItem>,
pub video_data: Vec<MmItem>,
pub audio_data: Vec<MmItem>,
/// Bytes of `image_data`'s I/O-backed sources, resolved by
/// `api_server::prefetch` in `payload::io_sources` order so MM workers
/// never block on I/O. Out-of-band: the values above stay as the client
@@ -684,9 +625,7 @@ impl GenerateRequest {
/// Python `GenerateReqInput.contains_mm_input()`.
pub fn has_multimodal(&self) -> bool {
self.mm.as_ref().is_some_and(|mm| {
mm_value_present(&mm.image_data)
|| mm_value_present(&mm.video_data)
|| mm_value_present(&mm.audio_data)
!mm.image_data.is_empty() || !mm.video_data.is_empty() || !mm.audio_data.is_empty()
})
}
@@ -700,9 +639,9 @@ impl GenerateRequest {
..Default::default()
};
if let Some(m) = self.mm.as_deref_mut() {
work.image_data = m.image_data.take();
work.video_data = m.video_data.take();
work.audio_data = m.audio_data.take();
work.image_data = std::mem::take(&mut m.image_data);
work.video_data = std::mem::take(&mut m.video_data);
work.audio_data = std::mem::take(&mut m.audio_data);
work.prefetched = std::mem::take(&mut m.prefetched);
work.mm_hashes = std::mem::take(&mut m.mm_hashes);
}
@@ -730,7 +669,7 @@ impl GenerateRequest {
/// each, a scalar broadcasts, a list must match the batch size.
/// Bytes a broadcast value costs per clone. Only the heap matters — the inline
/// part is bounded by the type.
trait HeapBytes {
pub(super) trait HeapBytes {
fn heap_bytes(&self) -> usize;
}
impl HeapBytes for bool {
@@ -758,23 +697,6 @@ impl<T: HeapBytes> HeapBytes for Option<T> {
self.as_ref().map_or(0, HeapBytes::heap_bytes)
}
}
impl HeapBytes for rmpv::Value {
fn heap_bytes(&self) -> usize {
use rmpv::Value;
const NODE: usize = std::mem::size_of::<rmpv::Value>();
match self {
Value::String(s) => s.as_bytes().len(),
Value::Binary(b) => b.len(),
Value::Ext(_, b) => b.len(),
Value::Array(items) => items.iter().map(|v| NODE + v.heap_bytes()).sum(),
Value::Map(entries) => entries
.iter()
.map(|(k, v)| 2 * NODE + k.heap_bytes() + v.heap_bytes())
.sum(),
_ => 0,
}
}
}
/// Collapse `fan_out`'s nullable-element output: outer `None` (field absent /
/// scalar broadcast of nothing) and inner `None` (an explicit `null` list
@@ -784,7 +706,7 @@ fn flatten_column<T>(column: Vec<Option<Option<T>>>) -> Vec<Option<T>> {
}
/// Reject a broadcast whose clones would exceed [`MAX_BROADCAST_CLONE_BYTES`].
fn check_broadcast_budget(per_clone: usize, n: usize, name: &str) -> Result<(), Error> {
pub(super) fn check_broadcast_budget(per_clone: usize, n: usize, name: &str) -> Result<(), Error> {
// `n == 1` is not a broadcast — there is one value and one prompt, so nothing
// is duplicated. Charging it here rejected ordinary single requests with a
// message about a batch they never sent.
@@ -999,32 +921,43 @@ mod tests {
}
/// Mm columns fan out per Python `_normalize_{image,video}_data`: a single
/// request passes the raw value through; a batch broadcasts a scalar image as
/// `[img]` per item, maps a list per item with matching lengths, and treats
/// `null`/`[]` as absent.
/// request keeps its items; a batch broadcasts a scalar to every item, maps
/// a list per item with matching lengths, and treats `null`/`[]` as absent.
#[test]
fn split_mm_fanout_matches_python_normalize() {
let image_of = |p: &GenerateRequest| p.mm.as_ref().unwrap().image_data.clone().unwrap();
let src = |s: &str| MmItem::Source(s.to_owned());
let images_of = |p: &GenerateRequest| p.mm.as_ref().unwrap().image_data.clone();
// Single request: raw value passes through untouched.
// Single request: one item, or a flat list, kept as sent.
let (ps, _) = requests(r#"{"text": "a", "image_data": "http://x/i.jpg"}"#).unwrap();
assert_eq!(image_of(&ps[0]).as_str(), Some("http://x/i.jpg"));
assert_eq!(images_of(&ps[0]), vec![src("http://x/i.jpg")]);
assert!(ps[0].has_multimodal());
let (ps, _) = requests(r#"{"text": "a", "image_data": ["u1", {"url": "u2"}]}"#).unwrap();
assert_eq!(
images_of(&ps[0]),
vec![src("u1"), MmItem::Ref { url: "u2".into() }]
);
// Batch + scalar image: broadcast, wrapped as a one-image list per item.
// Batch + scalar image: broadcast, one image per item.
let (ps, _) = requests(r#"{"text": ["a", "b"], "image_data": "u"}"#).unwrap();
for p in &ps {
assert_eq!(image_of(p).as_array().unwrap().len(), 1);
assert_eq!(images_of(p), vec![src("u")]);
assert!(p.has_multimodal());
}
// Batch + per-item list: element i goes to item i.
// Batch + per-item list: element i goes to item i; nested lists are
// per-item lists.
let (ps, _) = requests(r#"{"text": ["a", "b"], "image_data": ["u1", "u2"]}"#).unwrap();
assert_eq!(image_of(&ps[0]).as_str(), Some("u1"));
assert_eq!(image_of(&ps[1]).as_str(), Some("u2"));
assert_eq!(images_of(&ps[0]), vec![src("u1")]);
assert_eq!(images_of(&ps[1]), vec![src("u2")]);
let (ps, _) =
requests(r#"{"text": ["a", "b"], "image_data": [["u1", "u2"], null]}"#).unwrap();
assert_eq!(images_of(&ps[0]), vec![src("u1"), src("u2")]);
assert!(!ps[1].has_multimodal());
// Batch + wrong-length list is a 400.
// Batch + wrong-length list is a 400, as is the batch shape on a single.
assert!(requests(r#"{"text": ["a", "b"], "image_data": ["u1"]}"#).is_err());
assert!(requests(r#"{"text": "a", "image_data": [["u1"]]}"#).is_err());
// null / [] mean "no multimodal input".
let (ps, _) = requests(r#"{"text": "a", "image_data": null}"#).unwrap();
@@ -1032,11 +965,10 @@ mod tests {
let (ps, _) = requests(r#"{"text": "a", "image_data": []}"#).unwrap();
assert!(!ps[0].has_multimodal());
// Batch + scalar video: broadcast bare (not wrapped), per Python
// `_normalize_video_data`.
// Batch + scalar video broadcasts too (Python leaves it unwrapped, but
// every request's input is an item list here).
let (ps, _) = requests(r#"{"text": ["a", "b"], "video_data": "v"}"#).unwrap();
let video = ps[1].mm.as_ref().unwrap().video_data.clone().unwrap();
assert_eq!(video.as_str(), Some("v"));
assert_eq!(ps[1].mm.as_ref().unwrap().video_data, vec![src("v")]);
assert!(ps[1].has_multimodal());
}
@@ -1045,17 +977,17 @@ mod tests {
/// and are never charged.
#[test]
fn oversized_mm_broadcast_rejected() {
let big = rmpv::Value::from("x".repeat(MAX_BROADCAST_CLONE_BYTES / 2 + 1));
let err = split_mm_column(Some(big.clone()), 2, true, MmBroadcast::WrapInList)
let big = MmItem::Source("x".repeat(MAX_BROADCAST_CLONE_BYTES / 2 + 1));
let err = multimodal::fan_out(Some(MmDataInput::One(big.clone())), 2, true, "image_data")
.err()
.unwrap();
assert!(err.contains("broadcast"), "{err}");
assert!(err.to_string().contains("broadcast"), "{err}");
// A per-item list of the same total size moves, not clones: accepted.
let list = rmpv::Value::Array(vec![big, rmpv::Value::from("y")]);
assert!(split_mm_column(Some(list), 2, true, MmBroadcast::WrapInList).is_ok());
let list = MmDataInput::Many(vec![Some(big), Some(MmItem::Source("y".into()))]);
assert!(multimodal::fan_out(Some(list), 2, true, "image_data").is_ok());
// Small scalars broadcast fine.
let small = rmpv::Value::from("u1");
assert!(split_mm_column(Some(small), 2, true, MmBroadcast::AsIs).is_ok());
let small = MmDataInput::One(MmItem::Source("u1".into()));
assert!(multimodal::fan_out(Some(small), 2, true, "audio_data").is_ok());
}
/// `mm_hashes` rides only on single requests (Python `__getitem__`
@@ -1068,10 +1000,12 @@ mod tests {
assert_eq!(ps[0].take_mm_work().mm_hashes, vec!["a1b2", "0xff"]);
assert!(ps[0].mm.as_ref().unwrap().mm_hashes.is_empty());
// A batch cannot carry hashes (Python drops them), so it is rejected...
// A batch cannot carry hashes (Python drops them), so it is rejected,
// as is the nested batch shape on a single request...
for body in [
r#"{"text": ["a", "b"], "image_data": ["u", "v"], "mm_hashes": [["x"], ["y"]]}"#,
r#"{"text": ["a", "b"], "image_data": ["u", "v"], "mm_hashes": ["x", "y"]}"#,
r#"{"text": "a", "image_data": "u", "mm_hashes": [["x"]]}"#,
] {
let err = requests(body).err().unwrap();
assert!(matches!(err, Error::Validation(_)), "{body}: {err:?}");
@@ -1094,11 +1028,11 @@ mod tests {
let work = ps[0].take_mm_work();
assert_eq!(work.text.as_deref(), Some("hi"));
assert!(work.input_ids.is_none());
assert_eq!(work.image_data.unwrap().as_array().unwrap().len(), 2);
assert!(work.video_data.is_none());
assert_eq!(work.audio_data.unwrap().as_str(), Some("a"));
assert_eq!(work.image_data.len(), 2);
assert!(work.video_data.is_empty());
assert_eq!(work.audio_data, vec![MmItem::Source("a".into())]);
// Moved out, not cloned; `text` survives for the header.
assert!(ps[0].mm.as_ref().unwrap().image_data.is_none());
assert!(ps[0].mm.as_ref().unwrap().image_data.is_empty());
assert_eq!(ps[0].text.as_deref(), Some("hi"));
}
+8 -5
View File
@@ -28,11 +28,12 @@ pub enum OneOrMany<T: OneOrManyItem> {
/// a batch silently arrives as a single request. Those types need a
/// `deserialize_any` dispatch instead (see [`SamplingParamsInput`]).
///
/// [`TokenIds`] is the one member that does accept a sequence, and that ambiguity
/// is the intended semantics: flat `[1,2]` is one prompt's ids (or a broadcast),
/// `[[1],[2]]` is per-prompt — the shapes Python's `_normalize_batch`
/// distinguishes. `String` / `bool` / `i64` never match a list, so both forms
/// round-trip.
/// [`TokenIds`] and `Vec<String>` are the members that do accept a sequence, and
/// that ambiguity is the intended semantics: flat `[1,2]` is one prompt's ids
/// (or a broadcast), `[[1],[2]]` is per-prompt — the shapes Python's
/// `_normalize_batch` distinguishes, and `mm_hashes`'s
/// `Union[List[str], List[List[str]]]` reads the same way. `String` / `bool` /
/// `i64` never match a list, so both forms round-trip.
pub trait OneOrManyItem: sealed::SealedItem {}
impl<T: sealed::SealedItem> OneOrManyItem for T {}
@@ -45,6 +46,8 @@ mod sealed {
impl SealedItem for i64 {}
impl SealedItem for String {}
impl SealedItem for super::TokenIds {}
/// `mm_hashes`: a flat list is one request's hashes, nested is per-request.
impl SealedItem for Vec<String> {}
// Nullable elements for the PD bootstrap fields (`List[Optional[...]]` in
// Python — the PD router sends `bootstrap_port: [null, …]` when deferring to
// the scheduler's default port). A bare `null` never reaches `One(None)`: the
+68 -123
View File
@@ -6,16 +6,16 @@
//! precomputed features, …).
use bytes::Bytes;
use rmpv::Value;
use sglang_mm::driver::{ImageSource, MmInput};
use crate::message::multimodal::MmItem;
use crate::message::request::MmWorkItem;
/// True for sources the API layer must resolve before MM dispatch: I/O — network
/// *or* disk, since a network mount can hang past any HTTP timeout — never runs
/// on the fixed MM worker pool (see `api_server::prefetch`). `data:` and bare
/// base64 are pure CPU and stay on the worker. Lives next to `collect_images` so
/// the prefetch walk and the parse walk cannot drift.
/// base64 are pure CPU and stay on the worker. Lives next to [`image_source`]
/// so the prefetch walk and the parse walk cannot drift.
pub fn is_io_source(src: &str) -> bool {
src.starts_with("http://")
|| src.starts_with("https://")
@@ -23,103 +23,64 @@ pub fn is_io_source(src: &str) -> bool {
|| src.starts_with('/')
}
/// The I/O-backed sources of an `image_data` value, in `collect_images` order.
pub fn io_sources(value: &Value) -> Vec<String> {
let mut out = Vec::new();
let mut walk = |value: &Value| {
if let Some(src) = value.as_str().filter(|s| is_io_source(s)) {
out.push(src.to_owned());
}
};
if let Value::Array(values) = value {
values.iter().for_each(&mut walk);
} else {
walk(value);
}
out
}
/// How many media items an `image_data` value contributes, walked the way
/// [`collect_images`] walks it, so the item budget can reject before fetching.
pub fn item_count(value: &Value) -> usize {
match value {
Value::Nil => 0,
Value::Array(values) => values.iter().map(item_count).sum(),
_ => 1,
}
/// The I/O-backed sources of one modality's items, in item order.
pub fn io_sources(items: &[MmItem]) -> Vec<String> {
items
.iter()
.filter_map(MmItem::source)
.filter(|src| is_io_source(src))
.map(str::to_owned)
.collect()
}
/// I/O-backed sources are swapped for their `work.prefetched` bytes (in
/// [`io_sources`] order); one left without an entry is an internal error here,
/// never a fetch.
pub fn to_mm_input(work: MmWorkItem) -> Result<MmInput, String> {
let present = |v: &Option<Value>| v.as_ref().is_some_and(value_present);
if present(&work.video_data) || present(&work.audio_data) {
let MmWorkItem {
text,
input_ids,
image_data,
video_data,
audio_data,
prefetched,
mm_hashes: _,
} = work;
if !video_data.is_empty() || !audio_data.is_empty() {
return Err("unsupported modality: video/audio input".into());
}
let mut images = Vec::new();
if let Some(image_data) = &work.image_data {
collect_images(image_data, &mut work.prefetched.iter(), &mut images)?;
}
let mut prefetched = prefetched.iter();
let images = image_data
.into_iter()
.map(|item| image_source(item, &mut prefetched))
.collect::<Result<Vec<_>, _>>()?;
if images.is_empty() {
return Err("no raw image sources in mm input".into());
}
Ok(MmInput {
text: work.text,
input_ids: work.input_ids,
text,
input_ids,
images,
})
}
fn collect_images(
value: &Value,
fn image_source(
item: MmItem,
prefetched: &mut std::slice::Iter<Bytes>,
out: &mut Vec<ImageSource>,
) -> Result<(), String> {
match value {
Value::Nil => Ok(()),
Value::String(value) => {
let value = value
.as_str()
.ok_or_else(|| "non-utf8 image source".to_string())?;
if is_io_source(value) {
let bytes = prefetched
.next()
.ok_or_else(|| "I/O-backed image source was not prefetched".to_string())?;
out.push(ImageSource::Bytes(bytes.to_vec()));
} else {
out.push(ImageSource::String(value.to_owned()));
) -> Result<ImageSource, String> {
match item {
MmItem::Source(source) | MmItem::Ref { url: source } => {
if !is_io_source(&source) {
return Ok(ImageSource::String(source));
}
Ok(())
prefetched
.next()
.map(|bytes| ImageSource::Bytes(bytes.to_vec()))
.ok_or_else(|| "I/O-backed image source was not prefetched".to_string())
}
Value::Binary(value) => {
out.push(ImageSource::Bytes(value.clone()));
Ok(())
}
Value::Array(values) => {
for value in values {
match value {
Value::String(_) | Value::Binary(_) | Value::Nil => {
collect_images(value, prefetched, out)?
}
_ => {
return Err("unsupported image_data shape: nested/typed item".into());
}
}
}
Ok(())
}
_ => Err("unsupported image_data shape".into()),
}
}
/// Rust mirror of Python `has_valid_data`: `nil` and (recursively) empty or
/// all-nil lists don't count as multimodal input.
pub fn value_present(value: &Value) -> bool {
match value {
Value::Nil => false,
Value::Array(values) => values.iter().any(value_present),
_ => true,
MmItem::Preprocessed { format } => Err(format!(
"unsupported image_data item: preprocessed `{format}` input"
)),
}
}
@@ -127,65 +88,55 @@ pub fn value_present(value: &Value) -> bool {
mod tests {
use super::*;
fn image_work(image: Value) -> MmWorkItem {
fn src(s: &str) -> MmItem {
MmItem::Source(s.to_owned())
}
fn image_work(image_data: Vec<MmItem>) -> MmWorkItem {
MmWorkItem {
text: Some("prompt".into()),
image_data: Some(image),
image_data,
..Default::default()
}
}
#[test]
fn converts_string_and_list_images() {
let one = to_mm_input(image_work(Value::from("data:image/png;base64,x"))).unwrap();
fn converts_source_and_ref_images() {
let one = to_mm_input(image_work(vec![src("data:image/png;base64,x")])).unwrap();
assert_eq!(one.images.len(), 1);
let many = to_mm_input(image_work(Value::Array(vec![
Value::from("a"),
Value::from("b"),
])))
.unwrap();
let many =
to_mm_input(image_work(vec![src("a"), MmItem::Ref { url: "b".into() }])).unwrap();
assert_eq!(many.images.len(), 2);
assert!(matches!(&many.images[1], ImageSource::String(s) if s == "b"));
}
#[test]
fn unsupported_modalities_and_shapes_rejected() {
fn unsupported_modalities_and_items_rejected() {
let video = MmWorkItem {
video_data: Some(Value::from("video.mp4")),
video_data: vec![src("video.mp4")],
..Default::default()
};
assert!(to_mm_input(video).err().unwrap().contains("video/audio"));
let dict = Value::Map(vec![(Value::from("format"), Value::from("x"))]);
assert!(
to_mm_input(image_work(Value::Array(vec![dict])))
.err()
.unwrap()
.contains("image_data shape")
);
}
#[test]
fn empty_video_audio_lists_are_not_modalities() {
// Mirrors Python `has_valid_data`: nil / empty lists don't count.
let work = MmWorkItem {
input_ids: Some(vec![1]),
image_data: Some(Value::from("a")),
video_data: Some(Value::Array(vec![])),
audio_data: Some(Value::Array(vec![Value::Array(vec![])])),
..Default::default()
};
assert_eq!(to_mm_input(work).unwrap().images.len(), 1);
let err = to_mm_input(image_work(vec![MmItem::Preprocessed {
format: "processor_output".into(),
}]))
.err()
.unwrap();
assert!(err.contains("preprocessed `processor_output`"), "{err}");
}
/// I/O-backed sources (URLs, file paths) take their prefetched bytes in walk
/// order; one left unfetched errors, so no I/O can reach an MM worker.
#[test]
fn io_sources_use_prefetched_bytes() {
let image = Value::Array(vec![
Value::from("http://a/x.png"),
Value::from("data:image/png;base64,x"),
Value::from("/mnt/nfs/y.png"),
]);
let image = vec![
src("http://a/x.png"),
src("data:image/png;base64,x"),
MmItem::Ref {
url: "/mnt/nfs/y.png".into(),
},
];
assert_eq!(io_sources(&image), vec!["http://a/x.png", "/mnt/nfs/y.png"]);
let mut work = image_work(image.clone());
@@ -205,12 +156,6 @@ mod tests {
#[test]
fn image_free_work_rejected() {
assert!(
to_mm_input(image_work(Value::Nil))
.err()
.unwrap()
.contains("no raw image sources")
);
assert!(
to_mm_input(MmWorkItem::default())
.err()
@@ -644,7 +644,9 @@ fn multimodal_sentinel_is_validated_after_expansion() {
};
g.input_ids = Some(vec![1, -103, 2]);
g.mm = Some(Box::new(crate::message::request::MmData {
audio_data: Some(rmpv::Value::from("data:audio/wav;base64,xxxx")),
audio_data: vec![crate::message::multimodal::MmItem::Source(
"data:audio/wav;base64,xxxx".into(),
)],
..Default::default()
}));
@@ -771,7 +773,9 @@ fn mm_generate_req(rid: &str) -> Request {
rid: rid.to_string().into(),
text: Some("<image> hi".into()),
mm: Some(Box::new(crate::message::request::MmData {
image_data: Some(rmpv::Value::from("data:image/jpeg;base64,xxxx")),
image_data: vec![crate::message::multimodal::MmItem::Source(
"data:image/jpeg;base64,xxxx".into(),
)],
..Default::default()
})),
..Default::default()
@@ -826,7 +830,7 @@ fn mm_request_parks_then_mm_encoded_pushes_to_ring() {
assert_eq!(sub.work.text.as_deref(), Some("<image> hi"));
assert!(sub.work.input_ids.is_none(), "no client input_ids");
assert_eq!(
sub.work.image_data.as_ref().and_then(|v| v.as_str()),
sub.work.image_data.first().and_then(|item| item.source()),
Some("data:image/jpeg;base64,xxxx")
);
assert!(consumer.drain(16).headers.is_empty(), "parked, not queued");