use std::path::Path;
use std::sync::LazyLock;
use anyhow::{Context, Result, anyhow};
use llm_multimodal::vision::{PreProcessorConfig, VisionPreProcessor, VisionProcessorRegistry};
use llm_multimodal::{ModelMetadata, ModelRegistry};
use llm_tokenizer::traits::Tokenizer;
use llm_tokenizer::{Decoder, Encoder, Encoding, HuggingFaceTokenizer, SpecialTokens};
use crate::protocols::TokenIdType;
struct NullTokenizer;
impl Encoder for NullTokenizer {
fn encode(&self, _input: &str, _add_special_tokens: bool) -> anyhow::Result<Encoding> {
Ok(Encoding::Plain(Vec::new()))
}
fn encode_batch(
&self,
inputs: &[&str],
_add_special_tokens: bool,
) -> anyhow::Result<Vec<Encoding>> {
Ok(inputs.iter().map(|_| Encoding::Plain(Vec::new())).collect())
}
}
impl Decoder for NullTokenizer {
fn decode(&self, _ids: &[u32], _skip_special_tokens: bool) -> anyhow::Result<String> {
Ok(String::new())
}
}
impl Tokenizer for NullTokenizer {
fn vocab_size(&self) -> usize {
0
}
fn get_special_tokens(&self) -> &SpecialTokens {
static EMPTY: LazyLock<SpecialTokens> = LazyLock::new(SpecialTokens::default);
&EMPTY
}
fn token_to_id(&self, _token: &str) -> Option<u32> {
None
}
fn id_to_token(&self, _id: u32) -> Option<String> {
None
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
static REGISTRY: LazyLock<VisionProcessorRegistry> =
LazyLock::new(VisionProcessorRegistry::with_defaults);
static MODEL_REGISTRY: LazyLock<ModelRegistry> = LazyLock::new(ModelRegistry::new);
pub struct LightseekMmCounter {
processor: &'static dyn VisionPreProcessor,
config: PreProcessorConfig,
model_id: String,
}
impl LightseekMmCounter {
pub fn try_new(model_id: &str, model_type: Option<&str>, model_dir: &Path) -> Result<Self> {
let cfg_path = model_dir.join("preprocessor_config.json");
let json = std::fs::read_to_string(&cfg_path).with_context(|| {
format!(
"mm-routing: failed to read preprocessor_config.json at {}",
cfg_path.display()
)
})?;
let config = PreProcessorConfig::from_json(&json).with_context(|| {
format!(
"mm-routing: failed to parse preprocessor_config.json at {}",
cfg_path.display()
)
})?;
let processor = REGISTRY.find(model_id, model_type).ok_or_else(|| {
anyhow!(
"mm-routing: no image processor registered for model_id={:?} model_type={:?}",
model_id,
model_type
)
})?;
Ok(Self {
processor,
config,
model_id: model_id.to_string(),
})
}
pub fn count_tokens(&self, width: u32, height: u32) -> usize {
self.processor
.calculate_num_tokens(width, height, &self.config)
}
pub fn model_id(&self) -> &str {
&self.model_id
}
}
pub fn resolve_image_token_id(model_id: &str, model_dir: &Path) -> Option<TokenIdType> {
let config = read_json(model_dir, "config.json")?;
resolve_model_token_with_config(model_id, model_dir, &config).map(|resolved| resolved.token_id)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImagePromptKind {
RepeatedPad,
KimiK3,
}
impl LightseekMmCounter {
pub fn routing_prompt_kind(&self) -> Option<ImagePromptKind> {
match self.processor.model_name() {
"kimi-k3" => Some(ImagePromptKind::KimiK3),
"inkling" | "kimi-k2.5" | "llama4-vision" | "llava" | "llava-next" | "phi3-vision"
| "qwen2-vl" | "qwen3-omni" | "qwen3-vl" => Some(ImagePromptKind::RepeatedPad),
_ => None,
}
}
}
struct ResolvedModelToken {
token_id: TokenIdType,
}
fn resolve_model_token_with_config(
model_id: &str,
model_dir: &Path,
config: &serde_json::Value,
) -> Option<ResolvedModelToken> {
let tokenizer_path = model_dir.join("tokenizer.json");
let hf_tokenizer =
tokenizer_path
.to_str()
.and_then(|p| match HuggingFaceTokenizer::from_file(p) {
Ok(t) => Some(t),
Err(e) => {
tracing::debug!(
target: "mm_routing",
model_dir = %model_dir.display(),
err = %e,
"mm-routing: tokenizer.json not loaded; falling back to NullTokenizer"
);
None
}
});
let null_tokenizer = NullTokenizer;
let tokenizer: &dyn Tokenizer = match hf_tokenizer.as_ref() {
Some(t) => t,
None => &null_tokenizer,
};
let metadata = ModelMetadata {
model_id,
tokenizer,
config,
};
let spec = MODEL_REGISTRY.lookup(&metadata)?;
let id = spec
.placeholder_token_id(&metadata)
.map_err(|e| {
tracing::warn!(
target: "mm_routing",
model_id = %model_id,
err = %e,
"mm-routing: ModelProcessorSpec could not resolve placeholder_token_id"
);
e
})
.ok()?;
tracing::debug!(
target: "mm_routing",
model_id = %model_id,
image_token_id = id,
spec = spec.name(),
"resolved image-placeholder token id"
);
Some(ResolvedModelToken {
token_id: id as TokenIdType,
})
}
pub struct RoutingTokens {
pub image_token_id: Option<TokenIdType>,
pub chat_placeholder_token_id: Option<TokenIdType>,
pub image_prompt_kind: Option<ImagePromptKind>,
pub bos_token_string: Option<String>,
}
impl RoutingTokens {
pub fn exact_routing_image_token_id(
&self,
image_token_counter_available: bool,
) -> Option<TokenIdType> {
self.chat_placeholder_token_id
.filter(|_| image_token_counter_available && self.image_prompt_kind.is_some())
}
}
pub fn resolve_routing_tokens(
model_id: &str,
model_dir: &Path,
counter: Option<&LightseekMmCounter>,
) -> RoutingTokens {
let config = read_json(model_dir, "config.json");
let tokenizer_config = read_json(model_dir, "tokenizer_config.json");
let resolved = config
.as_ref()
.and_then(|c| resolve_model_token_with_config(model_id, model_dir, c));
let image_token_id = resolved.as_ref().map(|r| r.token_id);
let chat_placeholder_token_id = config
.as_ref()
.and_then(extract_chat_placeholder_from_config)
.or(image_token_id);
let image_prompt_kind =
chat_placeholder_token_id.and(counter.and_then(LightseekMmCounter::routing_prompt_kind));
let bos_token_string = tokenizer_config
.as_ref()
.and_then(extract_bos_token_from_tokenizer_config);
RoutingTokens {
image_token_id,
chat_placeholder_token_id,
image_prompt_kind,
bos_token_string,
}
}
pub fn resolve_exact_routing_image_token_id(
model_id: &str,
model_dir: &Path,
) -> Option<TokenIdType> {
let config = read_json(model_dir, "config.json")?;
let model_type = config.get("model_type").and_then(serde_json::Value::as_str);
let counter = LightseekMmCounter::try_new(model_id, model_type, model_dir).ok()?;
resolve_routing_tokens(model_id, model_dir, Some(&counter)).exact_routing_image_token_id(true)
}
fn read_json(model_dir: &Path, filename: &str) -> Option<serde_json::Value> {
let path = model_dir.join(filename);
let raw = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
Err(e) => {
tracing::warn!(
target: "mm_routing",
path = %path.display(),
err = %e,
"mm-routing: failed to read {filename}"
);
return None;
}
};
match serde_json::from_str(&raw) {
Ok(v) => Some(v),
Err(e) => {
tracing::warn!(
target: "mm_routing",
path = %path.display(),
err = %e,
"mm-routing: failed to parse {filename}"
);
None
}
}
}
fn extract_chat_placeholder_from_config(config: &serde_json::Value) -> Option<TokenIdType> {
config
.get("image_token_id")
.and_then(|x| x.as_u64())
.and_then(|id| u32::try_from(id).ok())
}
fn extract_bos_token_from_tokenizer_config(cfg: &serde_json::Value) -> Option<String> {
if !cfg
.get("add_bos_token")
.and_then(|x| x.as_bool())
.unwrap_or(false)
{
return None;
}
cfg.get("bos_token").and_then(|x| match x {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Object(o) => o
.get("content")
.and_then(|c| c.as_str())
.map(|s| s.to_owned()),
_ => None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn image_processor_registry_resolves_qwen3vl_via_path_substring() {
assert!(REGISTRY.find("Qwen/Qwen3-VL-2B-Instruct", None).is_some());
assert!(REGISTRY.find("/models/Qwen3-VL-2B/", None).is_some());
}
#[test]
fn image_processor_registry_uses_model_type_fallback() {
assert!(REGISTRY.find("/models/my-finetune", None).is_none());
assert!(
REGISTRY
.find("/models/my-finetune", Some("qwen3_vl"))
.is_some()
);
}
#[test]
fn counter_loads_hf_config_and_counts_known_qwen3_vl_dimensions() {
let model_dir = tempfile::tempdir().unwrap();
std::fs::write(
model_dir.path().join("preprocessor_config.json"),
serde_json::json!({
"patch_size": 16,
"merge_size": 2,
"min_pixels": 3136,
"max_pixels": 12_845_056,
"temporal_patch_size": 2
})
.to_string(),
)
.unwrap();
let counter = LightseekMmCounter::try_new(
"Qwen/Qwen3-VL-2B-Instruct",
Some("qwen3_vl"),
model_dir.path(),
)
.unwrap();
assert_eq!(counter.count_tokens(640, 480), 300);
}
fn write_model_config(model_dir: &Path, model_type: &str) {
std::fs::write(
model_dir.join("config.json"),
serde_json::json!({
"model_type": model_type,
"media_placeholder_token_id": 163605
})
.to_string(),
)
.unwrap();
}
#[test]
fn routing_tokens_classify_kimi_k3_via_selected_vision_processor() {
let model_dir = tempfile::tempdir().unwrap();
write_model_config(model_dir.path(), "kimi_k3");
std::fs::write(model_dir.path().join("preprocessor_config.json"), "{}").unwrap();
let counter = LightseekMmCounter::try_new(
"/models/internal-checkpoint",
Some("kimi_k3"),
model_dir.path(),
)
.unwrap();
assert_eq!(counter.routing_prompt_kind(), Some(ImagePromptKind::KimiK3));
let resolved = resolve_routing_tokens(
"/models/internal-checkpoint",
model_dir.path(),
Some(&counter),
);
assert_eq!(resolved.image_token_id, Some(163605));
assert_eq!(resolved.chat_placeholder_token_id, Some(163605));
assert_eq!(resolved.image_prompt_kind, Some(ImagePromptKind::KimiK3));
}
#[test]
fn routing_tokens_keep_kimi_k2_on_repeated_pad_prompt_shape() {
let model_dir = tempfile::tempdir().unwrap();
write_model_config(model_dir.path(), "kimi_k25");
std::fs::write(model_dir.path().join("preprocessor_config.json"), "{}").unwrap();
let counter =
LightseekMmCounter::try_new("moonshotai/Kimi-K2.6", Some("kimi_k25"), model_dir.path())
.unwrap();
assert_eq!(
counter.routing_prompt_kind(),
Some(ImagePromptKind::RepeatedPad)
);
let resolved =
resolve_routing_tokens("moonshotai/Kimi-K2.6", model_dir.path(), Some(&counter));
assert_eq!(resolved.image_token_id, Some(163605));
assert_eq!(
resolved.image_prompt_kind,
Some(ImagePromptKind::RepeatedPad)
);
}
#[test]
fn routing_tokens_keep_phi3_vision_on_repeated_pad_prompt_shape() {
let model_dir = tempfile::tempdir().unwrap();
std::fs::write(
model_dir.path().join("config.json"),
serde_json::json!({
"model_type": "phi3_v",
"image_token_id": 32044
})
.to_string(),
)
.unwrap();
std::fs::write(model_dir.path().join("preprocessor_config.json"), "{}").unwrap();
let counter = LightseekMmCounter::try_new(
"microsoft/Phi-3-vision-128k-instruct",
Some("phi3_v"),
model_dir.path(),
)
.unwrap();
assert_eq!(
counter.routing_prompt_kind(),
Some(ImagePromptKind::RepeatedPad)
);
let resolved = resolve_routing_tokens(
"microsoft/Phi-3-vision-128k-instruct",
model_dir.path(),
Some(&counter),
);
assert_eq!(
resolved.image_prompt_kind,
Some(ImagePromptKind::RepeatedPad)
);
}
#[test]
fn routing_tokens_keep_qwen3_omni_on_repeated_pad_prompt_shape() {
let model_dir = tempfile::tempdir().unwrap();
std::fs::write(
model_dir.path().join("config.json"),
serde_json::json!({
"model_type": "qwen3_omni_moe",
"thinker_config": {
"image_token_id": 151655
}
})
.to_string(),
)
.unwrap();
std::fs::write(model_dir.path().join("preprocessor_config.json"), "{}").unwrap();
let model_id = "Qwen/Qwen3-Omni-30B-A3B-Instruct";
let counter =
LightseekMmCounter::try_new(model_id, Some("qwen3_omni_moe"), model_dir.path())
.unwrap();
assert_eq!(
counter.routing_prompt_kind(),
Some(ImagePromptKind::RepeatedPad)
);
let resolved = resolve_routing_tokens(model_id, model_dir.path(), Some(&counter));
assert_eq!(resolved.chat_placeholder_token_id, Some(151655));
assert_eq!(
resolved.image_prompt_kind,
Some(ImagePromptKind::RepeatedPad)
);
assert_eq!(
resolve_exact_routing_image_token_id(model_id, model_dir.path()),
Some(151655)
);
}
#[test]
fn routing_prompt_classifies_inkling_as_repeated_pad() {
let model_dir = tempfile::tempdir().unwrap();
std::fs::write(model_dir.path().join("preprocessor_config.json"), "{}").unwrap();
let counter = LightseekMmCounter::try_new(
"/models/internal-checkpoint",
Some("inkling_mm_model"),
model_dir.path(),
)
.unwrap();
assert_eq!(
counter.routing_prompt_kind(),
Some(ImagePromptKind::RepeatedPad)
);
}
#[test]
fn explicit_placeholder_keeps_repeated_pad_for_generic_qwen_aliases() {
for model_type in ["qwen2_5_vl", "qwen3_6"] {
let model_dir = tempfile::tempdir().unwrap();
std::fs::write(
model_dir.path().join("config.json"),
serde_json::json!({
"model_type": model_type,
"image_token_id": 151655
})
.to_string(),
)
.unwrap();
std::fs::write(model_dir.path().join("preprocessor_config.json"), "{}").unwrap();
let counter = LightseekMmCounter::try_new(
"/models/vision-model",
Some(model_type),
model_dir.path(),
)
.unwrap();
let resolved =
resolve_routing_tokens("/models/vision-model", model_dir.path(), Some(&counter));
assert_eq!(
resolved.image_token_id, None,
"{model_type} should reproduce the independent ModelRegistry alias gap"
);
assert_eq!(resolved.chat_placeholder_token_id, Some(151655));
assert_eq!(
counter.routing_prompt_kind(),
Some(ImagePromptKind::RepeatedPad),
"{model_type} counter selection should also choose its routing layout"
);
assert_eq!(
resolved.image_prompt_kind,
Some(ImagePromptKind::RepeatedPad),
"{model_type} should retain exact routing through an explicit placeholder"
);
assert_eq!(
resolve_exact_routing_image_token_id("/models/vision-model", model_dir.path()),
Some(151655),
"{model_type} should be fully ready through the counter's model_type fallback"
);
}
}
#[test]
fn exact_worker_token_requires_counter_and_prompt_layout() {
let model_dir = tempfile::tempdir().unwrap();
write_model_config(model_dir.path(), "kimi_k3");
assert_eq!(
resolve_exact_routing_image_token_id("/models/internal-checkpoint", model_dir.path()),
None,
"a placeholder alone must not enable worker-side MM key rewriting"
);
std::fs::write(model_dir.path().join("preprocessor_config.json"), "{}").unwrap();
assert_eq!(
resolve_exact_routing_image_token_id("/models/internal-checkpoint", model_dir.path()),
Some(163605)
);
}
#[test]
fn image_processor_registry_covers_documented_families() {
const FAMILIES: &[(&str, &str, &str)] = &[
(
"Qwen3-Omni",
"Qwen/Qwen3-Omni-30B-A3B-Instruct",
"qwen3_omni_moe",
),
("Qwen3-VL", "Qwen/Qwen3-VL-2B-Instruct", "qwen3_vl"),
("Qwen2-VL", "Qwen/Qwen2-VL-7B-Instruct", "qwen2_vl"),
("Qwen2.5-VL", "Qwen/Qwen2.5-VL-7B-Instruct", "qwen2_5_vl"),
(
"LLaVA-NeXT",
"llava-hf/llava-v1.6-mistral-7b-hf",
"llava_next",
),
("LLaVA-1.5", "llava-hf/llava-1.5-7b-hf", "llava"),
("Llama-4", "meta-llama/Llama-4-Scout-17B-16E", "llama4"),
(
"Phi-3 Vision",
"microsoft/Phi-3-vision-128k-instruct",
"phi3_v",
),
("Kimi-K2.5", "moonshotai/Kimi-K2.5-Instruct", "kimi_k2_5"),
("Kimi-K2.6", "moonshotai/Kimi-K2.6-Instruct", "kimi_k2_6"),
("Qwen3.5", "Qwen/Qwen3.5-0.8B", "qwen3_5"),
("Qwen3.6", "Qwen/Qwen3.6-35B-A3B", "qwen3_6"),
("Kimi-K3", "moonshotai/Kimi-K3", "kimi_k3"),
("Inkling", "/models/inkling", "inkling_mm_model"),
];
let mut missing: Vec<&str> = Vec::new();
for (family, hf_id, model_type) in FAMILIES {
let by_id = REGISTRY.find(hf_id, None).is_some();
let by_type = REGISTRY.find("/local/finetune", Some(model_type)).is_some();
if !(by_id || by_type) {
missing.push(family);
}
}
assert!(
missing.is_empty(),
"image-processor registry has no processor for: {:?}. \
Either pick up an upstream release that registers these, or trim \
the supported-families list in docs.",
missing
);
}
}