Skip to content
Closed
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions clients/openapi-gen/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,17 @@ fn main() -> anyhow::Result<()> {
&resp_name,
),
);
let req_name = collect_schema(schema_for!(CountMessageTokensRequest), &mut schemas)?;
let resp_name = collect_schema(schema_for!(CountMessageTokensResponse), &mut schemas)?;
paths.insert(
"/v1/messages/count_tokens".to_string(),
post_endpoint(
"countMessageTokens",
"Count message tokens (Anthropic)",
&req_name,
&resp_name,
),
);

// ---- Responses API ----
use openai_protocol::responses::*;
Expand Down
6 changes: 4 additions & 2 deletions crates/external_router/src/gemini/steps/request_building.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
//! Transition: BuildRequest → NonStreamRequest | StreamRequestWithTool | StreamRequest

use axum::response::Response;
use openai_protocol::common::to_value_exact;
use serde_json::Value;

use crate::{
Expand Down Expand Up @@ -36,8 +37,9 @@ pub(crate) async fn request_building(ctx: &mut RequestContext) -> Result<StepRes
));
}

// Serialize the original request as the upstream payload.
let mut payload = serde_json::to_value(ctx.input.original_request.as_ref()).map_err(|e| {
// Serialize the original request as the upstream payload, every number
// as the client wrote it.
let mut payload = to_value_exact(ctx.input.original_request.as_ref()).map_err(|e| {
tracing::error!(error = %e, "Failed to serialize Gemini interactions request");
error::internal_error("internal_error", "Failed to build upstream request payload")
})?;
Expand Down
5 changes: 2 additions & 3 deletions crates/external_router/src/openai/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ use axum::{
response::Response,
};
use futures_util::StreamExt;
use openai_protocol::{chat::ChatCompletionRequest, model_type::Endpoint};
use serde_json::to_value;
use openai_protocol::{chat::ChatCompletionRequest, common::to_value_exact, model_type::Endpoint};
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;

Expand Down Expand Up @@ -83,7 +82,7 @@ pub(super) async fn route_chat(
}
};

let mut payload = match to_value(&body) {
let mut payload = match to_value_exact(&body) {
Ok(v) => v,
Err(e) => {
Metrics::record_router_error(
Expand Down
5 changes: 0 additions & 5 deletions crates/multimodal/scripts/check_worker_anchor_parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,6 @@
"video": ("config_id", "video_token_index"),
},
},
"kimi_k3": {
"model_id": "moonshotai/Kimi-K3",
# Anchor = tokenizer token for the config's media_placeholder_token_id.
"anchors": {"image": ("config_id", "media_placeholder_token_id")},
},
"glm53_flash": {
"model_id": "zai-org/GLM-5.3-Flash",
# Anchor = tokenizer token for the config's image_token_id. Video is
Expand Down
15 changes: 15 additions & 0 deletions crates/multimodal/src/registry/glm53_flash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ impl ModelProcessorSpec for Glm53FlashSpec {
MediaPartOrder::Authored
}

/// z.ai answers a `.bmp` image with a 400.
fn rejected_image_formats(&self) -> &'static [image::ImageFormat] {
&[image::ImageFormat::Bmp]
}

fn placeholder_token(&self, _metadata: &ModelMetadata) -> RegistryResult<String> {
Ok(IMAGE.to_string())
}
Expand Down Expand Up @@ -410,6 +415,16 @@ mod tests {
));
}

#[test]
fn bmp_images_are_rejected_as_at_the_vendor() {
use crate::registry::ModelProcessorSpec;

assert_eq!(
Glm53FlashSpec.rejected_image_formats(),
&[image::ImageFormat::Bmp]
);
}

#[test]
fn only_the_image_anchor_can_be_expanded_by_the_worker() {
let spec = Glm53FlashSpec;
Expand Down
14 changes: 6 additions & 8 deletions crates/multimodal/src/registry/kimi_k3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,6 @@ impl ModelProcessorSpec for KimiK3VisionSpec {
Self::pad_token_id(metadata)
}

/// `<|media_pad|>` is the single token vLLM's `kimi_k3` prompt updates
/// replace with the dimensioned media block.
fn worker_expandable(&self, modality: Modality) -> bool {
matches!(modality, Modality::Image)
}

fn modality_limits(
&self,
_metadata: &ModelMetadata,
Expand Down Expand Up @@ -286,11 +280,15 @@ mod tests {
}

#[test]
fn image_anchor_can_be_expanded_by_the_worker() {
/// vLLM's `kimi_k3` prompt updates target `<|kimi_image_placeholder|>`
/// (id 163838), not the gateway's `<|media_pad|>` anchor (id 163605), so
/// a forwarded reference fails the engine's prompt replacement: media
/// stay router-side.
fn media_are_processed_router_side() {
use crate::registry::ModelProcessorSpec;

let spec = super::KimiK3VisionSpec;
assert!(spec.worker_expandable(Modality::Image));
assert!(!spec.worker_expandable(Modality::Image));
assert!(!spec.worker_expandable(Modality::Video));
assert!(!spec.worker_expandable(Modality::Audio));
}
Expand Down
159 changes: 158 additions & 1 deletion crates/multimodal/src/registry/traits.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::{collections::HashMap, sync::OnceLock};

use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine};
use serde_json::Value;
use thiserror::Error;

Expand All @@ -8,7 +9,8 @@ use crate::{
encoder_inputs::PreprocessedEncoderInputs,
media::FrameSampling,
types::{
EncoderFieldLayouts, FieldLayout, Modality, PromptReplacement, TokenId, VideoSamplingInfo,
EncoderFieldLayouts, FieldLayout, MediaContentPart, Modality, PromptReplacement, TokenId,
VideoSamplingInfo,
},
vision::PreProcessorConfig,
};
Expand Down Expand Up @@ -42,6 +44,8 @@ pub enum ModelRegistryError {
spec: &'static str,
modality: Modality,
},
#[error("{format} images are not accepted by model spec {spec}")]
UnsupportedImageFormat { spec: &'static str, format: String },
}

pub type RegistryResult<T> = Result<T, ModelRegistryError>;
Expand Down Expand Up @@ -73,6 +77,40 @@ fn modality_limit_override(modality: Modality) -> Option<usize> {
}
}

/// The image format a content part names: sniffed from the bytes (inline,
/// or decoded from a data URL) before the client's media type is believed,
/// else the media type in any case, else the URL path's extension. `None`
/// when nothing says (a URL without an extension, another modality).
fn image_format_of(part: &MediaContentPart) -> Option<image::ImageFormat> {
let from_media_type = |media_type: &str| {
image::ImageFormat::from_mime_type(media_type.trim().to_ascii_lowercase())
};
match part {
MediaContentPart::ImageData {
data, mime_type, ..
} => image::guess_format(data)
.ok()
.or_else(|| mime_type.as_deref().and_then(from_media_type)),
MediaContentPart::ImageUrl { url, .. } => {
if let Some(rest) = url.strip_prefix("data:") {
let (header, payload) = rest.split_once(',').unwrap_or((rest, ""));
let mut parameters = header.split(';');
let media_type = parameters.next().unwrap_or_default();
let sniffed = parameters
.any(|parameter| parameter.trim().eq_ignore_ascii_case("base64"))
.then(|| BASE64_STANDARD.decode(payload.trim()).ok())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nit: This decodes the entire base64 payload just to sniff the magic bytes — image::guess_format only reads the first few dozen bytes, but a multi-megabyte data-URL image gets a full transient copy allocated and thrown away at validation time, on every image part, before the image is decoded again downstream. Decoding a bounded prefix keeps the sniff O(1):

let trimmed = payload.trim().as_bytes();
let prefix = &trimmed[..trimmed.len().min(64) / 4 * 4];
// 64 base64 chars = 48 bytes, plenty for guess_format
BASE64_STANDARD.decode(prefix).ok()

(Cutting on a 4-char boundary keeps a prefix of valid standard base64 valid; slicing as_bytes() also avoids a str slice panic on a malformed multi-byte payload.)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3ac330c: the sniff decodes a bounded prefix (128 base64 characters, 96 bytes, whole quads), not the payload; a test case with a 4 KiB BMP data URL still sniffs as BMP.

.flatten()
.and_then(|bytes| image::guess_format(&bytes).ok());
return sniffed.or_else(|| from_media_type(media_type));
}
let path = url.split(['?', '#']).next().unwrap_or_default();
Comment on lines +94 to +118

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nit: MIME types are case-insensitive (RFC 2045), but image::ImageFormat::from_mime_type matches the lowercase string exactly. A data URL spelled data:image/BMP;base64,… (or an ImageData part whose mime_type is IMAGE/BMP when byte-sniffing fails) slips past rejected_image_formats and gets served where the vendor would 400. Lowercasing the media type before the lookup closes it:

image::ImageFormat::from_mime_type(media_type.to_ascii_lowercase())

(from_extension already lowercases, so the .BMP URL-path case is covered.)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 76bdde1: the media type is trimmed and ASCII-lowercased before from_mime_type, for a data URL and for an ImageData part's mime_type alike; test cases data:image/BMP and IMAGE/BMP.

let extension = path.rsplit('/').next()?.rsplit_once('.')?.1;
image::ImageFormat::from_extension(extension)
}
_ => None,
}
}

fn check_media_counts(
spec: &'static str,
limits: &HashMap<Modality, usize>,
Expand Down Expand Up @@ -255,6 +293,34 @@ pub trait ModelProcessorSpec: Send + Sync {
})
}

/// Image formats the model's vendor refuses; the gateway refuses them
/// up front, judged from the bytes, the data URL's type or the URL path.
fn rejected_image_formats(&self) -> &'static [image::ImageFormat] {
&[]
}

