diff --git a/docs/supported-models.md b/docs/supported-models.md
index a55081d1..6d464f09 100644
--- a/docs/supported-models.md
+++ b/docs/supported-models.md
@@ -83,6 +83,7 @@ Implemented VLM variants include:
 - GLM-4V (`glm4v`): GLM-4V ViT vision tower (3D patch embedding, bilinear-resampled learned position embeddings, Conv2d spatial downsample, SwiGLU patch merger) plus a GLM-4 text backbone driven by sectioned even/odd MRoPE. Reuses the shared Qwen-VL image processor and prompt/token plumbing.
 - GLM-4V MoE (`glm4v_moe`): GLM-4.5V-class variant reusing the GLM-4V ViT vision tower with a GLM-4 MoE text backbone (grouped `noaux_tc` routing, shared experts, `first_k_dense_replace` dense layers) driven by sectioned half-split MRoPE. Reuses the GLM-4 MoE machinery and the shared Qwen-VL runtime.
 - Granite Vision (`granite_vision`, or `llava_next` with a `granite` text config): IBM's document VLM. A SigLIP vision tower with four intermediate feature taps (concatenated on the channel axis) feeds a 2-layer GELU projector; a learned `image_newline` embedding, LLaVA-Next AnyRes multi-tile preprocessing (`image_grid_pinpoints`), and a dense Granite text backbone (embedding / attention / residual / logits multipliers) complete the model. Both `config.json` spellings route to the same loader. Best for document understanding, charts, and tables.
+- Granite 4 Vision (`granite4_vision`): IBM's document VLM with multi-depth visual injection. A SigLIP tower feeds eight window-QFormer projectors (a QFormer with self+cross attention per `4x4`/`8x8` window, with deepstack mean-pool and spatial strided-offset query downsamplers) whose packed outputs are added into the residual stream at eight different depths of a Granite 4 hybrid (`granitemoehybrid`) text backbone during prefill, rather than merged once. Reuses the shared AnyRes tiling and the four Granite scalar multipliers. Best for document understanding.
 - GLM-OCR (`glm_ocr`): document-OCR sibling of GLM-4V. A 24-block ViT (3D patch embedding, per-head q/k RMSNorm on the packed `cu_seqlens` attention, 2D vision RoPE, Conv2d spatial downsample, SwiGLU patch merger) feeds a 16-layer GLM-4 text decoder driven by full-width even/odd MRoPE (`rope_parameters` with `mrope_section [16, 24, 24]`, `partial_rotary_factor 1.0`). The tower has no learned position embedding or post-conv norm, and the loader drops the next-n prediction (MTP) layer. Patches are reordered from the processor's raster order into spatial-merge-window order so the rotary, downsample, and merged-token grid stay spatially aligned (OCR reads scrambled patches wrong). Best for plain text, tables, and formula recognition.
 - Youtu-VL
 - Kimi-VL and Kimi-VL 2.5 (`kimi_vl` / `kimi_k25`): MoonViT native-resolution vision encoder (Conv2d patch embedding, learned plus bicubically-interpolated 2D position embedding, a shared 2D rotary embedding, block-diagonal cross-image attention, and `spatial_merge_size` patch merging) feeding a `LayerNorm -> Linear -> GELU -> Linear` connector into a DeepSeek-V3-style MoE text backbone. Detected, loaded, and served end to end: the safetensors directory loader wires the MoonViT tower, the connector, and the DeepSeek-V3 MoE backbone; the native-resolution processor patchifies each image; and the runtime expands each `<|media_pad|>` placeholder into `(h/merge) * (w/merge)` tokens before the merged vision features replace them. Image path only; the Kimi-VL 2.5 3D MoonViT video (image plus video patch embedding) path is tracked as a separate follow-up.
diff --git a/src/commands/generate_vlm.rs b/src/commands/generate_vlm.rs
index 25df68d0..86e70d09 100644
--- a/src/commands/generate_vlm.rs
+++ b/src/commands/generate_vlm.rs
@@ -218,6 +218,15 @@ fn print_preparation_summary(summary: VlmPreparationSummary) {
                 image_blocks, total_image_tokens
             );
         }