/// Refuse an image part whose format is one of
/// [`Self::rejected_image_formats`].
fn validate_image_formats(&self, parts: &[MediaContentPart]) -> RegistryResult<()> {
let rejected = self.rejected_image_formats();
if rejected.is_empty() {
return Ok(());
}
for part in parts {
if let Some(format) = image_format_of(part).filter(|f| rejected.contains(f)) {
return Err(ModelRegistryError::UnsupportedImageFormat {
spec: self.name(),
format: format
.extensions_str()
.first()
.map_or("unknown", |s| *s)
.to_string(),
});
}
}
Ok(())
}

fn processor_kwargs(&self, metadata: &ModelMetadata) -> RegistryResult<Value>;

/// Build the audio preprocessor for this model, if it supports audio.
Expand Down Expand Up @@ -506,6 +572,97 @@ mod tests {
assert!(!TestSpec.worker_expandable(Modality::Video));
}

#[test]
fn image_formats_are_accepted_unless_a_spec_rejects_them() {
assert!(TestSpec.rejected_image_formats().is_empty());
let bmp = MediaContentPart::ImageData {
data: b"BM\x00\x00".to_vec(),
mime_type: None,
uuid: None,
detail: None,
};
assert!(TestSpec
.validate_image_formats(std::slice::from_ref(&bmp))
.is_ok());

struct NoBmp;
impl ModelProcessorSpec for NoBmp {
fn name(&self) -> &'static str {
"no-bmp"
}
fn matches(&self, _metadata: &ModelMetadata) -> bool {
true
}
fn placeholder_token(&self, _metadata: &ModelMetadata) -> RegistryResult<String> {
Ok("<img>".to_string())
}
fn placeholder_token_id(&self, _metadata: &ModelMetadata) -> RegistryResult<TokenId> {
Ok(1)
}
fn modality_limits(
&self,
_metadata: &ModelMetadata,
) -> RegistryResult<HashMap<Modality, usize>> {
Ok(HashMap::from([(Modality::Image, 1)]))
}
fn processor_kwargs(&self, _metadata: &ModelMetadata) -> RegistryResult<Value> {
Ok(Value::Null)
}
fn prompt_replacements(
&self,
_metadata: &ModelMetadata,
_preprocessed: &PreprocessedEncoderInputs,
) -> RegistryResult<Vec<PromptReplacement>> {
Ok(Vec::new())
}
fn rejected_image_formats(&self) -> &'static [image::ImageFormat] {
&[image::ImageFormat::Bmp]
}
}
let png = MediaContentPart::ImageUrl {
url: "https://a/x.png".to_string(),
detail: None,
uuid: None,
max_long_side_pixel: None,
};
assert!(NoBmp
.validate_image_formats(std::slice::from_ref(&png))
.is_ok());
// Sniffed from the bytes (inline, or decoded from a data URL, before
// the client's media type is believed), from a data URL's type in
// any case, or from the URL path.
let data_url = |url: &str| MediaContentPart::ImageUrl {
url: url.to_string(),
detail: None,
uuid: None,
max_long_side_pixel: None,
};
let rejected = [
bmp,
MediaContentPart::ImageData {
data: b"zz".to_vec(),
mime_type: Some("IMAGE/BMP".to_string()),
uuid: None,
detail: None,
},
data_url("data:image/bmp;base64,Qk0AAA=="),
data_url("data:image/BMP;base64,Qk0AAA=="),
// BMP bytes behind a lying media type.
data_url("data:image/png;base64,Qk0AAAAA"),
data_url("https://a/scan.BMP?x=1"),
];
for part in &rejected {
assert_eq!(
NoBmp.validate_image_formats(std::slice::from_ref(part)),
Err(ModelRegistryError::UnsupportedImageFormat {
spec: "no-bmp",
format: "bmp".to_string(),
}),
"{part:?}"
);
}
}