+        VlmPreparationSummary::Granite4Vision {
+            image_blocks,
+            total_image_tokens,
+        } => {
+            println!(
+                "Granite 4 Vision: expanded {} <image> placeholder(s) ({} total image tokens)",
+                image_blocks, total_image_tokens
+            );
+        }
         VlmPreparationSummary::ImageBlocks(stats) => match stats.action {
             ImageTokenBlockAction::Expanded {
                 existing_image_count,
diff --git a/src/distributed/tensor_parallel/inference.rs b/src/distributed/tensor_parallel/inference.rs
index 32fe470b..db4826a7 100644
--- a/src/distributed/tensor_parallel/inference.rs
+++ b/src/distributed/tensor_parallel/inference.rs
@@ -258,6 +258,9 @@ fn fallback_architecture(model_type: ModelType) -> &'static str {
         // Granite Vision's text backbone is Granite; TP is refused for VLM-kind
         // models earlier, this keeps the dispatch table total.
         ModelType::GraniteVisionVLM => "granite",
+        // Granite 4 Vision's text backbone is granitemoehybrid; TP is refused for
+        // VLM-kind models earlier, this keeps the dispatch table total.
+        ModelType::Granite4VisionVLM => "granitemoehybrid",
         // Youtu-VL is not currently supported by tensor-parallel inference;
         // we return a placeholder architecture string here so the planner
         // does not panic on the dispatch table lookup. The actual loader
diff --git a/src/loaded_model.rs b/src/loaded_model.rs
index 49c4ef3c..30b14ee4 100644
--- a/src/loaded_model.rs
+++ b/src/loaded_model.rs
@@ -78,6 +78,7 @@ pub enum LoadedModel {
     MllamaVLM(vision::MllamaVLModel),
     LlavaVLM(vision::VisionLanguageModel),
     GraniteVisionVLM(vision::GraniteVisionVLModel),
+    Granite4VisionVLM(vision::Granite4VisionVLModel),
     Qwen2VL(vision::Qwen2VLModel),
     Qwen25VL(vision::Qwen25VLModel),
     Qwen3VL(vision::Qwen3VLModel),
@@ -210,6 +211,7 @@ macro_rules! delegate_language_model {
             LoadedModel::MllamaVLM(inner) => LanguageModel::$method(inner, $($arg),*),
             LoadedModel::LlavaVLM(inner) => LanguageModel::$method(inner, $($arg),*),
             LoadedModel::GraniteVisionVLM(inner) => LanguageModel::$method(inner, $($arg),*),
+            LoadedModel::Granite4VisionVLM(inner) => LanguageModel::$method(inner, $($arg),*),
             LoadedModel::Qwen2VL(inner) => LanguageModel::$method(inner, $($arg),*),
             LoadedModel::Qwen25VL(inner) => LanguageModel::$method(inner, $($arg),*),
             LoadedModel::Qwen3VL(inner) => LanguageModel::$method(inner, $($arg),*),
diff --git a/src/loaded_model_capabilities.rs b/src/loaded_model_capabilities.rs
index 8b93d2c2..a3f3b61f 100644
--- a/src/loaded_model_capabilities.rs
+++ b/src/loaded_model_capabilities.rs
@@ -61,6 +61,7 @@ pub enum VlmRuntimeRef<'a> {
     /// LFM2-VL (lfm2_vl) runtime.
     Lfm2Vl(&'a vision::Lfm2VlModel),
     GraniteVision(&'a vision::GraniteVisionVLModel),
+    Granite4Vision(&'a vision::Granite4VisionVLModel),
     Standard(&'a vision::VisionModule),
 }
 
@@ -175,6 +176,7 @@ impl LoadedModel {
             Self::Llama4VLM(vlm) => Some(VlmRuntimeRef::Standard(&vlm.vision)),
             Self::LlavaVLM(vlm) => Some(VlmRuntimeRef::Standard(&vlm.vision)),
             Self::GraniteVisionVLM(model) => Some(VlmRuntimeRef::GraniteVision(model)),
+            Self::Granite4VisionVLM(model) => Some(VlmRuntimeRef::Granite4Vision(model)),
             _ => None,
         }
     }
diff --git a/src/loading/mod.rs b/src/loading/mod.rs
index d6f9d9f3..d4c5671f 100644
--- a/src/loading/mod.rs
+++ b/src/loading/mod.rs
@@ -176,6 +176,7 @@ fn try_load_vlm_model_from_dir(
         ModelType::Gemma4Unified => Some(load_gemma4_unified(model_path)?),
         ModelType::LlavaVLM => Some(load_llava_vlm(model_path)?),
         ModelType::GraniteVisionVLM => Some(load_granite_vision_vlm(model_path)?),
+        ModelType::Granite4VisionVLM => Some(load_granite4_vision_vlm(model_path)?),
         ModelType::LlavaBunnyVLM => Some(load_llava_bunny_vlm(model_path)?),
         ModelType::AyaVisionVLM => Some(load_aya_vision_vlm(model_path)?),
         ModelType::PaliGemmaVLM => Some(load_paligemma_vlm(model_path)?),
diff --git a/src/loading/vlm.rs b/src/loading/vlm.rs
index bca16e91..cd379e76 100644
--- a/src/loading/vlm.rs
+++ b/src/loading/vlm.rs
@@ -41,6 +41,8 @@ use models::sanitize_config_json;
 mod gemma;
 #[path = "vlm_gemma_unified.rs"]
 mod gemma_unified;
+#[path = "vlm_granite4_vision.rs"]
+mod granite4_vision;
 #[path = "vlm_granite_vision.rs"]
 mod granite_vision;
 #[path = "vlm_idefics2.rs"]
@@ -75,6 +77,7 @@ mod youtu_vl_loader;
 pub(crate) use gemma::{load_gemma3_vlm, load_gemma3n_vlm, load_gemma4_vlm};
 pub(crate) use gemma_unified::load_gemma4_unified;
 pub(crate) use granite_vision::load_granite_vision_vlm;
+pub(crate) use granite4_vision::load_granite4_vision_vlm;
 pub(crate) use idefics2::load_idefics2_vlm;
 pub(crate) use internvl::load_internvl_vlm;
 pub(crate) use kimi_vl_loader::load_kimi_vl_vlm;
diff --git a/src/loading/vlm_granite4_vision.rs b/src/loading/vlm_granite4_vision.rs
new file mode 100644
index 00000000..80f5e465
--- /dev/null
+++ b/src/loading/vlm_granite4_vision.rs
@@ -0,0 +1,254 @@
+// Copyright 2025-2026 Lablup Inc. and Jeongkyu Shin
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Granite 4 Vision loader (SigLIP + 8 window-QFormer projectors + Granite 4
+//! hybrid text backbone with multi-depth injection).
+
+use anyhow::Result;
+use std::path::Path;
+
+use crate::LoadedModel;
+use crate::models;
+use crate::vision;
+use crate::vision::connectors::granite4_vision::{Downsampler, WindowQFormerProjector};
+
+use super::{load_vlm_weights_common, read_sanitized_vlm_config, strip_language_model_prefix};
+
+/// Default LLaVA-Next `image_grid_pinpoints` for Granite 4 Vision.
+fn default_pinpoints() -> Vec<(i32, i32)> {
+    let mut p: Vec<(i32, i32)> = Vec::new();
+    for w in (384..=3840).step_by(384) {
+        p.push((384, w));
+    }
+    for w in (384..=1920).step_by(384) {
+        p.push((768, w));
+    }
+    for w in (384..=1152).step_by(384) {
+        p.push((1152, w));
+    }
+    for h in [1536, 1920] {
+        for w in [384, 768] {
+            p.push((h, w));
+        }
+    }
+    for h in [2304, 2688, 3072, 3456, 3840] {
+        p.push((h, 384));
+    }
+    p
+}
+
+fn parse_pinpoints(full_config: &serde_json::Value) -> Vec<(i32, i32)> {
+    full_config
+        .get("image_grid_pinpoints")
+        .and_then(|v| v.as_array())
+        .map(|arr| {
+            arr.iter()
+                .filter_map(|pair| {
+                    let p = pair.as_array()?;
+                    Some((p.first()?.as_i64()? as i32, p.get(1)?.as_i64()? as i32))
+                })
+                .collect::<Vec<_>>()
+        })
+        .filter(|v| !v.is_empty())
+        .unwrap_or_else(default_pinpoints)
+}
+
+/// `downsample_rate` `"q/w"` -> `(q, w)`; defaults to `(4, 8)`.
+fn parse_downsample_rate(full_config: &serde_json::Value) -> (i32, i32) {
+    full_config
+        .get("downsample_rate")
+        .and_then(|v| v.as_str())
+        .and_then(|s| {
+            let mut it = s.split('/');
+            let q = it.next()?.trim().parse::<i32>().ok()?;
+            let w = it.next()?.trim().parse::<i32>().ok()?;
+            Some((q, w))
+        })
+        .unwrap_or((4, 8))
+}
+
+fn parse_i32_pairs(v: Option<&serde_json::Value>) -> Vec<[i32; 2]> {
+    v.and_then(|v| v.as_array())
+        .map(|arr| {
+            arr.iter()
+                .filter_map(|pair| {
+                    let p = pair.as_array()?;
+                    Some([p.first()?.as_i64()? as i32, p.get(1)?.as_i64()? as i32])
+                })
+                .collect()
+        })
+        .unwrap_or_default()
+}
+
+fn parse_i32_list(v: Option<&serde_json::Value>) -> Vec<i32> {
+    v.and_then(|v| v.as_array())
+        .map(|arr| {
+            arr.iter()
+                .filter_map(|x| x.as_i64().map(|i| i as i32))
+                .collect()
+        })
+        .unwrap_or_default()
+}
+
+/// Load a Granite 4 Vision VLM.
+pub(crate) fn load_granite4_vision_vlm(model_path: &Path) -> Result<LoadedModel> {
+    let (_config_str, full_config) = read_sanitized_vlm_config(model_path)?;
+
+    // Text backbone config: inherit top-level quantization, parse as granitemoehybrid.
+    let mut text_config_val = full_config
+        .get("text_config")
+        .cloned()
+        .ok_or_else(|| anyhow::anyhow!("Missing text_config in config.json"))?;
+    if let Some(obj) = text_config_val.as_object_mut()
+        && obj.get("quantization").is_none()
+        && let Some(q) = full_config.get("quantization")
+    {
+        obj.insert("quantization".to_string(), q.clone());
+    }
+    let text_args: models::granitemoehybrid::ModelArgs = serde_json::from_value(text_config_val)
+        .map_err(|e| anyhow::anyhow!("Failed to parse Granite 4 text config: {}", e))?;
+
+    let vision_config: vision::config::VisionConfig = serde_json::from_value(
+        full_config
+            .get("vision_config")
+            .cloned()
+            .ok_or_else(|| anyhow::anyhow!("Missing vision_config in config.json"))?,
+    )
+    .map_err(|e| anyhow::anyhow!("Failed to parse Granite 4 vision config: {}", e))?;
+
+    let group_size = text_args.group_size();
+    let bits = text_args.bits();
+    let (q, w) = parse_downsample_rate(&full_config);
+
+    let weights = strip_language_model_prefix(load_vlm_weights_common(model_path, None)?);
+
+    // Vision tower and 8 window-QFormer projectors read by ref before the text
+    // backbone consumes the weight map. The granite4 SigLIP tower uses the
+    // sigmoid `GELU(approx="fast")` in its MLP (unlike Granite Vision #539, which
+    // uses the tanh variant), matching the reference vision encoder.
+    let vision_tower =
+        vision::encoders::siglip::SigLipVisionModel::from_weights_with_quant_and_gelu(
+            &weights,
+            &vision_config,
+            "vision_tower.vision_model",
+            group_size,
+            bits,
+            true, // use_fast_gelu (sigmoid GELU)
+        )
+        .map_err(|e| anyhow::anyhow!("Failed to load Granite 4 vision tower: {}", e))?;
+
+    let spatial_offsets = [(0, 0), (0, 1), (1, 0), (1, 1)];
+    let mut projectors = Vec::with_capacity(8);
+    for i in 0..4 {
+        projectors.push(
+            WindowQFormerProjector::from_weights(
+                &weights,
+                &format!("layerwise_projectors.{i}"),
+                Downsampler::MeanPool,
+                q,
+                w,
+                group_size,
+                bits,
+            )
+            .map_err(|e| anyhow::anyhow!("Failed to load layerwise projector {i}: {}", e))?,
+        );
+    }
+    for (i, (row_off, col_off)) in spatial_offsets.iter().enumerate() {
+        projectors.push(
+            WindowQFormerProjector::from_weights(
+                &weights,
+                &format!("spatial_projectors.{i}"),
+                Downsampler::Strided {
+                    row_off: *row_off,
+                    col_off: *col_off,
+                },
+                q,
+                w,
+                group_size,
+                bits,
+            )
+            .map_err(|e| anyhow::anyhow!("Failed to load spatial projector {i}: {}", e))?,
+        );
+    }
+
+    let image_newline = weights
+        .get("image_newline")
+        .map(|x| mlxcel_core::copy(x))
+        .ok_or_else(|| anyhow::anyhow!("Missing image_newline weight"))?;
+
+    // Stream routing from config.
+    let deepstack_map = parse_i32_pairs(full_config.get("deepstack_layer_map"));
+    let spatial_targets = parse_i32_list(full_config.get("spatial_target_layers"));
+    let spatial_vision_layer = full_config
+        .get("spatial_vision_layer")
+        .and_then(|v| v.as_i64())
+        .unwrap_or(-1) as i32;
+    let use_spatial = full_config
+        .get("use_spatial_sampling")
+        .and_then(|v| v.as_bool())
+        .unwrap_or(true);
+
+    // Collect the deepstack vision taps; the spatial tap reuses one of them when
+    // present, otherwise it is appended.
+    let mut taps: Vec<i32> = deepstack_map.iter().map(|p| p[0]).collect();
+    let spatial_tap_idx = match taps.iter().position(|&t| t == spatial_vision_layer) {
+        Some(idx) => idx,
+        None => {
+            taps.push(spatial_vision_layer);
+            taps.len() - 1
+        }
+    };
+
+    let mut stream_specs: Vec<(usize, usize, usize)> = Vec::new();
+    for (i, pair) in deepstack_map.iter().enumerate() {
+        stream_specs.push((i, i, pair[1] as usize));
+    }
+    if use_spatial {
+        for (i, &layer) in spatial_targets.iter().enumerate() {
+            stream_specs.push((4 + i, spatial_tap_idx, layer as usize));
+        }
+    }
+
+    let image_token_index = full_config
+        .get("image_token_index")
+        .and_then(|v| v.as_i64())
+        .unwrap_or(100352) as i32;
+    let pinpoints = parse_pinpoints(&full_config);
+    let image_size = vision_config.image_size as u32;
+    let patch_grid = (vision_config.image_size / vision_config.patch_size) as i32; // 24
+    let feature_side = patch_grid / (w / q); // 24 / 2 = 12
+    let base_tokens = feature_side * feature_side; // 144
+    let processor = vision::processors::anyres::AnyResProcessor::new(pinpoints, image_size);
+
+    // Text backbone consumes the weight map last.
+    let eos_token_id = text_args.eos_token_ids().first().copied().unwrap_or(100257);
+    let text_model = models::GraniteMoeHybridModel::from_weights(text_args, weights)
+        .map_err(|e| anyhow::anyhow!("Failed to load Granite 4 text model: {}", e))?;
+
+    let vlm = vision::Granite4VisionVLModel::new(
+        text_model,
+        vision_tower,
+        projectors,
+        image_newline,
+        processor,
+        image_token_index,
+        taps,
+        stream_specs,
+        feature_side,
+        base_tokens,
+        eos_token_id,
+    );
+
+    Ok(LoadedModel::Granite4VisionVLM(vlm))
+}
diff --git a/src/model_metadata.rs b/src/model_metadata.rs
index 1c3e50ae..02ee0697 100644
--- a/src/model_metadata.rs
+++ b/src/model_metadata.rs
@@ -115,6 +115,7 @@ macro_rules! for_each_model_registration {
             Gemma4Unified => { kind: Vlm, directory: Vlm, weight: None, adapter: Some("Gemma4 Unified cannot be loaded with LoRA adapters yet") };
             LlavaVLM => { kind: Vlm, directory: Vlm, weight: None, adapter: Some("LLaVA VLM cannot be loaded with LoRA adapters yet") };
             GraniteVisionVLM => { kind: Vlm, directory: Vlm, weight: None, adapter: Some("Granite Vision VLM cannot be loaded with LoRA adapters yet") };
+            Granite4VisionVLM => { kind: Vlm, directory: Vlm, weight: None, adapter: Some("Granite 4 Vision VLM cannot be loaded with LoRA adapters yet") };
             LlavaBunnyVLM => { kind: Vlm, directory: Vlm, weight: None, adapter: Some("LLaVA VLM cannot be loaded with LoRA adapters yet") };
             AyaVisionVLM => { kind: Vlm, directory: Vlm, weight: None, adapter: Some("Aya Vision VLM cannot be loaded with LoRA adapters yet") };
             PaliGemmaVLM => { kind: Vlm, directory: Vlm, weight: None, adapter: Some("PaliGemma VLM cannot be loaded with LoRA adapters yet") };
diff --git a/src/models/detection.rs b/src/models/detection.rs
index 1597c3f0..4564c329 100644
--- a/src/models/detection.rs
+++ b/src/models/detection.rs
@@ -238,6 +238,7 @@ pub fn get_model_type(model_path: &Path) -> Result<ModelType> {
         "moondream3" => Ok(ModelType::Moondream3VLM),
         "moondream2" | "moondream1" => Ok(ModelType::Moondream2VLM),
         "granite_vision" => Ok(ModelType::GraniteVisionVLM),
+        "granite4_vision" => Ok(ModelType::Granite4VisionVLM),
         "llava" | "llava_next" => {
             // The original IBM Granite Vision checkpoint ships as `llava_next`
             // with a `granite` text backbone; route it to the Granite VLM.
diff --git a/src/models/granitemoehybrid.rs b/src/models/granitemoehybrid.rs
index fe0b4158..7db58a9a 100644
--- a/src/models/granitemoehybrid.rs
+++ b/src/models/granitemoehybrid.rs
@@ -821,6 +821,11 @@ impl GraniteMoeHybridSharedMLP {
 
 enum FeedForward {
     Dense(GraniteMoeHybridMLP),
+    /// Dense feed-forward kept in the fused `shared_mlp` form (a single
+    /// `input_linear` producing gate+up). Used for quantized dense checkpoints,
+    /// where the fused quantized `input_linear` cannot be split into separate
+    /// `mlp.gate_proj`/`up_proj` at load.
+    DenseShared(GraniteMoeHybridSharedMLP),
     Moe {
         moe: GraniteMoeHybridMoE,
         shared: GraniteMoeHybridSharedMLP,
@@ -831,6 +836,7 @@ impl FeedForward {
     fn forward(&self, x: &MlxArray) -> UniquePtr<MlxArray> {
         match self {
             FeedForward::Dense(mlp) => mlp.forward(x),
+            FeedForward::DenseShared(shared) => shared.forward(x),
             FeedForward::Moe { moe, shared } => {
                 let moe_out = moe.forward(x);
                 let shared_out = shared.forward(x);
@@ -946,6 +952,83 @@ impl GraniteMoeHybridLayerCache {
 
 // Granite 4.x model.
 
+/// Multi-depth visual injection for Granite 4 Vision: a per-target packed
+/// feature tensor `(total_image_tokens, hidden)` added at image-token positions
+/// right before the named decoder layer during prefill.
+pub struct HybridInjection<'a> {
+    /// `(1, seq)` mask (int/bool) that is nonzero at image-token positions.
+    pub visual_pos_mask: &'a MlxArray,
+    /// `(layer_index, features)` pairs; `features` is `(total_image_tokens, hidden)`.
+    pub targets: Vec<(usize, UniquePtr<MlxArray>)>,
+}
+
+/// Scatter-add `visual_embeds` `(n_img, hidden)` onto `h` `(1, seq, hidden)` at
+/// the nonzero positions of `visual_pos_mask` `(1, seq)`, in order. Batch-1 only
+/// (VLM prefill is unpadded batch-1). `pub(crate)` for the injection regression
+/// test (guards the `slice_update`-is-functional footgun).
+pub(crate) fn inject_at_positions(
+    h: &MlxArray,
+    visual_pos_mask: &MlxArray,
+    visual_embeds: &MlxArray,
+) -> UniquePtr<MlxArray> {
+    let h_shape = mlxcel_core::array_shape(h);
+    if h_shape[0] != 1 {
+        return mlxcel_core::copy(h);
+    }
+    let mask_1d = mlxcel_core::slice(visual_pos_mask, &[0, 0], &[1, h_shape[1]]);
+    let mask_1d = mlxcel_core::squeeze_axis(&mask_1d, 0);
+    let mask_i32 = mlxcel_core::astype(&mask_1d, mlxcel_core::dtype::INT32);
+    mlxcel_core::eval(&mask_i32);
+
+    let seq_len = h_shape[1] as usize;
+    let mut positions = Vec::new();
+    for i in 0..seq_len {
+        let val = mlxcel_core::slice(&mask_i32, &[i as i32], &[i as i32 + 1]);
+        mlxcel_core::eval(&val);
+        if mlxcel_core::item_i32(&val) != 0 {
+            positions.push(i as i32);
+        }
+    }
+    if positions.is_empty() {
+        return mlxcel_core::copy(h);
+    }
+
+    let hidden = h_shape[2];
+    let batch_h = mlxcel_core::slice(h, &[0, 0, 0], &[1, h_shape[1], hidden]);
+    let batch_h = mlxcel_core::squeeze_axis(&batch_h, 0);
+
+    let idx_arr = mlxcel_core::from_slice_i32(&positions, &[positions.len() as i32]);
+    let current = mlxcel_core::take(&batch_h, &idx_arr, 0);
+    let n_img = positions.len() as i32;
+    let embeds = mlxcel_core::slice(visual_embeds, &[0, 0], &[n_img, hidden]);
+    let embeds = mlxcel_core::astype(&embeds, mlxcel_core::array_dtype(&batch_h));
+    let updated = mlxcel_core::add(&current, &embeds);
+
+    // `slice_update` is functional (returns a new array); the result MUST be
+    // captured or the write is lost. Contiguous image runs (the single-image
+    // case) collapse to one slice_update; disjoint runs fall back to per-row.
+    let mut result = mlxcel_core::copy(&batch_h);
+    let contiguous = positions
+        .iter()
+        .enumerate()
+        .all(|(i, &p)| p == positions[0] + i as i32);
+    if contiguous {
+        let start = positions[0];
+        result =
+            mlxcel_core::slice_update(&result, &updated, &[start, 0], &[start + n_img, hidden]);
+    } else {
+        for (local_idx, &pos) in positions.iter().enumerate() {
+            let val = mlxcel_core::slice(
+                &updated,
+                &[local_idx as i32, 0],
+                &[local_idx as i32 + 1, hidden],
+            );
+            result = mlxcel_core::slice_update(&result, &val, &[pos, 0], &[pos + 1, hidden]);
+        }
+    }
+    mlxcel_core::expand_dims(&result, 0)
+}
+
 pub struct GraniteMoeHybridModel {
     config: ModelArgs,
     embed_tokens: UnifiedEmbedding,
@@ -974,8 +1057,50 @@ impl GraniteMoeHybridModel {
     ) -> UniquePtr<MlxArray> {
         // h = embed_tokens(x) * embedding_multiplier.
         let h = self.embed_tokens.forward(inputs);
-        let mut h = mlxcel_core::multiply_scalar(&h, self.embedding_multiplier);
+        let h = mlxcel_core::multiply_scalar(&h, self.embedding_multiplier);
+        self.run_from_embedded(h, caches, None)
+    }
+
+    /// Raw token-embedding lookup, no `embedding_multiplier`. Used by the
+    /// Granite 4 Vision wrapper, which zeroes image slots then relies on
+    /// multi-depth injection ([`HybridInjection`]) for image content.
+    pub fn input_embeddings(&self, input_ids: &MlxArray) -> UniquePtr<MlxArray> {
+        self.embed_tokens.forward(input_ids)
+    }
 
+    /// Forward from a pre-computed (un-scaled) embedding stream, applying
+    /// `embedding_multiplier` here, with optional multi-depth visual injection
+    /// (Granite 4 Vision). Routes through the model's own sequence state so
+    /// recurrent conv/SSM state and KV offsets advance exactly once.
+    pub(crate) fn forward_embeds_with_injection(
+        &self,
+        inputs_embeds: &MlxArray,
+        injection: Option<&HybridInjection>,
+        seq_id: Option<mlxcel_core::cache::SequenceId>,
+    ) -> UniquePtr<MlxArray> {
+        let run = |internal: &mut [GraniteMoeHybridLayerCache]| {
+            let h = mlxcel_core::multiply_scalar(inputs_embeds, self.embedding_multiplier);
+            self.run_from_embedded(h, internal, injection)
+        };
+        match seq_id {
+            Some(_) => self.sequence_state.with_or_create_sequence_state(
+                seq_id,
+                || GraniteMoeHybridModel::make_caches(self),
+                run,
+            ),
+            None => self.sequence_state.with_sequence_state(None, run),
+        }
+    }
+
+    /// Run the decoder stack + norm + head from an already-`embedding_multiplier`
+    /// scaled hidden state. Injects visual features at masked positions *before*
+    /// each targeted layer when `injection` is present.
+    fn run_from_embedded(
+        &self,
+        mut h: UniquePtr<MlxArray>,
+        caches: &mut [GraniteMoeHybridLayerCache],
+        injection: Option<&HybridInjection>,
+    ) -> UniquePtr<MlxArray> {
         let shape = mlxcel_core::array_shape(&h);
         let seq_len = shape[1];
 
@@ -993,7 +1118,16 @@ impl GraniteMoeHybridModel {
             None
         };
 
-        for (layer, cache) in self.layers.iter().zip(caches.iter_mut()) {
+        for (idx, (layer, cache)) in self.layers.iter().zip(caches.iter_mut()).enumerate() {
+            // Multi-depth visual injection happens before the targeted layer
+            // (layer 0 injection lands right after the embedding scale).
+            if let Some(inj) = injection {
+                for (target_layer, feats) in inj.targets.iter() {
+                    if *target_layer == idx {
+                        h = inject_at_positions(&h, inj.visual_pos_mask, feats);
+                    }
+                }
+            }
             let mask = if layer.is_attention() {
                 attn_mask.as_deref()
             } else {
@@ -1227,6 +1361,26 @@ fn build_feed_forward(
             shared_intermediate: args.shared_intermediate_size as i32,
         };
         Ok(FeedForward::Moe { moe, shared })
+    } else if weights.contains_key(&format!("{prefix}.shared_mlp.input_linear.weight"))
+        && !weights.contains_key(&format!("{prefix}.mlp.gate_proj.weight"))
+    {
+        // Dense checkpoint whose fused `shared_mlp.input_linear` was left intact
+        // by sanitize (quantized: the fused weight cannot be split). Use it as-is.
+        Ok(FeedForward::DenseShared(GraniteMoeHybridSharedMLP {
+            input_linear: UnifiedLinear::from_weights(
+                weights,
+                &format!("{prefix}.shared_mlp.input_linear"),
+                gs,
+                bits,
+            )?,
+            output_linear: UnifiedLinear::from_weights(
+                weights,
+                &format!("{prefix}.shared_mlp.output_linear"),
+                gs,
+                bits,
+            )?,
+            shared_intermediate: args.shared_intermediate_size as i32,
+        }))
     } else {
         let mlp_prefix = format!("{prefix}.mlp");
         Ok(FeedForward::Dense(GraniteMoeHybridMLP {
@@ -1320,9 +1474,14 @@ fn sanitize_weights(mut weights: WeightMap, args: &ModelArgs) -> WeightMap {
             if weights.contains_key(&format!("{layer_prefix}.mlp.gate_proj.weight")) {
                 continue;
             }
+            // Quantized fused `input_linear` cannot be split (slicing a quantized
+            // tensor is unsound); leave the whole fused `shared_mlp` intact so
+            // `build_feed_forward` loads it via `FeedForward::DenseShared`.
+            if weights.contains_key(&format!("{layer_prefix}.shared_mlp.input_linear.scales")) {
+                continue;
+            }
             let input_key = format!("{layer_prefix}.shared_mlp.input_linear.weight");
             if weights.contains_key(&input_key)
-                && !weights.contains_key(&format!("{layer_prefix}.shared_mlp.input_linear.scales"))
                 && let Some(input_weight) = weights.remove(&input_key)
             {
                 let shape = mlxcel_core::array_shape(&input_weight);
@@ -1413,6 +1572,43 @@ impl LanguageModel for GraniteMoeHybridModel {
         )
     }
 
+    fn forward_with_embeddings(
+        &self,
+        input_ids: &MlxArray,
+        input_embeddings: Option<&MlxArray>,
+        _caches: &mut [KVCache],
+        _mask: Option<&MlxArray>,
+    ) -> UniquePtr<MlxArray> {
+        match input_embeddings {
+            Some(embeds) => self.forward_embeds_with_injection(embeds, None, None),
+            None => self.sequence_state.with_sequence_state(None, |internal| {
+                self.forward_with_caches(input_ids, internal)
+            }),
+        }
+    }
+
+    fn forward_with_embeddings_and_sequence_id(
+        &self,
+        input_ids: &MlxArray,
+        input_embeddings: Option<&MlxArray>,
+        seq_id: Option<mlxcel_core::cache::SequenceId>,
+        _caches: &mut [KVCache],
+        _mask: Option<&MlxArray>,
+    ) -> UniquePtr<MlxArray> {
+        match input_embeddings {
+            Some(embeds) => self.forward_embeds_with_injection(embeds, None, seq_id),
+            None => self.sequence_state.with_or_create_sequence_state(
+                seq_id,
+                || GraniteMoeHybridModel::make_caches(self),
+                |internal| self.forward_with_caches(input_ids, internal),
+            ),
+        }
+    }
+
+    fn embed_tokens(&self, input_ids: &MlxArray) -> Option<UniquePtr<MlxArray>> {
+        Some(self.input_embeddings(input_ids))
+    }
+
     fn supports_snapshot_reuse(&self) -> bool {
         true
     }
diff --git a/src/models/granitemoehybrid_tests.rs b/src/models/granitemoehybrid_tests.rs
index 04003d1c..d0e9003d 100644
--- a/src/models/granitemoehybrid_tests.rs
+++ b/src/models/granitemoehybrid_tests.rs
@@ -197,3 +197,50 @@ fn granite_hybrid_quantization_read_from_config() {
     assert_eq!(bf16.group_size(), 64);
     assert_eq!(bf16.bits(), 4);
 }
+
+/// Regression for the `slice_update`-is-functional footgun: `inject_at_positions`
+/// MUST write the visual features into `h` at the image-token positions. A
+/// dropped `slice_update` return leaves the image slots zero and silently makes
+/// the VLM blind (Granite 4 Vision #541). Runs on the default device.
+#[test]
+fn inject_at_positions_writes_features_into_hidden_state() {
+    use super::granitemoehybrid::inject_at_positions;
+
+    let (seq, hidden) = (5i32, 4i32);
+    // Image slots start zeroed, exactly as `get_input_embeddings` produces them.
+    let h = mlxcel_core::zeros(&[1, seq, hidden], mlxcel_core::dtype::FLOAT32);
+    // Image tokens occupy positions 1, 2, 3 (a contiguous single-image run).
+    let mask = mlxcel_core::from_slice_i32(&[0, 1, 1, 1, 0], &[1, seq]);
+    // Three feature rows, each a distinct non-zero constant.
+    let feats = mlxcel_core::from_slice_f32(
+        &[1.0, 1.0, 1.0, 1.0, 2.0, 2.0, 2.0, 2.0, 3.0, 3.0, 3.0, 3.0],
+        &[3, hidden],
+    );
+
+    let out = inject_at_positions(&h, &mask, &feats);
+    mlxcel_core::eval(&out);
+    let val = |pos: i32, col: i32| {
+        let c = mlxcel_core::slice(&out, &[0, pos, col], &[1, pos + 1, col + 1]);
+        mlxcel_core::eval(&c);
+        mlxcel_core::item_f32(&c)
+    };
+
+    // Non-image positions stay zero; image positions carry their feature row.
+    assert_eq!(val(0, 0), 0.0, "non-image position must be untouched");
+    assert_eq!(
+        val(1, 0),
+        1.0,
+        "image position 1 must receive feature row 0"
+    );
+    assert_eq!(
+        val(2, 2),
+        2.0,
+        "image position 2 must receive feature row 1"
+    );
+    assert_eq!(
+        val(3, 3),
+        3.0,
+        "image position 3 must receive feature row 2"
+    );
+    assert_eq!(val(4, 0), 0.0, "non-image position must be untouched");
+}
diff --git a/src/models/mod.rs b/src/models/mod.rs
index a4c8885c..3d0267f4 100644
--- a/src/models/mod.rs
+++ b/src/models/mod.rs
@@ -233,61 +233,62 @@ pub use kokoro::KokoroModel;
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub enum ModelType {
     // Standard Transformer models
-    Llama,            // Llama 1/2/3, Mistral
-    Llama4,           // Llama 4 (MoE)
-    Llama4VLM,        // Llama 4 VLM (vision-language)
-    MllamaVLM,        // Llama 3.2 Vision (mllama): tiled ViT + gated cross-attention
-    Qwen2,            // Qwen 2/2.5
-    Qwen3,            // Qwen 3
-    Qwen3Moe,         // Qwen 3 MoE
-    Qwen3Next,        // Qwen 3 with GatedDeltaNet
-    Qwen35,           // Qwen 3.5 Hybrid (Transformer + GatedDeltaNet)
-    Qwen35VLM,        // Qwen 3.5 VLM (Qwen3-VL vision + Qwen3.5 hybrid text)
-    Qwen35Moe,        // Qwen 3.5 MoE Hybrid
-    Qwen35MoeVLM,     // Qwen 3.5 MoE VLM
-    Gemma,            // Gemma 1
-    Gemma2,           // Gemma 2
-    Gemma3,           // Gemma 3 (text-only)
-    Gemma4,           // Gemma 4 text-only route
-    DiffusionGemma,   // DiffusionGemma (block-diffusion on the Gemma 4 MoE backbone)
-    Gemma3VLM,        // Gemma 3 VLM (vision-language)
-    Gemma4VLM,        // Gemma 4 VLM (vision-language)
-    Gemma4Unified,    // Gemma 4 Unified (encoder-free text + vision + audio)
-    LlavaVLM,         // LLaVA (CLIP/SigLIP + Llama/Qwen2)
-    GraniteVisionVLM, // Granite Vision (SigLIP multi-tap + Granite text, AnyRes)
-    LlavaBunnyVLM,    // LLaVA-Bunny (SigLIP + Qwen2)
-    AyaVisionVLM,     // Aya Vision (SigLIP + Cohere2)
-    PaliGemmaVLM,     // PaliGemma (SigLIP + Gemma)
-    PixtralVLM,       // Pixtral (ViT w/ 2D RoPE + Mistral)
-    Mistral3VLM,      // Mistral 3 VLM (Pixtral ViT + PatchMerger + Mistral)
-    Qwen2VL,          // Qwen2-VL (custom ViT + Qwen2 w/ MRoPE)
-    Qwen25VL,         // Qwen2.5-VL (windowed ViT + Qwen2 w/ MRoPE)
-    Qwen3VL,          // Qwen3-VL (ViT + interleaved MRoPE + DeepStack)
-    Qwen3VLMoe,       // Qwen3-VL-MoE (Qwen3-VL + MoE text backbone)
-    PaddleOcrVL,      // PaddleOCR-VL (NaViT vision + ERNIE-4.5 w/ MRoPE)
-    Glm4v,            // GLM-4V (GLM-4V ViT + GLM-4 text w/ sectioned MRoPE)
-    Glm4vMoe,         // GLM-4V MoE (GLM-4V ViT + GLM-4 MoE text w/ MRoPE)
-    GlmOcr,           // GLM-OCR (GLM-OCR ViT + GLM-4 text w/ full-width MRoPE)
-    YoutuVLM,         // Youtu-VL (SigLIP2 windowed-attn + DeepSeek-V3-style MLA)
-    InternVLChatVLM,  // InternVL (internvl_chat): InternViT + pixel-shuffle mlp1 + Qwen2 text
-    SmolVLM,          // SmolVLM/SmolVLM2 (smolvlm): SigLIP + pixel-shuffle connector + SmolLM2 text
-    Idefics2,         // Idefics2 (idefics2): SigLIP + perceiver-resampler connector + Mistral text
-    MiniCPMOVLM,      // MiniCPM-o (dynamic SigLIP + resampler + Qwen3-VL text)
-    MiniCPMV46VLM,    // MiniCPM-V 4.6 (SigLIP + VitMerger + Merger + Qwen3.5 text)
-    Moondream3VLM,    // Moondream3 (custom ViT + custom text decoder, query/caption image path)
-    Moondream2VLM,    // Moondream2 (SigLIP-style ViT + Phi text decoder + crop tiling)
-    Gemma3n,          // Gemma 3n (text-only)
-    Gemma3nVLM,       // Gemma 3n VLM (MobileNetV5 + Gemma3n)
-    Phi,              // Phi 1/2
-    Phi3,             // Phi 3
-    Phi4MMVLM,        // Phi-4 Multimodal (SigLIP2 NaFlex + Phi4 text, image path only)
-    Phi4SigLipVLM,    // Phi-4 reasoning vision (SigLIP2 NaFlex + Phi3-style text)
-    Phi3VLM,          // Phi 3.5 Vision (CLIP + Phi3)
-    MolmoVLM,         // Molmo v1 (CLIP ViT + attention pooling + OLMo-style text)
-    Molmo2VLM,        // Molmo2 (custom ViT + attention pooling + Molmo2 text)
-    MolmoPointVLM,    // Molmo-Point (custom ViT + point prediction + Molmo2 text)
-    Phi3Small,        // Phi 3 Small
-    PhiMoe,           // Phi MoE
+    Llama,             // Llama 1/2/3, Mistral
+    Llama4,            // Llama 4 (MoE)
+    Llama4VLM,         // Llama 4 VLM (vision-language)
+    MllamaVLM,         // Llama 3.2 Vision (mllama): tiled ViT + gated cross-attention
+    Qwen2,             // Qwen 2/2.5
+    Qwen3,             // Qwen 3
+    Qwen3Moe,          // Qwen 3 MoE
+    Qwen3Next,         // Qwen 3 with GatedDeltaNet
+    Qwen35,            // Qwen 3.5 Hybrid (Transformer + GatedDeltaNet)
+    Qwen35VLM,         // Qwen 3.5 VLM (Qwen3-VL vision + Qwen3.5 hybrid text)
+    Qwen35Moe,         // Qwen 3.5 MoE Hybrid
+    Qwen35MoeVLM,      // Qwen 3.5 MoE VLM
+    Gemma,             // Gemma 1
+    Gemma2,            // Gemma 2
+    Gemma3,            // Gemma 3 (text-only)
+    Gemma4,            // Gemma 4 text-only route
+    DiffusionGemma,    // DiffusionGemma (block-diffusion on the Gemma 4 MoE backbone)
+    Gemma3VLM,         // Gemma 3 VLM (vision-language)
+    Gemma4VLM,         // Gemma 4 VLM (vision-language)
+    Gemma4Unified,     // Gemma 4 Unified (encoder-free text + vision + audio)
+    LlavaVLM,          // LLaVA (CLIP/SigLIP + Llama/Qwen2)
+    GraniteVisionVLM,  // Granite Vision (SigLIP multi-tap + Granite text, AnyRes)
+    Granite4VisionVLM, // Granite 4 Vision (SigLIP + window-QFormer + Granite-4 hybrid)
+    LlavaBunnyVLM,     // LLaVA-Bunny (SigLIP + Qwen2)
+    AyaVisionVLM,      // Aya Vision (SigLIP + Cohere2)
+    PaliGemmaVLM,      // PaliGemma (SigLIP + Gemma)
+    PixtralVLM,        // Pixtral (ViT w/ 2D RoPE + Mistral)
+    Mistral3VLM,       // Mistral 3 VLM (Pixtral ViT + PatchMerger + Mistral)
+    Qwen2VL,           // Qwen2-VL (custom ViT + Qwen2 w/ MRoPE)
+    Qwen25VL,          // Qwen2.5-VL (windowed ViT + Qwen2 w/ MRoPE)
+    Qwen3VL,           // Qwen3-VL (ViT + interleaved MRoPE + DeepStack)
+    Qwen3VLMoe,        // Qwen3-VL-MoE (Qwen3-VL + MoE text backbone)
+    PaddleOcrVL,       // PaddleOCR-VL (NaViT vision + ERNIE-4.5 w/ MRoPE)
+    Glm4v,             // GLM-4V (GLM-4V ViT + GLM-4 text w/ sectioned MRoPE)
+    Glm4vMoe,          // GLM-4V MoE (GLM-4V ViT + GLM-4 MoE text w/ MRoPE)
+    GlmOcr,            // GLM-OCR (GLM-OCR ViT + GLM-4 text w/ full-width MRoPE)
+    YoutuVLM,          // Youtu-VL (SigLIP2 windowed-attn + DeepSeek-V3-style MLA)
+    InternVLChatVLM,   // InternVL (internvl_chat): InternViT + pixel-shuffle mlp1 + Qwen2 text
+    SmolVLM,  // SmolVLM/SmolVLM2 (smolvlm): SigLIP + pixel-shuffle connector + SmolLM2 text
+    Idefics2, // Idefics2 (idefics2): SigLIP + perceiver-resampler connector + Mistral text
+    MiniCPMOVLM, // MiniCPM-o (dynamic SigLIP + resampler + Qwen3-VL text)
+    MiniCPMV46VLM, // MiniCPM-V 4.6 (SigLIP + VitMerger + Merger + Qwen3.5 text)
+    Moondream3VLM, // Moondream3 (custom ViT + custom text decoder, query/caption image path)
+    Moondream2VLM, // Moondream2 (SigLIP-style ViT + Phi text decoder + crop tiling)
+    Gemma3n,  // Gemma 3n (text-only)
+    Gemma3nVLM, // Gemma 3n VLM (MobileNetV5 + Gemma3n)
+    Phi,      // Phi 1/2
+    Phi3,     // Phi 3
+    Phi4MMVLM, // Phi-4 Multimodal (SigLIP2 NaFlex + Phi4 text, image path only)
+    Phi4SigLipVLM, // Phi-4 reasoning vision (SigLIP2 NaFlex + Phi3-style text)
+    Phi3VLM,  // Phi 3.5 Vision (CLIP + Phi3)
+    MolmoVLM, // Molmo v1 (CLIP ViT + attention pooling + OLMo-style text)
+    Molmo2VLM, // Molmo2 (custom ViT + attention pooling + Molmo2 text)
+    MolmoPointVLM, // Molmo-Point (custom ViT + point prediction + Molmo2 text)
+    Phi3Small, // Phi 3 Small
+    PhiMoe,   // Phi MoE
 
     // MoE models
     GptOss,
@@ -436,6 +437,7 @@ pub const ALL_MODEL_TYPES: &[ModelType] = &[
     ModelType::Gemma4Unified,
     ModelType::LlavaVLM,
     ModelType::GraniteVisionVLM,
+    ModelType::Granite4VisionVLM,
     ModelType::LlavaBunnyVLM,
     ModelType::AyaVisionVLM,
     ModelType::PaliGemmaVLM,
@@ -764,6 +766,10 @@ impl ModelType {
             // ----- Other VLM (cross-family vision-language stacks) -----
             ModelType::LlavaVLM => ("LLaVA (CLIP/SigLIP + Llama/Qwen2)", "Other VLM"),
             ModelType::GraniteVisionVLM => ("Granite Vision (SigLIP + Granite)", "Granite VLM"),
+            ModelType::Granite4VisionVLM => (
+                "Granite 4 Vision (SigLIP + Granite 4 hybrid)",
+                "Granite VLM",
+            ),
             ModelType::LlavaBunnyVLM => ("LLaVA-Bunny (SigLIP + Qwen2)", "Other VLM"),
             ModelType::InternVLChatVLM => {
                 ("InternVL (InternViT + pixel-shuffle + Qwen2)", "Other VLM")
@@ -861,6 +867,7 @@ mod metadata_tests {
             Gemma4Unified,
             LlavaVLM,
             GraniteVisionVLM,
+            Granite4VisionVLM,
             LlavaBunnyVLM,
             AyaVisionVLM,
             PaliGemmaVLM,
diff --git a/src/multimodal/vlm_runtime.rs b/src/multimodal/vlm_runtime.rs
index 6ef3f861..7e160822 100644
--- a/src/multimodal/vlm_runtime.rs
+++ b/src/multimodal/vlm_runtime.rs
@@ -149,6 +149,11 @@ pub enum VlmPreparationSummary {
         image_blocks: usize,
         total_image_tokens: i32,
     },
+    /// Granite 4 Vision expanded each `<image>` into `num_image_tokens` copies.
+    Granite4Vision {
+        image_blocks: usize,
+        total_image_tokens: i32,
+    },
     /// Kimi-VL expanded each `<|media_pad|>` into `(h/merge)*(w/merge)`
     /// media-placeholder tokens.
     KimiVL {
@@ -1032,6 +1037,35 @@ where
                 preparation,
             }))
         }
+        VlmRuntimeRef::Granite4Vision(granite4) => {
+            // AnyRes tiling at grid side 12 (patch 16, downsample /2); each image
+            // packs into `144 + H*(W+1)` tokens per injected stream.
+            let (pixel_values, infos) = granite4.processor.preprocess_with_tiles(images);
+
+            let preparation =
+                crate::multimodal::granite_vision_prompt::insert_granite_vision_image_tokens(
+                    prompt_tokens,
+                    &infos,
+                    granite4.image_token_index,
+                    granite4.feature_side,
+                    granite4.base_tokens,
+                )
+                .map(|stats| VlmPreparationSummary::Granite4Vision {
+                    image_blocks: stats.image_blocks,
+                    total_image_tokens: stats.total_image_tokens,
+                });
+
+            let _ = active_caches;
+            let _ = image_cache_keys;
+
+            let input_ids_arr = prompt_ids_array(prompt_tokens);
+            let embeddings = granite4.get_input_embeddings(&input_ids_arr, &pixel_values, &infos);
+
+            Ok(Some(PreparedVlmEmbeddings {
+                embeddings,
+                preparation,
+            }))
+        }
         VlmRuntimeRef::KimiVL(kimi) => {
             // MoonViT native-resolution preprocessing: each image is patchified
             // into [num_patches, C, p, p] plus its (h, w) patch grid.
diff --git a/src/server/chat_template.rs b/src/server/chat_template.rs
index 512b059b..1668d7a9 100644
--- a/src/server/chat_template.rs
+++ b/src/server/chat_template.rs
@@ -682,6 +682,18 @@ fn preprocess_template(template: String) -> String {
             out = out.replace(v, "");
         }
     }
+    // Some chat templates (e.g. Granite 4 Vision) write the image marker as a
+    // multi-line string literal — `"<image>\n"` split across two source lines —
+    // which minijinja renders as empty/whitespace instead of the marker, so the
+    // per-model image-token expansion never finds a placeholder and mis-places
+    // the image. Normalize such literals to an escaped single-line form.
+    for marker in ["<image>", "<video>", "<audio>"] {
+        let multiline = format!("\"{marker}\n\"");
+        let escaped = format!("\"{marker}\\n\"");
+        if out.contains(&multiline) {
+            out = out.replace(&multiline, &escaped);
+        }
+    }
     out
 }
 
@@ -808,6 +820,26 @@ fn configure_environment(env: &mut Environment<'_>) {
                     let suffix = arg_str();
                     return Ok(Value::from(s.ends_with(suffix)));
                 }
+                "index" | "find" => {
+                    // Python `str.index(sub)` / `str.find(sub)`: return the
+                    // character index of the first occurrence. `index` raises on
+                    // absence; `find` returns -1. Used by the Granite 4 Vision
+                    // template's image-position dispatcher.
+                    let sub = arg_str();
+                    match s.find(sub) {
+                        Some(byte_idx) => {
+                            let char_idx = s[..byte_idx].chars().count() as i64;
+                            return Ok(Value::from(char_idx));
+                        }
+                        None if method == "find" => return Ok(Value::from(-1i64)),
+                        None => {
+                            return Err(minijinja::Error::new(
+                                ErrorKind::InvalidOperation,
+                                "substring not found",
+                            ));
+                        }
+                    }
+                }
                 "replace" => {
                     let old = args.first().and_then(|a| a.as_str()).unwrap_or_default();
                     let new = args.get(1).and_then(|a| a.as_str()).unwrap_or_default();
@@ -921,11 +953,6 @@ fn configure_environment(env: &mut Environment<'_>) {
                         s.chars().any(|c| c.is_lowercase()) && !s.chars().any(|c| c.is_uppercase()),
                     ));
                 }
-                "find" => {
-                    let needle = arg_str();
-                    let idx = s.find(needle).map(|i| i as i64).unwrap_or(-1);
-                    return Ok(Value::from(idx));
-                }
                 "count" => {
                     let needle = arg_str();
                     if needle.is_empty() {
diff --git a/src/vision/connectors/granite4_vision.rs b/src/vision/connectors/granite4_vision.rs
new file mode 100644
index 00000000..66da2f67
--- /dev/null
+++ b/src/vision/connectors/granite4_vision.rs
@@ -0,0 +1,391 @@
+// Copyright 2025-2026 Lablup Inc. and Jeongkyu Shin
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Granite 4 Vision window-QFormer projector.
+//!
+//! Each of the eight projectors maps one SigLIP tap `(num_tiles, 576, 1152)` to
+//! `(num_tiles, 144, 2560)`. With key window `w = 8`, query window `q = 4`, tile
+//! side `s = 24`, window grid `n = s / w = 3`, output side `s' = n * q = 12`:
+//!
+//! 1. `norm` (LayerNorm, eps 1e-6) over the tap.
+//! 2. Key/value windowing: `(num_tiles, 24, 24, 1152)` -> 9 windows of `8x8`,
+//!    `(num_tiles*9, 64, 1152)`, plus the learned `image_positions` `(1, 64, 1152)`.
+//! 3. Query construction: downsample the normed tap to `(num_tiles, 12, 12,
+//!    1152)` (deepstack: 2x2 block mean; spatial: strided `(row,col)` offset),
+//!    window into `(num_tiles*9, 16, 1152)`, plus the learned `query` `(1, 16,
+//!    1152)`.
+//! 4. One post-norm QFormer layer (18 heads, head_dim 64): `layernorm` on the
+//!    queries, self-attention, cross-attention to the windowed keys, GELU FFN.
+//! 5. Un-window to `(num_tiles, 144, 1152)`.
+//! 6. `out_linear` (1152 -> 2560).
+//!
+//! Used by: Granite 4 Vision (`granite4_vision`).
+//! Reference: https://github.com/Blaizzy/mlx-vlm/tree/main/mlx_vlm/models/granite4_vision
+
+use mlxcel_core::layers::{LayerNorm, UnifiedLinear};
+use mlxcel_core::weights::WeightMap;
+use mlxcel_core::{MlxArray, UniquePtr};
+
+const QFORMER_EPS: f32 = 1e-5; // MLX nn.LayerNorm default (matches the reference).
+const NORM_EPS: f32 = 1e-6; // Projector `norm` uses an explicit 1e-6.
+
+fn load_layer_norm(weights: &WeightMap, prefix: &str, eps: f32) -> Result<LayerNorm, String> {
+    let weight = weights
+        .get(&format!("{prefix}.weight"))
+        .map(|w| mlxcel_core::copy(w))
+        .ok_or_else(|| format!("Weight not found: {prefix}.weight"))?;
+    let bias = weights
+        .get(&format!("{prefix}.bias"))
+        .map(|w| mlxcel_core::copy(w));
+    Ok(LayerNorm::new(weight, bias, eps))
+}
+
+fn load_array(weights: &WeightMap, key: &str) -> Result<UniquePtr<MlxArray>, String> {
+    weights
+        .get(key)
+        .map(|w| mlxcel_core::copy(w))
+        .ok_or_else(|| format!("Weight not found: {key}"))
+}
+
+/// Window `(num_tiles, s*s, C)` into `(num_tiles*n*n, win*win, C)` with `n = s /
+/// win`: each `win x win` block becomes one window row-group.
+fn window_tokens(x_flat: &MlxArray, s: i32, win: i32) -> UniquePtr<MlxArray> {
+    let shape = mlxcel_core::array_shape(x_flat);
+    let (nt, c) = (shape[0], shape[2]);
+    let n = s / win;
+    let x = mlxcel_core::reshape(x_flat, &[nt, n, win, n, win, c]);
+    let x = mlxcel_core::transpose_axes(&x, &[0, 1, 3, 2, 4, 5]);
+    mlxcel_core::reshape(&x, &[nt * n * n, win * win, c])
+}
+
+/// Inverse of [`window_tokens`]: `(num_tiles*n*n, win*win, C)` -> `(num_tiles,
+/// s*s, C)`.
+fn unwindow_tokens(x: &MlxArray, nt: i32, s: i32, win: i32) -> UniquePtr<MlxArray> {
+    let shape = mlxcel_core::array_shape(x);
+    let c = shape[2];
+    let n = s / win;
+    let x = mlxcel_core::reshape(x, &[nt, n, n, win, win, c]);
+    let x = mlxcel_core::transpose_axes(&x, &[0, 1, 3, 2, 4, 5]);
+    mlxcel_core::reshape(&x, &[nt, s * s, c])
+}
+
+/// Query downsampler kind: deepstack projectors mean-pool `2x2` blocks; spatial
+/// projectors select a fixed `(row, col)` offset within each `2x2` block.
+#[derive(Debug, Clone, Copy)]
+pub enum Downsampler {
+    MeanPool,
+    Strided { row_off: i32, col_off: i32 },
+}
+
+impl Downsampler {
+    /// `normed`: `(num_tiles, 576, 1152)` -> `(num_tiles, 144, 1152)`.
+    fn apply(&self, normed: &MlxArray, stride: i32) -> UniquePtr<MlxArray> {
+        let shape = mlxcel_core::array_shape(normed);
+        let (nt, c) = (shape[0], shape[2]);
+        let side = 24; // 384 / patch 16
+        let out_side = side / stride; // 12
+        // (num_tiles, 12, 2, 12, 2, 1152)
+        let x = mlxcel_core::reshape(normed, &[nt, out_side, stride, out_side, stride, c]);
+        let pooled = match self {
+            Downsampler::MeanPool => {
+                let m = mlxcel_core::mean_axis(&x, 4, false); // remove col-stride
+                mlxcel_core::mean_axis(&m, 2, false) // remove row-stride
+            }
+            Downsampler::Strided { row_off, col_off } => {
+                let sel_row = mlxcel_core::slice(
+                    &x,
+                    &[0, 0, *row_off, 0, 0, 0],
+                    &[nt, out_side, row_off + 1, out_side, stride, c],
+                );
+                let sel_row = mlxcel_core::squeeze_axis(&sel_row, 2); // (nt, 12, 12, 2, c)
+                let sel_col = mlxcel_core::slice(
+                    &sel_row,
+                    &[0, 0, 0, *col_off, 0],
+                    &[nt, out_side, out_side, col_off + 1, c],
+                );
+                mlxcel_core::squeeze_axis(&sel_col, 3) // (nt, 12, 12, c)
+            }
+        };
+        mlxcel_core::reshape(&pooled, &[nt, out_side * out_side, c])
+    }
+}
+
+// Multi-head attention block (self or cross), post-norm residual.
+struct QFormerAttention {
+    query: UnifiedLinear,
+    key: UnifiedLinear,
+    value: UnifiedLinear,
+    out_dense: UnifiedLinear,
+    out_norm: LayerNorm,
+    num_heads: i32,
+    head_dim: i32,
+    scale: f32,
+}
+
+impl QFormerAttention {
+    fn from_weights(
+        weights: &WeightMap,
+        prefix: &str,
+        num_heads: i32,
+        head_dim: i32,
+        gs: i32,
+        bits: i32,
+    ) -> Result<Self, String> {
+        Ok(Self {
+            query: UnifiedLinear::from_weights(
+                weights,
+                &format!("{prefix}.attention.query"),
+                gs,
+                bits,
+            )?,
+            key: UnifiedLinear::from_weights(
+                weights,
+                &format!("{prefix}.attention.key"),
+                gs,
+                bits,
+            )?,
+            value: UnifiedLinear::from_weights(
+                weights,
+                &format!("{prefix}.attention.value"),
+                gs,
+                bits,
+            )?,
+            out_dense: UnifiedLinear::from_weights(
+                weights,
+                &format!("{prefix}.output.dense"),
+                gs,
+                bits,
+            )?,
+            out_norm: load_layer_norm(weights, &format!("{prefix}.output.LayerNorm"), QFORMER_EPS)?,
+            num_heads,
+            head_dim,
+            scale: (head_dim as f32).powf(-0.5),
+        })
+    }
+
+    fn split_heads(&self, x: &MlxArray) -> UniquePtr<MlxArray> {
+        let s = mlxcel_core::array_shape(x);
+        let x = mlxcel_core::reshape(
+            &mlxcel_core::copy(x),
+            &[s[0], s[1], self.num_heads, self.head_dim],
+        );
+        mlxcel_core::transpose_axes(&x, &[0, 2, 1, 3])
+    }
+
+    /// `hidden`: query source `(B, Lq, D)`; `context`: key/value source `(B, Lk, D)`.
+    fn forward(&self, hidden: &MlxArray, context: &MlxArray) -> UniquePtr<MlxArray> {
+        let q = self.query.forward(hidden);
+        let k = self.key.forward(context);
+        let v = self.value.forward(context);
+        let q = self.split_heads(&q);
+        let k = self.split_heads(&k);
+        let v = self.split_heads(&v);
+        let out = unsafe {
+            mlxcel_core::layers::attention_from_ptr(
+                &q,
+                &k,
+                &v,
+                self.scale,
+                std::ptr::null(),
+                0.0,
+                0,
+            )
+        };
+        let out = mlxcel_core::transpose_axes(&out, &[0, 2, 1, 3]);
+        let hs = mlxcel_core::array_shape(hidden);
+        let out = mlxcel_core::reshape(&out, &[hs[0], hs[1], self.num_heads * self.head_dim]);
+        let dense = self.out_dense.forward(&out);
+        self.out_norm.forward(&mlxcel_core::add(&dense, hidden))
+    }
+}
+
+// One QFormer layer: self-attention, cross-attention, GELU FFN (all post-norm).
+struct WindowQFormerLayer {
+    self_attn: QFormerAttention,
+    cross_attn: QFormerAttention,
+    inter_dense: UnifiedLinear,
+    output_dense: UnifiedLinear,
+    output_norm: LayerNorm,
+}
+
+impl WindowQFormerLayer {
+    fn from_weights(
+        weights: &WeightMap,
+        prefix: &str,
+        num_heads: i32,
+        head_dim: i32,
+        gs: i32,
+        bits: i32,
+    ) -> Result<Self, String> {
+        Ok(Self {
+            self_attn: QFormerAttention::from_weights(
+                weights,
+                &format!("{prefix}.attention"),
+                num_heads,
+                head_dim,
+                gs,
+                bits,
+            )?,
+            cross_attn: QFormerAttention::from_weights(
+                weights,
+                &format!("{prefix}.crossattention"),
+                num_heads,
+                head_dim,
+                gs,
+                bits,
+            )?,
+            inter_dense: UnifiedLinear::from_weights(
+                weights,
+                &format!("{prefix}.intermediate_query.dense"),
+                gs,
+                bits,
+            )?,
+            output_dense: UnifiedLinear::from_weights(
+                weights,
+                &format!("{prefix}.output_query.dense"),
+                gs,
+                bits,
+            )?,
+            output_norm: load_layer_norm(
+                weights,
+                &format!("{prefix}.output_query.LayerNorm"),
+                QFORMER_EPS,
+            )?,
+        })
+    }
+
+    /// `h0`: layernormed windowed queries `(B, 16, D)`; `keys`: windowed keys
+    /// `(B, 64, D)`.
+    fn forward(&self, h0: &MlxArray, keys: &MlxArray) -> UniquePtr<MlxArray> {
+        let h1 = self.self_attn.forward(h0, h0);
+        let h2 = self.cross_attn.forward(&h1, keys);
+        let inter = mlxcel_core::gelu(&self.inter_dense.forward(&h2));
+        let ffn = self.output_dense.forward(&inter);
+        self.output_norm.forward(&mlxcel_core::add(&ffn, &h2))
+    }
+}
+
+/// One Granite 4 Vision window-QFormer projector.
+pub struct WindowQFormerProjector {
+    norm: LayerNorm,
+    query: UniquePtr<MlxArray>,
+    image_positions: UniquePtr<MlxArray>,
+    qformer_layernorm: LayerNorm,
+    layer: WindowQFormerLayer,
+    out_linear: UnifiedLinear,
+    downsampler: Downsampler,
+    /// Key window side (`w`, 8) and query window side (`q`, 4).
+    key_win: i32,
+    query_win: i32,
+    /// Downsample stride (`w / q`, 2).
+    stride: i32,
+}
+
+impl WindowQFormerProjector {
+    /// `q`/`w` from `config.json` `downsample_rate` ("4/8" -> q=4, w=8); the
+    /// `query`/`image_positions` weight shapes are validated against `q^2`/`w^2`.
+    pub fn from_weights(
+        weights: &WeightMap,
+        prefix: &str,
+        downsampler: Downsampler,
+        q: i32,
+        w: i32,
+        gs: i32,
+        bits: i32,
+    ) -> Result<Self, String> {
+        let num_heads = 18;
+        let head_dim = 64;
+        let query = load_array(weights, &format!("{prefix}.query"))?;
+        let image_positions = load_array(weights, &format!("{prefix}.image_positions"))?;
+
+        // Validate projector geometry against the weight shapes.
+        let q_shape = mlxcel_core::array_shape(&query);
+        if q_shape.len() != 3 || q_shape[1] != q * q {
+            return Err(format!(
+                "granite4 projector query length {} != q^2 ({}) for {prefix}",
+                q_shape.get(1).copied().unwrap_or(-1),
+                q * q
+            ));
+        }
+        let kv_shape = mlxcel_core::array_shape(&image_positions);
+        if kv_shape.len() != 3 || kv_shape[1] != w * w {
+            return Err(format!(
+                "granite4 projector image_positions length {} != w^2 ({}) for {prefix}",
+                kv_shape.get(1).copied().unwrap_or(-1),
+                w * w
+            ));
+        }
+
+        Ok(Self {
+            norm: load_layer_norm(weights, &format!("{prefix}.norm"), NORM_EPS)?,
+            query,
+            image_positions,
+            qformer_layernorm: load_layer_norm(
+                weights,
+                &format!("{prefix}.qformer.layernorm"),
+                QFORMER_EPS,
+            )?,
+            layer: WindowQFormerLayer::from_weights(
+                weights,
+                &format!("{prefix}.qformer.encoder.layer.0"),
+                num_heads,
+                head_dim,
+                gs,
+                bits,
+            )?,
+            out_linear: UnifiedLinear::from_weights(
+                weights,
+                &format!("{prefix}.out_linear"),
+                gs,
+                bits,
+            )?,
+            downsampler,
+            key_win: w,
+            query_win: q,
+            stride: w / q,
+        })
+    }
+
+    /// `tap`: `(num_tiles, 576, 1152)` -> `(num_tiles, 144, 2560)`.
+    pub fn forward(&self, tap: &MlxArray) -> UniquePtr<MlxArray> {
+        let normed = self.norm.forward(tap);
+        let dtype = mlxcel_core::array_dtype(&normed);
+        let nt = mlxcel_core::array_shape(&normed)[0];
+        let side = 24;
+        let out_side = side / self.stride; // 12
+
+        // Keys: window the 24x24 grid into 8x8 windows, add image_positions.
+        let keys = window_tokens(&normed, side, self.key_win);
+        let img_pos = mlxcel_core::astype(&self.image_positions, dtype);
+        let keys = mlxcel_core::add(&keys, &img_pos);
+
+        // Queries: downsample to 12x12, window into 4x4 windows, add query.
+        let grid = self.downsampler.apply(&normed, self.stride);
+        let q_win = window_tokens(&grid, out_side, self.query_win);
+        let query = mlxcel_core::astype(&self.query, dtype);
+        let q_win = mlxcel_core::add(&q_win, &query);
+
+        // QFormer layer (layernorm queries first, then self+cross+FFN).
+        let h0 = self.qformer_layernorm.forward(&q_win);
+        let h = self.layer.forward(&h0, &keys);
+
+        // Un-window and project to text hidden size.
+        let unwin = unwindow_tokens(&h, nt, out_side, self.query_win);
+        self.out_linear.forward(&unwin)
+    }
+}
+
+#[cfg(test)]
+#[path = "granite4_vision_tests.rs"]
+mod tests;
diff --git a/src/vision/connectors/granite4_vision_tests.rs b/src/vision/connectors/granite4_vision_tests.rs
new file mode 100644
index 00000000..927630da
--- /dev/null
+++ b/src/vision/connectors/granite4_vision_tests.rs
@@ -0,0 +1,99 @@
+// Copyright 2025-2026 Lablup Inc. and Jeongkyu Shin
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+use super::{Downsampler, unwindow_tokens, window_tokens};
+use mlxcel_core::MlxArray;
+
+fn to_vec(x: &MlxArray) -> Vec<f32> {
+    let shape = mlxcel_core::array_shape(x);
+    let n: i32 = shape.iter().product();
+    let flat = mlxcel_core::reshape(x, &[n]);
+    mlxcel_core::eval(&flat);
+    (0..n)
+        .map(|i| {
+            let cell = mlxcel_core::slice(&flat, &[i], &[i + 1]);
+            mlxcel_core::eval(&cell);
+            mlxcel_core::item_f32(&cell)
+        })
+        .collect()
+}
+
+#[test]
+fn window_unwindow_round_trip_is_identity() {
+    // 1 tile, 12x12 grid, window 4 -> 9 windows of 16, C=2. Un-window must
+    // recover the original.
+    let (nt, s, win, c) = (1i32, 12i32, 4i32, 2i32);
+    let n = (nt * s * s * c) as usize;
+    let data: Vec<f32> = (0..n).map(|i| i as f32).collect();
+    let x = mlxcel_core::from_slice_f32(&data, &[nt, s * s, c]);
+    let windowed = window_tokens(&x, s, win);
+    assert_eq!(
+        mlxcel_core::array_shape(&windowed),
+        vec![nt * (s / win) * (s / win), win * win, c]
+    );
+    let back = unwindow_tokens(&windowed, nt, s, win);
+    assert_eq!(mlxcel_core::array_shape(&back), vec![nt, s * s, c]);
+    assert_eq!(to_vec(&x), to_vec(&back));
+}
+
+#[test]
+fn mean_pool_downsampler_equals_block_mean() {
+    // 1 tile, 24x24 grid, C=1: the value at (r,c) is r*24 + c. A 2x2 block mean
+    // at output (or, oc) averages input rows {2*or, 2*or+1} x cols {2*oc, 2*oc+1}.
+    let (nt, c) = (1i32, 1i32);
+    let side = 24usize;
+    let data: Vec<f32> = (0..(side * side)).map(|i| i as f32).collect();
+    let x = mlxcel_core::from_slice_f32(&data, &[nt, (side * side) as i32, c]);
+    let pooled = Downsampler::MeanPool.apply(&x, 2); // (1, 144, 1)
+    assert_eq!(mlxcel_core::array_shape(&pooled), vec![1, 144, 1]);
+    let got = to_vec(&pooled);
+    // Reference: output (or, oc) = mean of the 2x2 block.
+    for or in 0..12usize {
+        for oc in 0..12usize {
+            let mut sum = 0f32;
+            for dr in 0..2usize {
+                for dc in 0..2usize {
+                    let (r, cc) = (2 * or + dr, 2 * oc + dc);
+                    sum += (r * side + cc) as f32;
+                }
+            }
+            let want = sum / 4.0;
+            assert!((got[or * 12 + oc] - want).abs() < 1e-3, "block ({or},{oc})");
+        }
+    }
+}
+
+#[test]
+fn strided_downsampler_selects_expected_offset() {
+    // Same numbered 24x24 grid; strided (row_off, col_off) selects input
+    // (2*or + row_off, 2*oc + col_off).
+    let side = 24usize;
+    let data: Vec<f32> = (0..(side * side)).map(|i| i as f32).collect();
+    let x = mlxcel_core::from_slice_f32(&data, &[1, (side * side) as i32, 1]);
+    for (row_off, col_off) in [(0, 0), (0, 1), (1, 0), (1, 1)] {
+        let sel = Downsampler::Strided { row_off, col_off }.apply(&x, 2);
+        let got = to_vec(&sel);
+        for or in 0..12usize {
+            for oc in 0..12usize {
+                let r = 2 * or + row_off as usize;
+                let cc = 2 * oc + col_off as usize;
+                let want = (r * side + cc) as f32;
+                assert!(
+                    (got[or * 12 + oc] - want).abs() < 1e-3,
+                    "offset ({row_off},{col_off}) at ({or},{oc})"
+                );
+            }
+        }
+    }
+}
diff --git a/src/vision/connectors/mod.rs b/src/vision/connectors/mod.rs
index 7ff8df25..881052fe 100644
--- a/src/vision/connectors/mod.rs
+++ b/src/vision/connectors/mod.rs
@@ -18,6 +18,7 @@
 
 pub mod avg_pool;
 pub mod aya_vision;
+pub mod granite4_vision;
 pub mod identity;
 pub mod lfm2_vl;
 pub mod linear;
diff --git a/src/vision/granite4_vision.rs b/src/vision/granite4_vision.rs
new file mode 100644
index 00000000..65a4b124
--- /dev/null
+++ b/src/vision/granite4_vision.rs
@@ -0,0 +1,360 @@
+// Copyright 2025-2026 Lablup Inc. and Jeongkyu Shin
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Granite 4 Vision (`granite4_vision`) Vision-Language Model.
+//!
+//! IBM's document VLM with multi-depth visual injection. A SigLIP tower feeds
+//! eight window-QFormer projectors ([`super::connectors::granite4_vision`])
+//! whose packed outputs are added into the residual stream at eight different
+//! depths of a Granite 4 hybrid text backbone
+//! ([`crate::models::granitemoehybrid::GraniteMoeHybridModel`]) during prefill,
+//! rather than merged once into the input embeddings.
+//!
+//! Prefill flow: embed tokens, zero the `<image>` (100352) slots, run the tower
+//! taps + 8 projectors + AnyRes packing to build 8 streams, then hand the
+//! streams + position mask to the backbone's injection entry point (which owns
+//! the recurrent conv/SSM + KV state). Decode steps carry no image input.
+
+use std::cell::RefCell;
+
+use super::merge::InputEmbeddings;
+use super::processors::anyres::{AnyResProcessor, AnyResTileInfo, unpadded_token_hw};
+use crate::models::granitemoehybrid::{GraniteMoeHybridModel, HybridInjection};
+use crate::vision::connectors::granite4_vision::WindowQFormerProjector;
+use crate::vision::encoders::siglip::SigLipVisionModel;
+use mlxcel_core::cache::SequenceId;
+use mlxcel_core::generate::LanguageModel;
+use mlxcel_core::layers::KVCache;
+use mlxcel_core::{MlxArray, UniquePtr};
+
+/// Stashed injection produced by `get_input_embeddings` and consumed by the
+/// injecting `forward_with_embeddings*` on the very next prefill call.
+struct StashedInjection {
+    mask: UniquePtr<MlxArray>,
+    targets: Vec<(usize, UniquePtr<MlxArray>)>,
+}
+
+/// Granite 4 Vision VLM.
+pub struct Granite4VisionVLModel {
+    pub text_model: GraniteMoeHybridModel,
+    pub vision_tower: SigLipVisionModel,
+    /// 8 projectors: `[0..4]` deepstack (layerwise), `[4..8]` spatial.
+    pub projectors: Vec<WindowQFormerProjector>,
+    pub image_newline: UniquePtr<MlxArray>,
+    pub processor: AnyResProcessor,
+    pub image_token_index: i32,
+    /// The 4 SigLIP taps to collect (deepstack `L[9],L[15],L[21],L[27]`).
+    pub deepstack_taps: Vec<i32>,
+    /// Per stream: `(projector_index, tap_index, decoder_layer)`.
+    pub stream_specs: Vec<(usize, usize, usize)>,
+    pub feature_side: i32,
+    pub base_tokens: i32,
+    pub eos_token_id: i32,
+    injection: RefCell<Option<StashedInjection>>,
+}
+
+impl Granite4VisionVLModel {
+    /// Construct from loaded parts (the injection stash starts empty).
+    #[allow(clippy::too_many_arguments)]
+    pub fn new(
+        text_model: GraniteMoeHybridModel,
+        vision_tower: SigLipVisionModel,
+        projectors: Vec<WindowQFormerProjector>,
+        image_newline: UniquePtr<MlxArray>,
+        processor: AnyResProcessor,
+        image_token_index: i32,
+        deepstack_taps: Vec<i32>,
+        stream_specs: Vec<(usize, usize, usize)>,
+        feature_side: i32,
+        base_tokens: i32,
+        eos_token_id: i32,
+    ) -> Self {
+        Self {
+            text_model,
+            vision_tower,
+            projectors,
+            image_newline,
+            processor,
+            image_token_index,
+            deepstack_taps,
+            stream_specs,
+            feature_side,
+            base_tokens,
+            eos_token_id,
+            injection: RefCell::new(None),
+        }
+    }
+
+    /// Build the zeroed input embeddings and stash the 8 injection streams.
+    pub fn get_input_embeddings(
+        &self,
+        input_ids: &MlxArray,
+        pixel_values: &MlxArray,
+        infos: &[AnyResTileInfo],
+    ) -> InputEmbeddings {
+        let inputs_embeds = self.text_model.input_embeddings(input_ids);
+
+        // Collect the 4 deepstack taps once; spatial streams reuse the last tap.
+        let taps = self
+            .vision_tower
+            .forward_collect_layers(pixel_values, &self.deepstack_taps);
+
+        // Build the 8 packed streams.
+        let mut targets: Vec<(usize, UniquePtr<MlxArray>)> =
+            Vec::with_capacity(self.stream_specs.len());
+        for &(proj_idx, tap_idx, layer) in &self.stream_specs {
+            let proj_out = self.projectors[proj_idx].forward(taps[tap_idx].as_ref().unwrap());
+            let packed = self.pack_streams(&proj_out, infos);
+            mlxcel_core::eval(&packed);
+            targets.push((layer, packed));
+        }
+
+        // Host-read the image-token positions to build the zero-multiplier and
+        // the injection position mask.
+        mlxcel_core::eval(input_ids);
+        let seq_len = mlxcel_core::array_shape(input_ids)[1];
+        let mut keep = vec![1.0f32; seq_len as usize];
+        let mut mask = vec![0i32; seq_len as usize];
+        for i in 0..seq_len as usize {
+            let tok = mlxcel_core::slice(input_ids, &[0, i as i32], &[1, i as i32 + 1]);
+            mlxcel_core::eval(&tok);
+            if mlxcel_core::item_i32(&tok) == self.image_token_index {
+                keep[i] = 0.0;
+                mask[i] = 1;
+            }
+        }
+        let keep_arr = mlxcel_core::from_slice_f32(&keep, &[1, seq_len, 1]);
+        let keep_arr = mlxcel_core::astype(&keep_arr, mlxcel_core::array_dtype(&inputs_embeds));
+        let zeroed = mlxcel_core::multiply(&inputs_embeds, &keep_arr);
+        mlxcel_core::eval(&zeroed);
+
+        let mask_arr = mlxcel_core::from_slice_i32(&mask, &[1, seq_len]);
+        mlxcel_core::eval(&mask_arr);
+
+        *self.injection.borrow_mut() = Some(StashedInjection {
+            mask: mask_arr,
+            targets,
+        });
+
+        InputEmbeddings {
+            inputs_embeds: zeroed,
+            attention_mask_4d: None,
+        }
+    }
+
+    /// Pack one stream's per-tile features `(total_tiles, base_tokens, D)` into
+    /// `(N_img_total, D)` (base tile rows + unpadded grid with a trailing
+    /// `image_newline` column, per image). Same math as Granite Vision at grid
+    /// side 12.
+    fn pack_streams(&self, proj_out: &MlxArray, infos: &[AnyResTileInfo]) -> UniquePtr<MlxArray> {
+        let d = *mlxcel_core::array_shape(proj_out).last().unwrap();
+        let mut features: Vec<UniquePtr<MlxArray>> = Vec::with_capacity(infos.len());
+        let mut offset = 0i32;
+        for info in infos {
+            let n = info.num_tiles;
+            let tiles = mlxcel_core::slice(
+                proj_out,
+                &[offset, 0, 0],
+                &[offset + n, self.base_tokens, d],
+            );
+            offset += n;
+            features.push(self.pack_one_image(&tiles, info, d));
+        }
+        match features.len() {
+            1 => features.into_iter().next().unwrap(),
+            _ => {
+                let mut iter = features.into_iter();
+                let first = iter.next().unwrap();
+                iter.fold(first, |acc, next| mlxcel_core::concatenate(&acc, &next, 0))
+            }
+        }
+    }
+
+    fn pack_one_image(
+        &self,
+        tiles: &MlxArray,
+        info: &AnyResTileInfo,
+        d: i32,
+    ) -> UniquePtr<MlxArray> {
+        let side = self.feature_side;
+        let base_tokens = self.base_tokens;
+        let (nth, ntw) = (info.n_tiles_h, info.n_tiles_w);
+        let n_grid = nth * ntw;
+        let newline = mlxcel_core::astype(&self.image_newline, mlxcel_core::array_dtype(tiles));
+
+        let base = mlxcel_core::slice(tiles, &[0, 0, 0], &[1, base_tokens, d]);
+        let base = mlxcel_core::reshape(&base, &[base_tokens, d]);
+        if n_grid == 0 {
+            let nl = mlxcel_core::reshape(&newline, &[1, d]);
+            return mlxcel_core::concatenate(&base, &nl, 0);
+        }
+
+        let grid = mlxcel_core::slice(tiles, &[1, 0, 0], &[1 + n_grid, base_tokens, d]);
+        let grid = mlxcel_core::reshape(&grid, &[nth, ntw, side, side, d]);
+        let grid = mlxcel_core::transpose_axes(&grid, &[4, 0, 2, 1, 3]);
+        let gh = side * nth;
+        let gw = side * ntw;
+        let grid = mlxcel_core::reshape(&grid, &[d, gh, gw]);
+
+        let (h, w) = unpadded_token_hw(info.orig_h, info.orig_w, nth, ntw, side);
+        let unpad_rows = (info.orig_w as f64) / (info.orig_h as f64) > gw as f64 / gh as f64;
+        let grid = if unpad_rows {
+            let pad = (gh - h) / 2;
+            mlxcel_core::slice(&grid, &[0, pad, 0], &[d, pad + h, gw])
+        } else {
+            let pad = (gw - w) / 2;
+            mlxcel_core::slice(&grid, &[0, 0, pad], &[d, gh, pad + w])
+        };
+
+        let nl = mlxcel_core::reshape(&newline, &[d, 1, 1]);
+        let nl = mlxcel_core::broadcast_to(&nl, &[d, h, 1]);
+        let grid = mlxcel_core::concatenate(&grid, &nl, 2);
+        let grid = mlxcel_core::reshape(&grid, &[d, h * (w + 1)]);
+        let grid = mlxcel_core::transpose_axes(&grid, &[1, 0]);
+        mlxcel_core::concatenate(&base, &grid, 0)
+    }
+
+    /// Prefill through the backbone with the stashed injection (or plain when
+    /// there is nothing stashed / on decode).
+    fn inject_forward(
+        &self,
+        input_embeddings: Option<&MlxArray>,
+        input_ids: &MlxArray,
+        seq_id: Option<SequenceId>,
+        caches: &mut [KVCache],
+        mask: Option<&MlxArray>,
+    ) -> UniquePtr<MlxArray> {
+        let Some(embeds) = input_embeddings else {
+            return match seq_id {
+                Some(id) => {
+                    self.text_model
+                        .forward_with_sequence_id(input_ids, Some(id), caches, mask)
+                }
+                None => self.text_model.forward(input_ids, caches, mask),
+            };
+        };
+        match self.injection.borrow_mut().take() {
+            Some(stash) => {
+                let injection = HybridInjection {
+                    visual_pos_mask: &stash.mask,
+                    targets: stash.targets,
+                };
+                self.text_model
+                    .forward_embeds_with_injection(embeds, Some(&injection), seq_id)
+            }
+            None => self
+                .text_model
+                .forward_embeds_with_injection(embeds, None, seq_id),
+        }
+    }
+}
+
+impl LanguageModel for Granite4VisionVLModel {
+    fn forward(
+        &self,
+        input_ids: &MlxArray,
+        caches: &mut [KVCache],
+        mask: Option<&MlxArray>,
+    ) -> UniquePtr<MlxArray> {
+        self.text_model.forward(input_ids, caches, mask)
+    }
+
+    fn forward_with_embeddings(
+        &self,
+        input_ids: &MlxArray,
+        input_embeddings: Option<&MlxArray>,
+        caches: &mut [KVCache],
+        mask: Option<&MlxArray>,
+    ) -> UniquePtr<MlxArray> {
+        self.inject_forward(input_embeddings, input_ids, None, caches, mask)
+    }
+
+    fn forward_with_sequence_id(
+        &self,
+        input_ids: &MlxArray,
+        seq_id: Option<SequenceId>,
+        caches: &mut [KVCache],
+        mask: Option<&MlxArray>,
+    ) -> UniquePtr<MlxArray> {
+        self.text_model
+            .forward_with_sequence_id(input_ids, seq_id, caches, mask)
+    }
+
+    fn forward_with_embeddings_and_sequence_id(
+        &self,
+        input_ids: &MlxArray,
+        input_embeddings: Option<&MlxArray>,
+        seq_id: Option<SequenceId>,
+        caches: &mut [KVCache],
+        mask: Option<&MlxArray>,
+    ) -> UniquePtr<MlxArray> {
+        self.inject_forward(input_embeddings, input_ids, seq_id, caches, mask)
+    }
+
+    fn embed_tokens(&self, input_ids: &MlxArray) -> Option<UniquePtr<MlxArray>> {
+        Some(self.text_model.input_embeddings(input_ids))
+    }
+
+    fn make_caches(&self) -> Vec<KVCache> {
+        LanguageModel::make_caches(&self.text_model)
+    }
+
+    fn num_layers(&self) -> usize {
+        self.text_model.num_layers()
+    }
+
+    fn eos_token_ids(&self) -> Vec<i32> {
+        vec![self.eos_token_id]
+    }
+
+    fn supports_batching(&self) -> bool {
+        false
+    }
+
+    fn supports_padded_prefill(&self) -> bool {
+        false
+    }
+
+    fn prepare_sequence_state(&self, seq_id: SequenceId) {
+        self.text_model.prepare_sequence_state(seq_id);
+    }
+
+    fn release_sequence_state_by_id(&self, seq_id: SequenceId) {
+        self.text_model.release_sequence_state_by_id(seq_id);
+    }
+
+    fn supports_snapshot_reuse(&self) -> bool {
+        self.text_model.supports_snapshot_reuse()
+    }
+
+    fn snapshot_sequence_state(
+        &self,
+        seq_id: SequenceId,
+        token_len: usize,
+    ) -> Option<mlxcel_core::generate::ModelStateSnapshot> {
+        self.text_model.snapshot_sequence_state(seq_id, token_len)
+    }
+
+    fn restore_sequence_state(
+        &self,
+        seq_id: SequenceId,
+        snapshot: &mlxcel_core::generate::ModelStateSnapshot,
+    ) -> Result<(), String> {
+        self.text_model.restore_sequence_state(seq_id, snapshot)
+    }
+
+    fn trim_internal_caches(&self, excess: i32) {
+        self.text_model.trim_internal_caches(excess);
+    }
+}
diff --git a/src/vision/mod.rs b/src/vision/mod.rs
index 6b5e3a6e..12d267f5 100644
--- a/src/vision/mod.rs
+++ b/src/vision/mod.rs
@@ -47,6 +47,7 @@ pub mod gemma4_vl;
 pub mod glm4v;
 pub mod glm4v_moe;
 pub mod glm_ocr;
+pub mod granite4_vision;
 pub mod granite_vision;
 pub mod idefics2;
 pub mod internvl;
@@ -81,6 +82,7 @@ pub use glm_ocr::GlmOcrModel;
 pub use glm4v::Glm4vModel;
 pub use glm4v_moe::Glm4vMoeModel;
 pub use granite_vision::GraniteVisionVLModel;
+pub use granite4_vision::Granite4VisionVLModel;
 pub use idefics2::Idefics2Model;
 pub use internvl::InternVLChatVLM;
 pub use kimi_vl::{KimiVLModel, KimiVLMultiModalProjector};
diff --git a/tests/granite4_vision_parity.rs b/tests/granite4_vision_parity.rs
new file mode 100644
index 00000000..8faca3c8
--- /dev/null
+++ b/tests/granite4_vision_parity.rs
@@ -0,0 +1,104 @@
+// Copyright 2025-2026 Lablup Inc. and Jeongkyu Shin
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Granite 4 Vision (`granite4_vision`) parity tests.
+//!
+//! Config-level detection runs in CI without a checkpoint. The forward-pass
+//! tests are gated on a real Granite 4 Vision model under the shared model
+//! directory and `eprintln!` + return when absent.
+
+mod common;
+
+use std::path::PathBuf;
+
+use common::repo_model_dir;
+use mlxcel::models::{ModelType, get_model_type};
+
+const MODEL_NAME: &str = "granite-4.0-3b-vision-4bit";
+
+fn model_dir() -> Option<PathBuf> {
+    let dir = repo_model_dir(MODEL_NAME);
+    if dir.exists() {
+        Some(dir)
+    } else {
+        eprintln!("Skipping Granite 4 Vision test: model directory not found at {dir:?}");
+        None
+    }
+}
+
+#[test]
+fn detects_granite4_vision_model_type() {
+    let tmp = std::env::temp_dir().join(format!("mlxcel-granite4-detect-{}", std::process::id()));
+    std::fs::create_dir_all(&tmp).expect("create temp dir");
+    let config = r#"{
+        "model_type": "granite4_vision",
+        "image_token_index": 100352,
+        "downsample_rate": "4/8",
+        "deepstack_layer_map": [[-19, 9], [-13, 6], [-7, 3], [-1, 0]],
+        "spatial_target_layers": [12, 15, 18, 21],
+        "spatial_vision_layer": -1,
+        "text_config": {"model_type": "granitemoehybrid", "hidden_size": 2560},
+        "vision_config": {"model_type": "siglip_vision_model", "num_hidden_layers": 27,
+            "hidden_size": 1152, "intermediate_size": 4304, "num_attention_heads": 16,
+            "patch_size": 16, "image_size": 384}
+    }"#;
+    std::fs::write(tmp.join("config.json"), config).expect("write config.json");
+
+    let model_type = get_model_type(&tmp).expect("detect model type");
+    assert_eq!(model_type, ModelType::Granite4VisionVLM);
+
+    let _ = std::fs::remove_dir_all(&tmp);
+}
+
+#[test]
+fn detects_and_loads_real_model_as_vlm() {
+    let Some(dir) = model_dir() else { return };
+
+    let model_type = get_model_type(&dir).expect("detect model type");
+    assert_eq!(model_type, ModelType::Granite4VisionVLM);
+
+    let (model, _tokenizer) = mlxcel::load_model(&dir).expect("load Granite 4 Vision");
+    assert!(model.is_vlm(), "Granite 4 Vision must register as a VLM");
+}
+
+#[test]
+fn text_only_forward_produces_finite_logits() {
+    let Some(dir) = model_dir() else { return };
+
+    let (model, tokenizer) = mlxcel::load_model(&dir).expect("load Granite 4 Vision");
+
+    let tokens: Vec<i32> = tokenizer
+        .encode("Hello, world.", true)
+        .expect("tokenize")
+        .iter()
+        .map(|&t| t as i32)
+        .collect();
+    let input_ids = mlxcel_core::from_slice_i32(&tokens, &[1, tokens.len() as i32]);
+
+    let mut caches = mlxcel_core::generate::LanguageModel::make_caches(&model);
+    let logits =
+        mlxcel_core::generate::LanguageModel::forward(&model, &input_ids, &mut caches, None);
+    mlxcel_core::eval(&logits);
+
+    let shape = mlxcel_core::array_shape(&logits);
+    let vocab = *shape.last().unwrap();
+    let last_pos = shape[1] - 1;
+    let row = mlxcel_core::slice(&logits, &[0, last_pos, 0], &[1, last_pos + 1, vocab]);
+    let max = mlxcel_core::max_all(&row);
+    mlxcel_core::eval(&max);
+    assert!(
+        mlxcel_core::item_f32(&max).is_finite(),
+        "text-only logits must be finite"
+    );
+}