#[test]
fn validation_accepts_any_declared_modality_subset() {
assert_eq!(validate(&TestSpec, &[(Modality::Image, 2)]), Ok(()));
Expand Down
31 changes: 31 additions & 0 deletions crates/protocols/src/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ use validator;
// Default value helpers
// ============================================================================

/// A typed request as a `Value` with every number as the client wrote it.
/// `serde_json::to_value` stores an `f32` widened to `f64`, so `"top_p":
/// 0.95` would go upstream as `0.949999988079071`; the writer prints each
/// `f32` in its shortest round-trip form, so encode with it and parse that
/// back. Only typed `f32` fields are exact this way: numbers kept as
/// `Value` (an `other` map, nested values) were normalized at parse time
/// (`1e2` is `100.0`, `-0` is `-0.0`).
pub fn to_value_exact<T: Serialize>(value: &T) -> serde_json::Result<Value> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nit: to_value_exact was inserted between default_unknown_model's doc comment and the function it documents. Lines 14–15 ("Default model for endpoints where model is optional…") now attach to to_value_exact's rustdoc, and default_unknown_model is left undocumented. Move the new function (with its own doc) above line 14 or below default_unknown_model.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 76bdde1: to_value_exact moved above the doc comment, which is back on default_unknown_model.

serde_json::from_slice(&serde_json::to_vec(value)?)
}

/// Default model for endpoints where model is optional (e.g., /generate).
/// Uses UNKNOWN_MODEL_ID so routers treat it as "any available worker."
pub fn default_unknown_model() -> String {
Expand Down Expand Up @@ -1079,6 +1090,26 @@ mod tests {

use super::*;

#[test]
fn to_value_exact_keeps_f32_fields_as_the_client_wrote_them() {
let request: crate::chat::ChatCompletionRequest = serde_json::from_value(json!({
"model": "glm-5.3-flash",
"messages": [{"role": "user", "content": "hi"}],
"top_p": 0.95,
"temperature": 0.7
}))
.unwrap();
// The widening this guards against: 0.95f32 is 0.949999988079071 as f64.
assert_ne!(
serde_json::to_value(&request).unwrap()["top_p"],
json!(0.95)
);
let exact = to_value_exact(&request).unwrap();
assert_eq!(exact["top_p"], json!(0.95));
assert_eq!(exact["temperature"], json!(0.7));
assert_eq!(exact["model"], json!("glm-5.3-flash"));
}

#[derive(Deserialize)]
struct NullableBoolTest {
#[serde(default, deserialize_with = "deserialize_null_as_false")]
Expand Down
Loading
Loading