Преглед на файлове

docker-images: dsv4-flash-vision

Alexander Korolev преди 1 седмица
родител
ревизия
bfeebc41cb

+ 14 - 0
docker-images/dsv4-flash-vision/Dockerfile

@@ -0,0 +1,14 @@
+ARG BASE_IMAGE
+FROM ${BASE_IMAGE}
+
+COPY plugin /opt/dsv4_vision_src
+COPY patch_dspark_skip_bias_vl.py /tmp/patch_dspark_skip_bias_vl.py
+COPY patch_mm_hc_streams.py /tmp/patch_mm_hc_streams.py
+RUN python3 -m pip install --no-cache-dir --no-deps /opt/dsv4_vision_src \
+    && python3 /tmp/patch_dspark_skip_bias_vl.py \
+    && python3 /tmp/patch_mm_hc_streams.py
+
+ENV VLLM_PLUGINS=dsv4_vision
+ENV TORCH_CUDA_ARCH_LIST=12.1a
+ENV FLASHINFER_CUDA_ARCH_LIST=12.1a
+ENTRYPOINT ["vllm", "serve"]

+ 11 - 0
docker-images/dsv4-flash-vision/Dockerfile.b12x-vision

@@ -0,0 +1,11 @@
+FROM eugr/spark-vllm-b12x:latest
+
+COPY plugin /opt/dsv4_vision_src
+COPY patch_dspark_skip_bias_vl.py /tmp/patch_dspark_skip_bias_vl.py
+RUN python3 -m pip install --no-cache-dir --no-deps /opt/dsv4_vision_src \
+    && python3 /tmp/patch_dspark_skip_bias_vl.py
+
+ENV VLLM_PLUGINS=dsv4_vision
+ENV TORCH_CUDA_ARCH_LIST=12.1a
+ENV FLASHINFER_CUDA_ARCH_LIST=12.1a
+ENTRYPOINT ["vllm", "serve"]

+ 21 - 0
docker-images/dsv4-flash-vision/UPSTREAM.md

@@ -0,0 +1,21 @@
+# Upstream provenance — DeepSeek-V4-Flash-Vision-Exp recipe
+
+Day-0 port of the multimodal lane:
+
+- repo:   https://github.com/sfxnz/DeepSeek-V4-Flash-Vision-Exp-vLLM-2x-DGX-Spark
+- commit: 7a958128b84a (2026-08-31)
+- license: repo LICENSE (see tree); model deepseek-ai/DeepSeek-V4-Flash-Vision-Exp is MIT;
+  model revision served: 86f746b36186f0e567729a5c06a8c918caba82a9
+
+| local file                | upstream path                          |
+|---------------------------|----------------------------------------|
+| Dockerfile                | docker/Dockerfile.b12x-vision (FROM genericized to ARG BASE_IMAGE; original pins eugr/spark-vllm-b12x:latest, digest sha256:7dc02f162929943ba2e14514066ed2a04bb7e9ed3592d4eb460ebcbb1f8376bd) |
+| patch_dspark_skip_bias_vl.py | docker/patch_dspark_skip_bias_vl.py (verbatim) |
+| plugin/dsv4_vision/*      | docker/plugin/dsv4_vision/* (verbatim; registered as VLLM_PLUGINS=dsv4_vision via the vllm.general_plugins entry point) |
+| build.sh, versions.env    | ours (generic driver; BASE_IMAGE/TAG overridable for the eugr-vs-aidendle94 fork comparison) |
+
+Known upstream limitations (their README): image-token expert routing in the
+MoE router is not wired for the ViT lane yet (`ffn.gate.bias_vl` gate
+tensors are skipped by the loader patch); text decode unaffected.
+VLLM_USE_B12X_MHC must stay OFF (this checkpoint's rms_norm_eps is 1e-20;
+the fused Gram mHC kernel only accepts 1e-6).

+ 32 - 0
docker-images/dsv4-flash-vision/build.sh

@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+# Build the SM121 DeepSeek-V4-Flash-Vision-Exp image. The generic driver
+# forwards every KEY=VAL pin in versions.env as a --build-arg to the
+# Dockerfile; TAG and BASE_IMAGE can be overridden via the environment for
+# the fork comparison (TAG=dsv4-flash-vision-eugr:local and
+# TAG=dsv4-flash-vision-aiden:local from the same context).
+set -euo pipefail
+cd "$(dirname "$0")"
+TAG="${TAG:-dsv4-flash-vision:local}"
+
+ARGS=()
+while IFS= read -r line; do
+    case "$line" in
+        ''|\#*) continue ;;
+    esac
+    key="${line%%=*}"; val="${line#*=}"
+    val="${val%\"}"; val="${val#\"}"
+    [ -n "$key" ] && ARGS+=(--build-arg "${key}=${val}")
+done < versions.env
+[ -n "${BASE_IMAGE:-}" ] && ARGS+=(--build-arg "BASE_IMAGE=${BASE_IMAGE}")
+
+echo "=== Building ${TAG} ==="
+# Persistent buildkit cache (per node, shared across image dirs): the
+# version-independent stages (apt, torch, CUDA base) are reused across
+# rebuilds — only the pinned source stages (vllm/b12x/…) re-download and
+# re-compile. Override the dir with SM121_BUILDKIT_CACHE if desired.
+CACHE_DIR="${SM121_BUILDKIT_CACHE:-$HOME/.cache/sm121-buildkit}"
+mkdir -p "${CACHE_DIR}"
+docker buildx build "${ARGS[@]}" --cache-from "type=local,src=${CACHE_DIR}" \
+    --cache-to "type=local,dest=${CACHE_DIR},mode=max" \
+    --progress=plain --load -t "${TAG}" -f Dockerfile . 2>&1
+echo "=== Done: ${TAG} ==="

+ 33 - 0
docker-images/dsv4-flash-vision/patch_dspark_skip_bias_vl.py

@@ -0,0 +1,33 @@
+#!/usr/bin/env python3
+"""Skip Vision-Exp gate tensors the B12X DSpark draft loader cannot bind."""
+from pathlib import Path
+
+import vllm
+
+base = Path(vllm.__file__).parent / "models" / "deepseek_v4" / "nvidia"
+for name in ("dspark.py", "dspark_v2.py"):
+    if (base / name).exists():
+        p = base / name
+        break
+else:
+    raise SystemExit("no dspark.py/dspark_v2.py under " + str(base))
+text = p.read_text()
+old = '''                if name.endswith(".ffn.gate.bias"):
+                    name = name.replace(
+                        ".ffn.gate.bias", ".ffn.gate.e_score_correction_bias"
+                    )
+                param = params_dict[name]
+'''
+new = '''                if name.endswith(".ffn.gate.bias_vl"):
+                    continue
+                if name.endswith(".ffn.gate.bias"):
+                    name = name.replace(
+                        ".ffn.gate.bias", ".ffn.gate.e_score_correction_bias"
+                    )
+                if name not in params_dict:
+                    continue
+                param = params_dict[name]
+'''
+if old not in text:
+    raise SystemExit(f"patch target missing in {p}")
+p.write_text(text.replace(old, new, 1))

+ 83 - 0
docker-images/dsv4-flash-vision/patch_mm_hc_streams.py

@@ -0,0 +1,83 @@
+#!/usr/bin/env python3
+"""Make the b12x runner's multimodal `inputs_embeds` buffers follow the
+model's native embed shape instead of a fixed 2-D [T, H].
+
+At boot the warmup probed `x` shapes of [16, 16, 4096] (hc-stream run) and
+[14, 4096] (plain run) from the SAME layer — the dsv4 b12x embedding emits
+varying rank per request. Re-fit the buffer whenever the non-token tail
+changes so both the mm merge and CUDA-graph capture see a stable tensor.
+"""
+from pathlib import Path
+
+import vllm
+
+p = (
+    Path(vllm.__file__).parent
+    / "v1/worker/gpu/mm/encoder_runner.py"
+)
+text = p.read_text()
+old = """        x = self.model.embed_input_ids(
+            input_ids, multimodal_embeddings=mm_embeds, is_multimodal=is_mm_embed
+        )
+        # Copy to the pre-allocated buffer for CUDA graphs.
+        self.inputs_embeds[: x.shape[0]] = x
+        return self.inputs_embeds
+"""
+new = """        x = self.model.embed_input_ids(
+            input_ids, multimodal_embeddings=mm_embeds, is_multimodal=is_mm_embed
+        )
+        # dsv4 b12x embeds vary in rank per request ([T, K, H] hc-stream runs,
+        # [T, H] plain): re-fit the buffer when the non-token tail changes.
+        tail = tuple(x.shape[1:])
+        if (
+            self.inputs_embeds.dim() != x.dim()
+            or tuple(self.inputs_embeds.shape[1:]) != tail
+        ):
+            self.inputs_embeds = torch.zeros(
+                self.max_num_tokens, *tail, dtype=x.dtype, device=x.device
+            )
+        # Copy to the pre-allocated buffer for CUDA graphs.
+        self.inputs_embeds[: x.shape[0]] = x
+        return self.inputs_embeds
+"""
+if old not in text:
+    raise SystemExit(f"encoder_runner patch target missing in {p}")
+p.write_text(text.replace(old, new, 1))
+print("encoder_runner mm shape-follow patch applied")
+
+# Same shape-follow for the DSpark speculator's re-embed buffer: with the
+# multimodal gate on, the MTP speculator re-embeds draft tokens through the
+# target's embed and assigns [B, T, H] rows into a [max, hidden] buffer.
+sp = (
+    Path(vllm.__file__).parent
+    / "v1/worker/gpu/spec_decode/multi_module_mtp/speculator.py"
+)
+sp_text = sp.read_text()
+sp_old = """        if self.inputs_embeds is not None:
+            mm_embeds, is_mm_embed = mm_inputs or (None, None)
+            self.inputs_embeds[:num_tokens] = self.model.embed_input_ids(
+                self.input_buffers.input_ids[:num_tokens],
+                multimodal_embeddings=mm_embeds,
+                is_multimodal=is_mm_embed,
+            )
+"""
+sp_new = """        if self.inputs_embeds is not None:
+            mm_embeds, is_mm_embed = mm_inputs or (None, None)
+            emb = self.model.embed_input_ids(
+                self.input_buffers.input_ids[:num_tokens],
+                multimodal_embeddings=mm_embeds,
+                is_multimodal=is_mm_embed,
+            )
+            if emb.dim() != self.inputs_embeds.dim() or tuple(
+                emb.shape[1:]
+            ) != tuple(self.inputs_embeds.shape[1:]):
+                self.inputs_embeds = torch.zeros_like(
+                    emb, dtype=emb.dtype, device=emb.device
+                )
+            self.inputs_embeds[:num_tokens] = emb
+"""
+if sp_old in sp_text:
+    sp.write_text(sp_text.replace(sp_old, sp_new, 1))
+    print("speculator re-embed shape-follow patch applied")
+else:
+    print("speculator re-embed pattern not found — skipping (harmless)")

+ 16 - 0
docker-images/dsv4-flash-vision/plugin/dsv4_vision/__init__.py

@@ -0,0 +1,16 @@
+"""vLLM general plugin for DeepSeek-V4-Flash-Vision-Exp."""
+
+__all__ = ["register"]
+
+
+def register() -> None:
+    from vllm.model_executor.models.registry import ModelRegistry
+
+    ModelRegistry.register_model(
+        "DeepseekV4ForCausalLM",
+        "dsv4_vision.model:DeepseekV4VisionForCausalLM",
+    )
+    ModelRegistry.register_model(
+        "DeepseekV4VisionForCausalLM",
+        "dsv4_vision.model:DeepseekV4VisionForCausalLM",
+    )

+ 957 - 0
docker-images/dsv4-flash-vision/plugin/dsv4_vision/encoding_dsv4.py

@@ -0,0 +1,957 @@
+"""
+DeepSeek-V4 Text and Vision Encoding
+
+A self-contained implementation for encoding/decoding DeepSeek-V4 chat messages
+with tool calling, thinking mode, quick instruction tasks, and image content blocks.
+"""
+
+from typing import Any, Dict, List, Union, Optional, Tuple
+import copy
+import json
+import re
+
+# ============================================================
+# Special Tokens
+# ============================================================
+
+bos_token: str = "<|begin▁of▁sentence|>"
+eos_token: str = "<|end▁of▁sentence|>"
+thinking_start_token: str = "<think>"
+thinking_end_token: str = "</think>"
+dsml_token: str = "|DSML|"
+
+USER_SP_TOKEN = "<|User|>"
+ASSISTANT_SP_TOKEN = "<|Assistant|>"
+LATEST_REMINDER_SP_TOKEN = "<|latest_reminder|>"
+IMAGE_PLACEHOLDER = "<|deepseek_image|>"
+IMAGE_TAG_PATTERN = re.compile(r"<image>(.*?)</image>", re.DOTALL)
+
+# Task special tokens for internal classification tasks
+DS_TASK_SP_TOKENS = {
+    "action": "<|action|>",
+    "query": "<|query|>",
+    "authority": "<|authority|>",
+    "domain": "<|domain|>",
+    "title": "<|title|>",
+    "read_url": "<|read_url|>",
+}
+VALID_TASKS = set(DS_TASK_SP_TOKENS.keys())
+
+# ============================================================
+# Templates
+# ============================================================
+
+system_msg_template: str = "{content}"
+user_msg_template: str = "{content}"
+latest_reminder_msg_template: str = "{content}"
+assistant_msg_template: str = "{reasoning}{content}{tool_calls}" + eos_token
+assistant_msg_wo_eos_template: str = "{reasoning}{content}{tool_calls}"
+thinking_template: str = "{reasoning_content}"
+
+response_format_template: str = (
+    "## Response Format:\n\nYou MUST strictly adhere to the following schema to reply:\n{schema}"
+)
+tool_call_template: str = (
+    "<{dsml_token}invoke name=\"{name}\">\n{arguments}\n</{dsml_token}invoke>"
+)
+tool_calls_template = (
+    "<{dsml_token}{tc_block_name}>\n{tool_calls}\n</{dsml_token}{tc_block_name}>"
+)
+tool_calls_block_name: str = "tool_calls"
+
+tool_output_template: str = (
+    "<tool_result>{content}</tool_result>"
+)
+
+# Reasoning effort levels. In thinking mode, the prompt for the selected level is
+# prepended at the very beginning of the conversation. `low` is the default and
+# adds nothing.
+REASONING_EFFORT_PROMPTS: Dict[str, str] = {
+    "low": "",
+    "high": (
+        "Reasoning Effort: Absolute maximum with no shortcuts permitted.\n"
+        "You MUST be very thorough in your thinking and comprehensively decompose the problem to resolve the root cause, rigorously stress-testing your logic against all potential paths, edge cases, and adversarial scenarios.\n"
+        "Explicitly write out your entire deliberation process, documenting every intermediate step, considered alternative, and rejected hypothesis to ensure absolutely no assumption is left unchecked.\n\n"
+    ),
+    "max": (
+        "Reasoning Effort: Beyond maximum — exhaustive, relentless, and uncompromising.\n"
+        "You MUST reason with the utmost depth and rigor, leaving absolutely nothing to chance: exhaustively decompose the problem into its most fundamental components, trace every causal chain to its root, and resolve the underlying cause rather than any surface symptom.\n"
+        "Do not stop reasoning until you have independently verified the solution from multiple angles and are certain that no assumption remains unchecked and no error remains undiscovered.\n\n"
+    ),
+}
+DEFAULT_REASONING_EFFORT = "low"
+
+TOOLS_TEMPLATE = """## Tools
+
+You have access to a set of tools to help answer the user's question. You can invoke tools by writing a "<{dsml_token}tool_calls>" block like the following:
+
+<{dsml_token}tool_calls>
+<{dsml_token}invoke name="$TOOL_NAME">
+<{dsml_token}parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</{dsml_token}parameter>
+...
+</{dsml_token}invoke>
+<{dsml_token}invoke name="$TOOL_NAME2">
+...
+</{dsml_token}invoke>
+</{dsml_token}tool_calls>
+
+String parameters should be specified as is and set `string="true"`. For all other types (numbers, booleans, arrays, objects), pass the value in JSON format and set `string="false"`.
+
+If thinking_mode is enabled (triggered by {thinking_start_token}), you MUST output your complete reasoning inside {thinking_start_token}...{thinking_end_token} BEFORE any tool calls or final response.
+
+Otherwise, output directly after {thinking_end_token} with tool calls or final response.
+
+### Available Tool Schemas
+
+{tool_schemas}
+
+You MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls.
+"""
+
+# ============================================================
+# Utility Functions
+# ============================================================
+
+def to_json(value: Any) -> str:
+    """Serialize a value to JSON string."""
+    try:
+        return json.dumps(value, ensure_ascii=False)
+    except:
+        return json.dumps(value, ensure_ascii=True)
+
+
+def tools_from_openai_format(tools):
+    """Extract function definitions from OpenAI-format tool list."""
+    return [tool["function"] for tool in tools]
+
+
+def tool_calls_from_openai_format(tool_calls):
+    """Convert OpenAI-format tool calls to internal format."""
+    return [
+        {
+            "name": tool_call["function"]["name"],
+            "arguments": tool_call["function"]["arguments"],
+        }
+        for tool_call in tool_calls
+    ]
+
+
+def tool_calls_to_openai_format(tool_calls):
+    """Convert internal tool calls to OpenAI format."""
+    return [
+        {
+            "type": "function",
+            "function": {
+                "name": tool_call["name"],
+                "arguments": tool_call["arguments"],
+            }
+        }
+        for tool_call in tool_calls
+    ]
+
+
+def encode_arguments_to_dsml(tool_call: Dict[str, str]) -> str:
+    """
+    Encode tool call arguments into DSML parameter format.
+
+    Args:
+        tool_call: Dict with "name" and "arguments" (JSON string) keys.
+
+    Returns:
+        DSML-formatted parameter string.
+    """
+    p_dsml_template = '<{dsml_token}parameter name="{key}" string="{is_str}">{value}</{dsml_token}parameter>'
+    P_dsml_strs = []
+
+    try:
+        arguments = json.loads(tool_call["arguments"])
+    except Exception as err:
+        arguments = {"arguments": tool_call["arguments"]}
+
+    for k, v in arguments.items():
+        p_dsml_str = p_dsml_template.format(
+            dsml_token=dsml_token,
+            key=k,
+            is_str="true" if isinstance(v, str) else "false",
+            value=v if isinstance(v, str) else to_json(v),
+        )
+        P_dsml_strs.append(p_dsml_str)
+
+    return "\n".join(P_dsml_strs)
+
+
+def decode_dsml_to_arguments(tool_name: str, tool_args: Dict[str, Tuple[str, str]]) -> Dict[str, str]:
+    """
+    Decode DSML parameters back to a tool call dict.
+
+    Args:
+        tool_name: Name of the tool.
+        tool_args: Dict mapping param_name -> (value, is_string_flag).
+
+    Returns:
+        Dict with "name" and "arguments" (JSON string) keys.
+    """
+    def _decode_value(key: str, value: str, string: str):
+        if string == "true":
+            value = to_json(value)
+        return f"{to_json(key)}: {value}"
+
+    tool_args_json = "{" + ", ".join([_decode_value(k, v, string=is_str) for k, (v, is_str) in tool_args.items()]) + "}"
+    return dict(name=tool_name, arguments=tool_args_json)
+
+
+def render_tools(tools: List[Dict[str, Union[str, Dict[str, Any]]]]) -> str:
+    """
+    Render tool schemas into the system prompt format.
+
+    Args:
+        tools: List of tool schema dicts (each with name, description, parameters).
+
+    Returns:
+        Formatted tools section string.
+    """
+    tools_json = [to_json(t) for t in tools]
+
+    return TOOLS_TEMPLATE.format(
+        tool_schemas="\n".join(tools_json),
+        dsml_token=dsml_token,
+        thinking_start_token=thinking_start_token,
+        thinking_end_token=thinking_end_token,
+    )
+
+
+def find_last_user_index(messages: List[Dict[str, Any]]) -> int:
+    """Find the index of the last user/developer message."""
+    last_user_index = -1
+    for idx in range(len(messages) - 1, -1, -1):
+        if messages[idx].get("role") in ["user", "developer"]:
+            last_user_index = idx
+            break
+    return last_user_index
+
+
+# ============================================================
+# Message Rendering
+# ============================================================
+
+def render_message(index: int, messages: List[Dict[str, Any]], thinking_mode: str, drop_thinking: bool = True, reasoning_effort: Optional[str] = None) -> str:
+    """
+    Render a single message at the given index into its encoded string form.
+
+    This is the core function that converts each message in the conversation
+    into the DeepSeek-V4 format.
+
+    Args:
+        index: Index of the message to render.
+        messages: Full list of messages in the conversation.
+        thinking_mode: Either "chat" or "thinking".
+        drop_thinking: Whether to drop reasoning content from earlier turns.
+        reasoning_effort: Reasoning effort level, one of "low", "high", "max".
+            None is treated as "low".
+
+    Returns:
+        Encoded string for this message.
+    """
+    assert 0 <= index < len(messages)
+    assert thinking_mode in ["chat", "thinking"], f"Invalid thinking_mode `{thinking_mode}`"
+
+    prompt = ""
+    msg = messages[index]
+    last_user_idx = find_last_user_index(messages)
+
+    role = msg.get("role")
+    content = msg.get("content")
+    tools = msg.get("tools")
+    response_format = msg.get("response_format")
+    tool_calls = msg.get("tool_calls")
+    reasoning_content = msg.get("reasoning_content")
+    wo_eos = msg.get("wo_eos", False)
+
+    if tools:
+        tools = tools_from_openai_format(tools)
+    if tool_calls:
+        tool_calls = tool_calls_from_openai_format(tool_calls)
+
+    # Reasoning effort prefix (only at index 0 in thinking mode; "low" adds nothing)
+    reasoning_effort = reasoning_effort or DEFAULT_REASONING_EFFORT
+    assert reasoning_effort in REASONING_EFFORT_PROMPTS, \
+        f"Invalid reasoning effort: {reasoning_effort}, expected one of {list(REASONING_EFFORT_PROMPTS)}"
+    if index == 0 and thinking_mode == "thinking":
+        prompt += REASONING_EFFORT_PROMPTS[reasoning_effort]
+
+    if role == "system":
+        prompt += system_msg_template.format(content=content or "")
+        if tools:
+            prompt += "\n\n" + render_tools(tools)
+        if response_format:
+            prompt += "\n\n" + response_format_template.format(schema=to_json(response_format))
+
+    elif role == "developer":
+        assert content, f"Invalid message for role `{role}`: {msg}"
+
+        content_developer = USER_SP_TOKEN
+        content_developer += content
+
+        if tools:
+            content_developer += "\n\n" + render_tools(tools)
+        if response_format:
+            content_developer += "\n\n" + response_format_template.format(schema=to_json(response_format))
+
+        prompt += user_msg_template.format(content=content_developer)
+
+    elif role == "user":
+        prompt += USER_SP_TOKEN
+
+        # Handle content blocks (tool results mixed with text)
+        content_blocks = msg.get("content_blocks")
+        if content_blocks:
+            parts = []
+            for block in content_blocks:
+                block_type = block.get("type")
+                if block_type == "text":
+                    parts.append(block.get("text", ""))
+                elif block_type == "tool_result":
+                    tool_content = block.get("content", "")
+                    if isinstance(tool_content, list):
+                        text_parts = []
+                        for b in tool_content:
+                            if b.get("type") == "text":
+                                text_parts.append(b.get("text", ""))
+                            else:
+                                text_parts.append(f"[Unsupported {b.get('type')}]")
+                        tool_content = "\n\n".join(text_parts)
+                    parts.append(tool_output_template.format(content=tool_content))
+                else:
+                    parts.append(f"[Unsupported {block_type}]")
+            prompt += "\n\n".join(parts)
+        else:
+            prompt += content or ""
+
+    elif role == "latest_reminder":
+        prompt += LATEST_REMINDER_SP_TOKEN + latest_reminder_msg_template.format(content=content)
+
+    elif role == "tool":
+        raise NotImplementedError("deepseek_v4 merges tool messages into user; please preprocess with merge_tool_messages()")
+
+    elif role == "assistant":
+        thinking_part = ""
+        tc_content = ""
+
+        if tool_calls:
+            tc_list = [
+                tool_call_template.format(
+                    dsml_token=dsml_token,
+                    name=tc.get("name"),
+                    arguments=encode_arguments_to_dsml(tc)
+                )
+                for tc in tool_calls
+            ]
+            tc_content += '\n\n' + tool_calls_template.format(
+                dsml_token=dsml_token,
+                tool_calls="\n".join(tc_list),
+                tc_block_name=tool_calls_block_name,
+            )
+
+        summary_content = content or ""
+        rc = reasoning_content or ""
+
+        # Check if previous message has a task - if so, this is a task output (no thinking)
+        prev_has_task = index - 1 >= 0 and messages[index - 1].get("task") is not None
+
+        if thinking_mode == "thinking" and not prev_has_task:
+            if not drop_thinking or index > last_user_idx:
+                thinking_part = thinking_template.format(reasoning_content=rc) + thinking_end_token
+            else:
+                thinking_part = ""
+
+        if wo_eos:
+            prompt += assistant_msg_wo_eos_template.format(
+                reasoning=thinking_part,
+                content=summary_content,
+                tool_calls=tc_content,
+            )
+        else:
+            prompt += assistant_msg_template.format(
+                reasoning=thinking_part,
+                content=summary_content,
+                tool_calls=tc_content,
+            )
+    else:
+        raise NotImplementedError(f"Unknown role: {role}")
+
+    # Append transition tokens based on what follows
+    if index + 1 < len(messages) and messages[index + 1].get("role") not in ["assistant", "latest_reminder"]:
+        return prompt
+
+    task = messages[index].get("task")
+    if task is not None:
+        # Task special token for internal classification tasks
+        assert task in VALID_TASKS, f"Invalid task: '{task}'. Valid tasks are: {list(VALID_TASKS)}"
+        task_sp_token = DS_TASK_SP_TOKENS[task]
+
+        if task != "action":
+            # Non-action tasks: append task sp token directly after the message
+            prompt += task_sp_token
+        else:
+            # Action task: append Assistant + thinking token + action sp token
+            prompt += ASSISTANT_SP_TOKEN
+            prompt += thinking_end_token if thinking_mode != "thinking" else thinking_start_token
+            prompt += task_sp_token
+
+    elif messages[index].get("role") in ["user", "developer"]:
+        # Normal generation: append Assistant + thinking token
+        prompt += ASSISTANT_SP_TOKEN
+        if not drop_thinking and thinking_mode == "thinking":
+            prompt += thinking_start_token
+        elif drop_thinking and thinking_mode == "thinking" and index >= last_user_idx:
+            prompt += thinking_start_token
+        else:
+            prompt += thinking_end_token
+
+    return prompt
+
+
+# ============================================================
+# Preprocessing
+# ============================================================
+
+def merge_tool_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+    """
+    Merge tool messages into the preceding user message using content_blocks format.
+
+    DeepSeek-V4 does not have a standalone "tool" role; instead, tool results
+    are encoded as <tool_result> blocks within user messages.
+
+    This function converts a standard OpenAI-format conversation (with separate
+    "tool" role messages) into V4 format where tool results are merged into
+    user messages.
+
+    Args:
+        messages: List of message dicts in OpenAI format.
+
+    Returns:
+        Processed message list with tool messages merged into user messages.
+    """
+    merged: List[Dict[str, Any]] = []
+
+    for msg in messages:
+        msg = copy.deepcopy(msg)
+        role = msg.get("role")
+
+        if role == "tool":
+            # Convert tool message to a user message with tool_result block
+            tool_block = {
+                "type": "tool_result",
+                "tool_use_id": msg.get("tool_call_id", ""),
+                "content": msg.get("content", ""),
+            }
+            # Merge into previous message if it's already a user (merged tool)
+            if merged and merged[-1].get("role") == "user" and "content_blocks" in merged[-1]:
+                merged[-1]["content_blocks"].append(tool_block)
+            else:
+                merged.append({
+                    "role": "user",
+                    "content_blocks": [tool_block],
+                })
+        elif role == "user":
+            content_blocks = msg.get("content_blocks")
+            if content_blocks is None:
+                content_blocks = [{"type": "text", "text": msg.get("content", "")}]
+            if merged and merged[-1].get("role") == "user" and "content_blocks" in merged[-1] and merged[-1].get("task") is None:
+                merged[-1]["content_blocks"].extend(content_blocks)
+            else:
+                # Preserve structured content and all message-level metadata.
+                new_msg = msg
+                new_msg["content_blocks"] = content_blocks
+                merged.append(new_msg)
+        else:
+            merged.append(msg)
+
+    return merged
+
+
+def sort_tool_results_by_call_order(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+    """
+    Sort tool_result blocks within user messages by the order of tool_calls
+    in the preceding assistant message.
+
+    Args:
+        messages: Preprocessed message list (after merge_tool_messages).
+
+    Returns:
+        Message list with sorted tool result blocks.
+    """
+    last_tool_call_order: Dict[str, int] = {}
+
+    for msg in messages:
+        role = msg.get("role")
+        if role == "assistant" and msg.get("tool_calls"):
+            last_tool_call_order = {}
+            for idx, tc in enumerate(msg["tool_calls"]):
+                tc_id = tc.get("id") or tc.get("function", {}).get("id", "")
+                if tc_id:
+                    last_tool_call_order[tc_id] = idx
+
+        elif role == "user" and msg.get("content_blocks"):
+            tool_blocks = [b for b in msg["content_blocks"] if b.get("type") == "tool_result"]
+            if len(tool_blocks) > 1 and last_tool_call_order:
+                sorted_blocks = sorted(
+                    tool_blocks,
+                    key=lambda b: last_tool_call_order.get(b.get("tool_use_id", ""), 0)
+                )
+                sorted_idx = 0
+                new_blocks = []
+                for block in msg["content_blocks"]:
+                    if block.get("type") == "tool_result":
+                        new_blocks.append(sorted_blocks[sorted_idx])
+                        sorted_idx += 1
+                    else:
+                        new_blocks.append(block)
+                msg["content_blocks"] = new_blocks
+
+    return messages
+
+
+# ============================================================
+# Main Encoding Function
+# ============================================================
+
+def _encode_messages_text(
+    messages: List[Dict[str, Any]],
+    thinking_mode: str,
+    context: Optional[List[Dict[str, Any]]] = None,
+    drop_thinking: bool = True,
+    add_default_bos_token: bool = True,
+    reasoning_effort: Optional[str] = None,
+) -> str:
+    """
+    Encode a list of messages into the DeepSeek-V4 prompt format.
+
+    This is the main entry point for encoding conversations. It handles:
+    - BOS token insertion
+    - Thinking mode with optional reasoning content dropping
+    - Tool message merging into user messages
+    - Multi-turn conversation context
+
+    Args:
+        messages: List of message dicts to encode.
+        thinking_mode: Either "chat" or "thinking".
+        context: Optional preceding context messages (already encoded prefix).
+        drop_thinking: If True, drop reasoning_content from earlier assistant turns
+                      (only keep reasoning for messages after the last user message).
+        add_default_bos_token: Whether to prepend BOS token at conversation start.
+        reasoning_effort: Reasoning effort level, one of "low", "high", "max".
+            Only takes effect in thinking mode. None is treated as "low".
+
+    Returns:
+        The encoded prompt string.
+    """
+    context = context if context else []
+
+    # Preprocess: merge tool messages and sort tool results
+    messages = merge_tool_messages(messages)
+    messages = sort_tool_results_by_call_order(context + messages)[len(context):]
+    if context:
+        context = merge_tool_messages(context)
+        context = sort_tool_results_by_call_order(context)
+
+    full_messages = context + messages
+
+    prompt = bos_token if add_default_bos_token and len(context) == 0 else ""
+
+    # Resolve drop_thinking: if any message has tools defined, don't drop thinking
+    effective_drop_thinking = drop_thinking
+    if any(m.get("tools") for m in full_messages):
+        effective_drop_thinking = False
+
+    if thinking_mode == "thinking" and effective_drop_thinking:
+        full_messages = _drop_thinking_messages(full_messages)
+        # After dropping, recalculate how many messages to render
+        # (context may have shrunk too)
+        num_to_render = len(full_messages) - len(_drop_thinking_messages(context))
+        context_len = len(full_messages) - num_to_render
+    else:
+        num_to_render = len(messages)
+        context_len = len(context)
+
+    for idx in range(num_to_render):
+        prompt += render_message(
+            idx + context_len,
+            full_messages,
+            thinking_mode=thinking_mode,
+            drop_thinking=effective_drop_thinking,
+            reasoning_effort=reasoning_effort,
+        )
+
+    return prompt
+
+
+def _drop_thinking_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+    """
+    Drop reasoning_content and non-essential messages before the last user message.
+
+    Behavior:
+    - Messages with role in ["user", "system", "tool", "latest_reminder"] are always kept.
+    - Messages at or after the last user index are always kept.
+    - Assistant messages before the last user get reasoning_content removed.
+    - Developer messages before the last user are dropped entirely.
+    """
+    last_user_idx = find_last_user_index(messages)
+    result = []
+    keep_roles = {"user", "system", "tool", "latest_reminder", "direct_search_results"}
+
+    for idx, msg in enumerate(messages):
+        role = msg.get("role")
+        if role in keep_roles or idx >= last_user_idx:
+            result.append(msg)
+        elif role == "assistant":
+            msg = copy.copy(msg)
+            msg.pop("reasoning_content", None)
+            result.append(msg)
+        # developer and other roles before last_user_idx are dropped
+
+    return result
+
+
+# ============================================================
+# Vision Message Preprocessing
+# ============================================================
+
+def parse_tagged_text(text: str) -> Union[str, List[Dict[str, Any]]]:
+    """Convert ``<image>path</image>`` text into standard content blocks."""
+    matches = list(IMAGE_TAG_PATTERN.finditer(text))
+    remaining = IMAGE_TAG_PATTERN.sub("", text)
+    if "<image>" in remaining or "</image>" in remaining:
+        raise ValueError("Malformed <image>path</image> tag")
+    if not matches:
+        return text
+
+    blocks: List[Dict[str, Any]] = []
+    cursor = 0
+    for match in matches:
+        if match.start() > cursor:
+            blocks.append({"type": "text", "text": text[cursor:match.start()]})
+        path = match.group(1)
+        if not path:
+            raise ValueError("Image path must not be empty")
+        blocks.append({
+            "type": "image_url",
+            "image_url": {"url": path},
+        })
+        cursor = match.end()
+    if cursor < len(text):
+        blocks.append({"type": "text", "text": text[cursor:]})
+    return blocks
+
+
+def _is_image_block(block: Dict[str, Any]) -> bool:
+    """Return whether a content block is an OpenAI/Anthropic/internal image."""
+    return isinstance(block, dict) and block.get("type") in ("image", "image_url")
+
+
+def _extract_image(block: Dict[str, Any]) -> Dict[str, Any]:
+    """Normalize a supported image block into an internal image record."""
+    record: Dict[str, Any] = {"type": "image"}
+    if block.get("type") == "image_url":
+        image_url = block.get("image_url")
+        if isinstance(image_url, str):
+            record["url"] = image_url
+        else:
+            record["url"] = (image_url or {}).get("url", "")
+    else:
+        for key in ("source", "url", "data"):
+            if key in block:
+                record[key] = block[key]
+    if not any(record.get(key) for key in ("source", "url", "data")):
+        raise ValueError("Image block does not contain a valid source")
+    return record
+
+
+def _process_image_blocks(
+    blocks: List[Any], image_placeholder: str = IMAGE_PLACEHOLDER
+) -> Tuple[List[Any], List[Dict[str, Any]]]:
+    """Replace image blocks and collect their records in one ordered traversal."""
+    new_blocks: List[Any] = []
+    images: List[Dict[str, Any]] = []
+    for block in blocks:
+        if not isinstance(block, dict):
+            new_blocks.append(block)
+            continue
+        if _is_image_block(block):
+            new_blocks.append({"type": "text", "text": image_placeholder})
+            images.append(_extract_image(block))
+        elif block.get("type") == "tool_result" and isinstance(block.get("content"), list):
+            block = copy.copy(block)
+            block["content"], nested_images = _process_image_blocks(
+                block["content"], image_placeholder)
+            new_blocks.append(block)
+            images.extend(nested_images)
+        elif block.get("type") == "text":
+            text = block.get("text") or ""
+            if IMAGE_PLACEHOLDER in text:
+                raise ValueError(
+                    f"Text block contains image placeholder '{IMAGE_PLACEHOLDER}': "
+                    f"'{text[:100]}'. Images should be separate content blocks."
+                )
+            new_blocks.append(block)
+        else:
+            new_blocks.append(block)
+    return new_blocks, images
+
+
+def _validate_no_image_sp_tokens(msg: Dict[str, Any]) -> None:
+    """Reject user-supplied image placeholder tokens in textual fields."""
+    content = msg.get("content")
+    if isinstance(content, str) and IMAGE_PLACEHOLDER in content:
+        raise ValueError(
+            f"Message content contains image special token '{IMAGE_PLACEHOLDER}'. "
+            "Images should be provided as image content blocks."
+        )
+    reasoning_content = msg.get("reasoning_content")
+    if isinstance(reasoning_content, str) and IMAGE_PLACEHOLDER in reasoning_content:
+        raise ValueError(
+            f"reasoning_content contains image special token '{IMAGE_PLACEHOLDER}'"
+        )
+
+
+def process_image_messages(
+    messages: List[Dict[str, Any]],
+) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
+    """Normalize image blocks and return their records in prompt order."""
+    processed: List[Dict[str, Any]] = []
+    images: List[Dict[str, Any]] = []
+    for msg in messages:
+        msg = copy.deepcopy(msg)
+        _validate_no_image_sp_tokens(msg)
+
+        if isinstance(msg.get("content"), list) and "content_blocks" not in msg:
+            msg["content_blocks"] = msg.pop("content")
+
+        if msg.get("content_blocks"):
+            msg["content_blocks"], message_images = _process_image_blocks(
+                msg["content_blocks"])
+            images.extend(message_images)
+            if not isinstance(msg.get("content"), str):
+                texts = [
+                    block.get("text", "")
+                    for block in msg["content_blocks"]
+                    if isinstance(block, dict) and block.get("type") == "text"
+                ]
+                msg["content"] = "\n\n".join(texts)
+
+        processed.append(msg)
+    return processed, images
+
+
+def encode_messages(
+    messages: List[Dict[str, Any]],
+    thinking_mode: str,
+    context: Optional[List[Dict[str, Any]]] = None,
+    drop_thinking: bool = True,
+    add_default_bos_token: bool = True,
+    reasoning_effort: Optional[str] = None,
+    return_multi_modal_data: bool = False,
+) -> Any:
+    """Encode text or multimodal messages through one canonical public entrypoint.
+
+    Text-only calls preserve the original string-returning API. When
+    return_multi_modal_data is true, the result is ``(prompt, media_data)``.
+    """
+    context = context or []
+    processed_context, _ = process_image_messages(context) if context else ([], [])
+    processed_messages, images = process_image_messages(messages)
+    prompt = _encode_messages_text(
+        processed_messages,
+        thinking_mode=thinking_mode,
+        context=processed_context if processed_context else None,
+        drop_thinking=drop_thinking,
+        add_default_bos_token=add_default_bos_token,
+        reasoning_effort=reasoning_effort,
+    )
+    if return_multi_modal_data:
+        return prompt, {"images": images}
+    return prompt
+
+
+def load_cases(input_file: str) -> List[Dict[str, Any]]:
+    """Load one or more OpenAI-format conversation cases from JSON."""
+    with open(input_file) as file:
+        data = json.load(file)
+    if isinstance(data, dict):
+        data = [data]
+    elif data and isinstance(data[0], dict) and "role" in data[0]:
+        data = [{"messages": data}]
+
+    cases = []
+    for case in data:
+        messages = copy.deepcopy(case["messages"])
+        if "tools" in case:
+            if not messages:
+                raise ValueError("A case with tools must contain at least one message")
+            messages[0]["tools"] = case["tools"]
+        cases.append({
+            "messages": messages,
+            "context": case.get("context"),
+            "thinking_mode": case.get("thinking_mode"),
+            "reasoning_effort": case.get("reasoning_effort"),
+        })
+    return cases
+
+
+def encode_case(
+    case: Dict[str, Any], thinking_mode: str
+) -> Tuple[str, List[Dict[str, Any]]]:
+    """Encode one JSON case and return its current-turn image records."""
+    prompt, media_data = encode_messages(
+        case["messages"],
+        thinking_mode=case.get("thinking_mode") or thinking_mode,
+        context=case.get("context"),
+        reasoning_effort=case.get("reasoning_effort"),
+        return_multi_modal_data=True,
+    )
+    return prompt, media_data["images"]
+
+
+# ============================================================
+# Parsing (Decoding model output)
+# ============================================================
+
+def _read_until_stop(index: int, text: str, stop: List[str]) -> Tuple[int, str, Optional[str]]:
+    """
+    Read text from index until one of the stop strings is found.
+
+    Returns:
+        Tuple of (new_index, content_before_stop, matched_stop_string_or_None).
+    """
+    min_pos = len(text)
+    matched_stop = None
+
+    for s in stop:
+        pos = text.find(s, index)
+        if pos != -1 and pos < min_pos:
+            min_pos = pos
+            matched_stop = s
+
+    if matched_stop:
+        content = text[index:min_pos]
+        return min_pos + len(matched_stop), content, matched_stop
+    else:
+        content = text[index:]
+        return len(text), content, None
+
+
+def parse_tool_calls(index: int, text: str) -> Tuple[int, Optional[str], List[Dict[str, str]]]:
+    """
+    Parse DSML tool calls from text starting at the given index.
+
+    Args:
+        index: Starting position in text.
+        text: The full text to parse.
+
+    Returns:
+        Tuple of (new_index, last_stop_token, list_of_tool_call_dicts).
+        Each tool call dict has "name" and "arguments" keys.
+    """
+    tool_calls: List[Dict[str, Any]] = []
+    stop_token = None
+    tool_calls_end_token = f"</{dsml_token}{tool_calls_block_name}>"
+
+    while index < len(text):
+        index, _, stop_token = _read_until_stop(index, text, [f"<{dsml_token}invoke", tool_calls_end_token])
+        if _ != ">\n":
+            raise ValueError(f"Tool call format error: expected '>\\n' but got '{_}'")
+
+        if stop_token == tool_calls_end_token:
+            break
+
+        if stop_token is None:
+            raise ValueError("Missing special token in tool calls")
+
+        index, tool_name_content, stop_token = _read_until_stop(index, text, [f"<{dsml_token}parameter", f"</{dsml_token}invoke"])
+
+        p_tool_name = re.findall(r'^\s*name="(.*?)">\n$', tool_name_content, flags=re.DOTALL)
+        if len(p_tool_name) != 1:
+            raise ValueError(f"Tool name format error: '{tool_name_content}'")
+        tool_name = p_tool_name[0]
+
+        tool_args: Dict[str, Tuple[str, str]] = {}
+        while stop_token == f"<{dsml_token}parameter":
+            index, param_content, stop_token = _read_until_stop(index, text, [f"/{dsml_token}parameter"])
+
+            param_kv = re.findall(r'^ name="(.*?)" string="(true|false)">(.*?)<$', param_content, flags=re.DOTALL)
+            if len(param_kv) != 1:
+                raise ValueError(f"Parameter format error: '{param_content}'")
+            param_name, string, param_value = param_kv[0]
+
+            if param_name in tool_args:
+                raise ValueError(f"Duplicate parameter name: '{param_name}'")
+            tool_args[param_name] = (param_value, string)
+
+            index, content, stop_token = _read_until_stop(index, text, [f"<{dsml_token}parameter", f"</{dsml_token}invoke"])
+            if content != ">\n":
+                raise ValueError(f"Parameter format error: expected '>\\n' but got '{content}'")
+
+        tool_call = decode_dsml_to_arguments(tool_name=tool_name, tool_args=tool_args)
+        tool_calls.append(tool_call)
+
+    return index, stop_token, tool_calls
+
+
+def parse_message_from_completion_text(text: str, thinking_mode: str) -> Dict[str, Any]:
+    """
+    Parse a model completion text into a structured assistant message.
+
+    This function takes the raw text output from the model (a single assistant turn)
+    and extracts:
+    - reasoning_content (thinking block)
+    - content (summary/response)
+    - tool_calls (if any)
+
+    NOTE: This function is designed to parse only correctly formatted strings and
+    will raise ValueError for malformed output.
+
+    Args:
+        text: The raw completion text (including EOS token).
+        thinking_mode: Either "chat" or "thinking".
+
+    Returns:
+        Dict with keys: "role", "content", "reasoning_content", "tool_calls".
+        tool_calls are in OpenAI format.
+    """
+    summary_content, reasoning_content, tool_calls = "", "", []
+    index, stop_token = 0, None
+    tool_calls_start_token = f"\n\n<{dsml_token}{tool_calls_block_name}"
+
+    is_thinking = thinking_mode == "thinking"
+    is_tool_calling = False
+
+    if is_thinking:
+        index, content_delta, stop_token = _read_until_stop(index, text, [thinking_end_token, tool_calls_start_token])
+        reasoning_content = content_delta
+        assert stop_token == thinking_end_token, "Invalid thinking format: missing </think>"
+
+    index, content_delta, stop_token = _read_until_stop(index, text, [eos_token, tool_calls_start_token])
+    summary_content = content_delta
+    if stop_token == tool_calls_start_token:
+        is_tool_calling = True
+    else:
+        assert stop_token == eos_token, "Invalid format: missing EOS token"
+
+    if is_tool_calling:
+        index, stop_token, tool_calls = parse_tool_calls(index, text)
+
+        index, tool_ends_text, stop_token = _read_until_stop(index, text, [eos_token])
+        assert not tool_ends_text, "Unexpected content after tool calls"
+
+    assert len(text) == index and stop_token in [eos_token, None], "Unexpected content at end"
+
+    for sp_token in [bos_token, eos_token, thinking_start_token, thinking_end_token, dsml_token]:
+        assert sp_token not in summary_content and sp_token not in reasoning_content, \
+            f"Unexpected special token '{sp_token}' in content"
+
+    return {
+        "role": "assistant",
+        "content": summary_content,
+        "reasoning_content": reasoning_content,
+        "tool_calls": tool_calls_to_openai_format(tool_calls)
+    }

+ 188 - 0
docker-images/dsv4-flash-vision/plugin/dsv4_vision/image_processor.py

@@ -0,0 +1,188 @@
+from __future__ import annotations
+
+import base64
+import io
+import math
+from dataclasses import dataclass
+from typing import Any
+from urllib.request import urlopen
+
+IMAGE_START, IMAGE_PAD, IMAGE, IMAGE_NEW_LINE, IMAGE_END = range(5)
+COMPRESS_PAD_TO = 4
+
+
+@dataclass
+class ImageInput:
+    start: int
+    patches: Any
+    n_vit_h: int
+    n_vit_w: int
+    types: Any
+    perm: Any
+
+
+def grid_tokens(best_height, best_width, patch_size, downsample_ratio):
+    """Number of LLM tokens the aligner grid occupies (N-layout, incl. row/align padding)."""
+    n_llm_h = math.ceil((best_height // patch_size) / downsample_ratio)
+    n_llm_w = math.ceil((best_width // patch_size) / downsample_ratio)
+    num_tokens = n_llm_h * (n_llm_w + 1) + 2
+    if n_llm_h % 2 == 1:
+        num_tokens += n_llm_w + 1
+    num_tokens += (n_llm_h + 1) // 2 * (n_llm_w + 1) % 2 * 2
+    return n_llm_h, n_llm_w, num_tokens
+
+
+def solve_resize_ratio(height, width, patch_size, downsample_ratio, max_n_token):
+    r = height / width
+    max_w_float = math.sqrt((max_n_token - 2) / r + 0.25) - 0.5
+    max_h_float = max_w_float * r
+    if max_w_float < 1.0:
+        max_w = 1
+        max_h = (max_n_token - 2) // (max_w + 1)
+        if max_h % 2 == 1:
+            max_h -= 1
+        best_width = max_w * patch_size * downsample_ratio
+        best_height = max_h * patch_size * downsample_ratio
+    elif max_h_float < 2.0:
+        max_h = 2
+        max_w = ((max_n_token - 2) // max_h) - 1
+        assert max_w > 1
+        best_width = max_w * patch_size * downsample_ratio
+        best_height = max_h * patch_size * downsample_ratio
+    else:
+        max_w = math.floor(max_w_float)
+        max_h = math.floor(max_h_float)
+        if max_h % 2 == 1:
+            max_h -= 1
+        beta = min(max_w * patch_size * downsample_ratio / width, max_h * patch_size * downsample_ratio / height)
+        best_width = math.floor(width * beta / patch_size) * patch_size
+        best_height = math.floor(height * beta / patch_size) * patch_size
+    n_llm_h, n_llm_w, num_tokens = grid_tokens(best_height, best_width, patch_size, downsample_ratio)
+    return n_llm_h, n_llm_w, best_height, best_width, num_tokens
+
+
+def safe_resize(height, width, best_height, best_width, patch_size, downsample_ratio, max_n_token):
+    max_n_token -= COMPRESS_PAD_TO - 1
+    n_llm_h, n_llm_w, num_tokens = grid_tokens(best_height, best_width, patch_size, downsample_ratio)
+    budget = max_n_token
+    while num_tokens > max_n_token:
+        n_llm_h, n_llm_w, best_height, best_width, num_tokens = solve_resize_ratio(
+            height, width, patch_size, downsample_ratio, budget)
+        budget -= 1
+    return n_llm_h, n_llm_w, best_height, best_width
+
+
+def load_image_bytes(record) -> bytes:
+    """Load image bytes from raw/base64 data, an Anthropic source, URL, or path."""
+    data = record.get("data")
+    if isinstance(data, bytes):
+        return data
+    if isinstance(data, str):
+        return base64.b64decode(data)
+
+    source = record.get("source")
+    if isinstance(source, dict):
+        if source.get("data") is not None:
+            return base64.b64decode(source["data"])
+        if source.get("url"):
+            return load_image_bytes({"url": source["url"]})
+
+    url = record.get("url")
+    if isinstance(url, str) and url:
+        if url.startswith("data:"):
+            header, _, payload = url.partition(",")
+            if ";base64" not in header:
+                raise ValueError(f"Unsupported data URL encoding: {header}")
+            return base64.b64decode(payload)
+        if url.startswith(("http://", "https://")):
+            with urlopen(url, timeout=30) as response:
+                return response.read()
+        with open(url, "rb") as file:
+            return file.read()
+
+    raise ValueError(f"Cannot load image from record: {list(record.keys())}")
+
+
+def load_image(record, args):
+    """Load and transform one image record into ViT patches."""
+    import numpy as np
+    import torch
+    from PIL import Image, ImageOps
+
+    p = args.vision_patch_size
+    with Image.open(io.BytesIO(load_image_bytes(record))) as source:
+        image = source.convert("RGB")
+    width, height = image.size
+    if args.vision_max_wh_ratio is not None and width > height * args.vision_max_wh_ratio:
+        width = height * args.vision_max_wh_ratio
+    if 0 < width * height < args.vision_min_pixels:
+        ratio = (args.vision_min_pixels / (width * height)) ** 0.5
+        width = int(width * ratio)
+        height = int(height * ratio)
+    best_width = math.ceil(width / p) * p
+    best_height = math.ceil(height / p) * p
+    n_llm_h, n_llm_w, best_height, best_width = safe_resize(
+        height, width, best_height, best_width, p, args.vision_downsample_ratio, args.vision_max_n_token)
+    n_vit_h, n_vit_w = best_height // p, best_width // p
+    if args.vision_max_wh_ratio is not None and image.width >= args.vision_max_wh_ratio * image.height:
+        image = image.resize((best_width, best_height))
+    else:
+        image = ImageOps.pad(image, (best_width, best_height), color=(127, 127, 127))
+    x = torch.from_numpy(np.asarray(image, dtype=np.float32)).permute(2, 0, 1) / 255
+    x = ((x - 0.5) / 0.5).to(torch.bfloat16)
+    patches = x.reshape(3, n_vit_h, p, n_vit_w, p).permute(1, 3, 0, 2, 4).reshape(n_vit_h * n_vit_w, 3, p, p)
+    return patches, n_vit_h, n_vit_w, n_llm_h, n_llm_w
+
+
+def build_image_block(n_llm_h: int, n_llm_w: int, start_pos: int):
+    """Builds the N-layout token types (final order) and the aligner-row order for IMAGE slots."""
+    import torch
+
+    compress_pad = COMPRESS_PAD_TO - 1 - start_pos % COMPRESS_PAD_TO
+    pad_h = n_llm_h % 2
+    rows = n_llm_h + pad_h
+    row_len = n_llm_w + 1
+    pad_last = rows // 2 * row_len % 2 * 2
+    types = torch.tensor(([IMAGE] * n_llm_w + [IMAGE_NEW_LINE]) * n_llm_h + [IMAGE_PAD] * (row_len * pad_h), dtype=torch.int64)
+    order = torch.arange(rows * row_len).view(rows // 2, 2, row_len).transpose(1, 2).reshape(-1)
+    image_idx = torch.full((rows * row_len,), -1, dtype=torch.int64)
+    image_idx.view(rows, row_len)[:n_llm_h, :n_llm_w] = torch.arange(n_llm_h * n_llm_w).view(n_llm_h, n_llm_w)
+    perm = image_idx[order]
+    perm = perm[perm >= 0]
+    types = torch.cat([
+        torch.full((compress_pad,), IMAGE_PAD, dtype=torch.int64),
+        torch.tensor([IMAGE_START]),
+        types[order],
+        torch.full((pad_last,), IMAGE_PAD, dtype=torch.int64),
+        torch.tensor([IMAGE_END]),
+    ])
+    return types, perm
+
+
+def prepare_vl_inputs(prompt, images, tokenizer, args):
+    """Expand image placeholder tokens into sentinel blocks and ImageInput values."""
+    from .encoding_dsv4 import IMAGE_PLACEHOLDER
+
+    image_token_id = tokenizer.convert_tokens_to_ids(IMAGE_PLACEHOLDER)
+    if image_token_id is None or image_token_id == tokenizer.unk_token_id:
+        raise ValueError(f"Token not found in tokenizer: {IMAGE_PLACEHOLDER}")
+    prompt_tokens = tokenizer.encode(prompt)
+    num_placeholders = sum(token == image_token_id for token in prompt_tokens)
+    if num_placeholders != len(images):
+        raise ValueError(
+            f"Found {num_placeholders} image tokens but got {len(images)} images")
+
+    tokens, image_inputs = [], []
+    image_iter = iter(images)
+    for tok in prompt_tokens:
+        if tok != image_token_id:
+            tokens.append(tok)
+            continue
+        patches, n_vit_h, n_vit_w, n_llm_h, n_llm_w = load_image(
+            next(image_iter), args)
+        types, perm = build_image_block(n_llm_h, n_llm_w, len(tokens))
+        image_inputs.append(ImageInput(len(tokens), patches, n_vit_h, n_vit_w, types, perm))
+        tokens += (args.vocab_size + types).tolist()
+    if not image_inputs:
+        return tokens, None
+    return tokens, image_inputs

+ 259 - 0
docker-images/dsv4-flash-vision/plugin/dsv4_vision/model.py

@@ -0,0 +1,259 @@
+"""Vision-Exp wrapper around vLLM DeepseekV4ForCausalLM.
+
+Loads official `vision.*` / `aligner.*` / image sentinel tensors and merges
+ViT embeddings on prefill. Language decode stays on the B12X DeepSeek V4 path.
+
+The OpenAI multimodal branch (processor trio + embed-merge hooks) is gated
+behind VLLM_DSV4_MM=1: the fork's V2 "always-embed" mm arm routes through
+this override with a [B, T]-ranks ids slice and currently degenerates
+logits, so with VLLM_DSV4_MM unset (the default) the lane boots text-only —
+the verified state — and VLLM_DSV4_MM=1 opts into the experimental path.
+"""
+
+from __future__ import annotations
+
+import os
+from types import SimpleNamespace
+from typing import Any
+
+import torch
+import torch.nn as nn
+
+from vllm.model_executor.models.utils import AutoWeightsLoader
+from vllm.models.deepseek_v4 import DeepseekV4ForCausalLM
+from vllm.multimodal import MULTIMODAL_REGISTRY
+
+from .processor import (
+    DSV4VisionDummyInputsBuilder,
+    DSV4VisionMultiModalProcessor,
+    DSV4VisionProcessingInfo,
+)
+from .vision import Aligner, ViT
+
+_MM_ON = os.environ.get("VLLM_DSV4_MM", "0") == "1"
+
+
+def _maybe_mm(cls):
+    """Register the OpenAI multimodal processor trio only when mm is on."""
+    if not _MM_ON:
+        return cls
+    return MULTIMODAL_REGISTRY.register_processor(
+        DSV4VisionMultiModalProcessor,
+        info=DSV4VisionProcessingInfo,
+        dummy_inputs=DSV4VisionDummyInputsBuilder,
+    )(cls)
+
+
+def _vision_args(hf: Any) -> SimpleNamespace:
+    return SimpleNamespace(
+        vision_n_layers=int(getattr(hf, "vision_n_layers", 0) or 0),
+        vision_dim=int(getattr(hf, "vision_dim", 1024)),
+        vision_n_heads=int(getattr(hf, "vision_n_heads", 16)),
+        vision_inter_dim=int(getattr(hf, "vision_inter_dim", 2816)),
+        vision_patch_size=int(getattr(hf, "vision_patch_size", 14)),
+        vision_rope_theta=float(getattr(hf, "vision_rope_theta", 10000.0)),
+        vision_downsample_ratio=int(getattr(hf, "vision_downsample_ratio", 3)),
+        vision_max_n_token=int(getattr(hf, "vision_max_n_token", 384)),
+        vision_min_pixels=int(getattr(hf, "vision_min_pixels", 147456)),
+        vision_max_wh_ratio=int(getattr(hf, "vision_max_wh_ratio", 8)),
+        dim=int(getattr(hf, "hidden_size", 4096)),
+        num_hash_layers=int(getattr(hf, "num_hash_layers", 0) or 0),
+    )
+
+
+@_maybe_mm
+class DeepseekV4VisionForCausalLM(DeepseekV4ForCausalLM):
+    # ModelConfig.is_multimodal_model keys off this class attribute
+    # (interfaces.supports_multimodal) — without it the OpenAI layer
+    # answers "not a multimodal model" for image_url requests. Gated by
+    # _maybe_mm so the text-only default lane stays verified.
+    supports_multimodal = _MM_ON
+    # The fork's hash MoE routes by token id (tid2eid gather), so the merged
+    # mm path must keep input_ids alongside the merged embeddings.
+    requires_raw_input_tokens = _MM_ON
+    supports_multimodal_raw_input_only = False
+    supports_encoder_tp_data = False
+    supports_mm_device_do_normalize = False
+
+    @classmethod
+    def get_placeholder_str(cls, modality: str, i: int) -> str | None:
+        # chat_utils asks the resolved model class for the per-image prompt
+        # placeholder; the tokenizer encodes it to <|deepseek_image|> (id 129264).
+        if _MM_ON and modality.startswith("image"):
+            from .encoding_dsv4 import IMAGE_PLACEHOLDER
+
+            return IMAGE_PLACEHOLDER
+        return None
+
+    def __init__(self, *, vllm_config, prefix: str = ""):
+        super().__init__(vllm_config=vllm_config, prefix=prefix)
+        hf = vllm_config.model_config.hf_config
+        args = _vision_args(hf)
+        self._vision_args = args
+        if args.vision_n_layers <= 0:
+            self.vision = None
+            self.aligner = None
+            return
+        self.vision = ViT(args)
+        self.aligner = Aligner(args)
+        self.image_start = nn.Parameter(torch.empty(args.dim))
+        self.image_end = nn.Parameter(torch.empty(args.dim))
+        self.image_newline = nn.Parameter(torch.empty(args.dim))
+        self.image_pad = nn.Parameter(torch.empty(args.dim))
+
+    def encode_image(self, patches: torch.Tensor, n_vit_h: int, n_vit_w: int) -> torch.Tensor:
+        return self.aligner(self.vision(patches, n_vit_h, n_vit_w), n_vit_h, n_vit_w)
+
+    def embed_input_ids(
+        self,
+        input_ids: torch.Tensor,
+        multimodal_embeddings=None,
+        *,
+        is_multimodal: torch.Tensor | None = None,
+    ) -> torch.Tensor:
+        # The fork's embed output follows the input rank: [B, T, H] from the
+        # speculator's [B, T] ids, [T, H] from plain [T] ids. mm rows must
+        # land on the same rows either way (they never exceed inner dims).
+        if is_multimodal is not None:
+            # Sentinel block ids may exceed vocab; zero them before lookup.
+            input_ids = input_ids.masked_fill(
+                is_multimodal.to(input_ids.device).unsqueeze(-1), 0
+            )
+        out = super().embed_input_ids(input_ids)
+        if multimodal_embeddings:
+            mm_stack = torch.cat(multimodal_embeddings)  # [N, H]
+            if out.dim() == 3:
+                out[is_multimodal] = mm_stack.to(out.dtype)
+            else:
+                out.masked_scatter_(
+                    is_multimodal.to(out.device).unsqueeze(-1),
+                    mm_stack.reshape(-1),
+                )
+        return out
+
+    def embed_multimodal(self, **kwargs) -> tuple:
+        patches = kwargs.get("image_patches")
+        if patches is None or self.vision is None:
+            return ()
+        grids = kwargs["image_grids"].data
+        types_all = kwargs["image_types"].data
+        perm_all = kwargs["image_perm"].data
+        t_counts = kwargs["image_type_counts"].data.tolist()
+        perm_counts = kwargs["image_perm_counts"].data.tolist()
+        n_items = int(grids.shape[0])
+
+        out: list[torch.Tensor] = []
+        p_off = t_off = q_off = 0
+        start = self.image_start
+        pad = self.image_pad
+        newline = self.image_newline
+        end = self.image_end
+        for i in range(n_items):
+            n_vit_h, n_vit_w, n_llm_h, n_llm_w = grids[i].tolist()
+            p_count = n_vit_h * n_vit_w
+            t_count = int(t_counts[i])
+            q_count = int(perm_counts[i])
+            item_types = types_all[t_off : t_off + t_count].tolist()
+            item_perm = perm_all[q_off : q_off + q_count].to(torch.long)
+            if q_count != sum(t == 2 for t in item_types):
+                raise ValueError(
+                    "DSV4 vision perm/sentinel layout mismatch "
+                    f"({q_count} != {sum(t == 2 for t in item_types)})"
+                )
+            t_off += t_count
+            q_off += q_count
+            vit = self.encode_image(patches[p_off : p_off + p_count], n_vit_h, n_vit_w)
+            p_off += p_count
+            rows: list[torch.Tensor] = []
+            vi = 0
+            for t in item_types:
+                if t == 0:  # IMAGE_START
+                    rows.append(start)
+                elif t == 1:  # IMAGE_PAD
+                    rows.append(pad)
+                elif t == 2:  # IMAGE
+                    rows.append(vit[int(item_perm[vi])])
+                    vi += 1
+                elif t == 3:  # IMAGE_NEW_LINE
+                    rows.append(newline)
+                else:  # IMAGE_END
+                    rows.append(end)
+            out.append(torch.stack(rows))
+
+        return tuple(out)
+
+    def forward(
+        self,
+        input_ids,
+        positions,
+        intermediate_tensors=None,
+        inputs_embeds=None,
+        **kwargs,
+    ):
+        # The runner embeds via embed_input_ids (mm merge) and hands us
+        # merged inputs_embeds; lm_head and get_mtp_target_hidden_states
+        # stay on the parent class.
+        if not _MM_ON and inputs_embeds is not None:
+            # text-only belt-and-braces: without the mm gate the parent never
+            # sees runner-generated embeds, but if one sneaks in, sync the
+            # token path instead so hidden shapes stay native.
+            inputs_embeds = None
+        return super().forward(
+            input_ids,
+            positions,
+            intermediate_tensors,
+            inputs_embeds,
+        )
+
+    def load_weights(self, weights):
+        if self.vision is None:
+            return super().load_weights(weights)
+        leftover = []
+        stacked = {
+            "vision.": self.vision,
+            "aligner.": self.aligner,
+        }
+        params = {
+            "image_start": self.image_start,
+            "image_end": self.image_end,
+            "image_newline": self.image_newline,
+            "image_pad": self.image_pad,
+        }
+        for name, tensor in weights:
+            if name in params:
+                params[name].data.copy_(tensor)
+                continue
+            routed = False
+            for prefix, module in stacked.items():
+                if name.startswith(prefix):
+                    AutoWeightsLoader(module).load_weights(
+                        [(name[len(prefix) :], tensor)]
+                    )
+                    routed = True
+                    break
+            if not routed:
+                leftover.append((name, tensor))
+        leftover = [
+            pair
+            for pair in leftover
+            if not _skip_unmapped_gate(pair[0], self._vision_args)
+        ]
+        return super().load_weights(leftover)
+
+
+def _skip_unmapped_gate(name: str, args: SimpleNamespace) -> bool:
+    # Hash MoE layers register tid2eid and leave e_score_correction_bias as
+    # None. The B12X mapper still rewrites ffn.gate.bias onto that missing
+    # name. bias_vl has no mapper at all.
+    if name.endswith("ffn.gate.bias_vl"):
+        return True
+    if name.endswith("ffn.gate.bias"):
+        parts = name.split(".")
+        if (
+            len(parts) >= 5
+            and parts[0] == "layers"
+            and parts[1].isdigit()
+            and int(parts[1]) < args.num_hash_layers
+        ):
+            return True
+    return False

+ 352 - 0
docker-images/dsv4-flash-vision/plugin/dsv4_vision/processor.py

@@ -0,0 +1,352 @@
+"""OpenAI-multimodal processor wiring for DeepSeek-V4-Flash-Vision-Exp.
+
+dots3_note-style processor trio (workflow of the b12x vLLM fork):
+
+- a small HF-like processor that tokenizes prompt text (placeholders intact)
+  and precomputes ViT patches / grid / sentinel layout per image,
+- a dummy builder for MM profiling,
+- a `BaseMultiModalProcessor` whose prompt update expands every
+  `<|deepseek_image|>` token (vocab id 129264) into the per-image sentinel
+  block built by `image_processor.build_image_block`, with the whole block
+  flagged as mm embeddings.
+
+`model.embed_multimodal` turns the tensors into per-image embedding runs and
+`model.embed_input_ids` scatters them over the flagged range on prefill.
+"""
+
+from __future__ import annotations
+
+import math
+from collections.abc import Mapping, Sequence
+from functools import cached_property
+from typing import Any, cast
+
+import numpy as np
+import torch
+from PIL import Image
+from transformers import BatchFeature
+
+from vllm.config.multimodal import BaseDummyOptions, ImageDummyOptions
+from vllm.inputs import MultiModalDataDict
+from vllm.multimodal.inputs import MultiModalFieldConfig, MultiModalKwargsItems
+from vllm.multimodal.parse import MultiModalDataItems
+from vllm.multimodal.processing import (
+    BaseDummyInputsBuilder,
+    BaseMultiModalProcessor,
+    BaseProcessingInfo,
+    PromptReplacement,
+    PromptUpdateDetails,
+)
+
+from .encoding_dsv4 import IMAGE_PLACEHOLDER
+from .image_processor import (
+    COMPRESS_PAD_TO,
+    build_image_block,
+    grid_tokens,
+    safe_resize,
+    solve_resize_ratio,
+)
+
+_PROTO_PATCHES = 60  # prototype patch-grid side for the dummy-size sweep
+
+
+def _vision_kv(hf: Any) -> dict[str, int | float]:
+    return {
+        "vision_patch_size": int(getattr(hf, "vision_patch_size", 14)),
+        "vision_downsample_ratio": int(getattr(hf, "vision_downsample_ratio", 3)),
+        "vision_max_n_token": int(getattr(hf, "vision_max_n_token", 384)),
+        "vision_min_pixels": int(getattr(hf, "vision_min_pixels", 147456)),
+        "vision_max_wh_ratio": int(getattr(hf, "vision_max_wh_ratio", 8)),
+    }
+
+
+class DSV4VisionImagePreparer:
+    """Resize/pad/patch a PIL image per the day-0 `load_image` math."""
+
+    def __init__(self, hf: Any) -> None:
+        for key, value in _vision_kv(hf).items():
+            setattr(self, key, value)
+        self.image_token_id: int | None = None
+
+    @property
+    def p(self) -> int:
+        return self.vision_patch_size
+
+    def prepare(self, image: Image.Image) -> dict[str, torch.Tensor]:
+        image = image.convert("RGB")
+        width, height = image.size
+        if self.vision_max_wh_ratio and width > height * self.vision_max_wh_ratio:
+            width = height * self.vision_max_wh_ratio
+        if 0 < width * height < self.vision_min_pixels:
+            scale = (self.vision_min_pixels / (width * height)) ** 0.5
+            width = int(width * scale)
+            height = int(height * scale)
+        best_width = math.ceil(width / self.p) * self.p
+        best_height = math.ceil(height / self.p) * self.p
+        n_llm_h, n_llm_w, best_height, best_width = safe_resize(
+            height,
+            width,
+            best_height,
+            best_width,
+            self.p,
+            self.vision_downsample_ratio,
+            self.vision_max_n_token,
+        )
+        n_vit_h, n_vit_w = best_height // self.p, best_width // self.p
+        if self.vision_max_wh_ratio and image.width >= self.vision_max_wh_ratio * image.height:
+            resized = image.resize((best_width, best_height))
+        else:
+            resized = _pad_image(image, best_width, best_height)
+        x = torch.from_numpy(np.asarray(resized, dtype="float32")).permute(2, 0, 1) / 255
+        x = ((x - 0.5) / 0.5).to(torch.bfloat16)
+        patches = (
+            x.reshape(3, n_vit_h, self.p, n_vit_w, self.p)
+            .permute(1, 3, 0, 2, 4)
+            .reshape(n_vit_h * n_vit_w, 3, self.p, self.p)
+        )
+        return dict(
+            patches=patches,
+            n_vit_h=n_vit_h,
+            n_vit_w=n_vit_w,
+            n_llm_h=n_llm_h,
+            n_llm_w=n_llm_w,
+        )
+
+    def max_block_scan(self) -> tuple[int, int, int]:
+        """(best_h, best_w) and its block length over candidate aspect ratios."""
+        base = _PROTO_PATCHES * self.p * self.vision_downsample_ratio
+        ratios = [1 / r for r in (1, 2, 3, 4, 5, 6, 7, 8)]
+        ratios += [1 / r for r in ratios[1:]]
+        best: tuple[int, int, int] = (0, 0, 0)
+        for height_ratio in ratios:
+            height = max(self.p * self.vision_downsample_ratio, int(base * height_ratio))
+            width = max(self.p * self.vision_downsample_ratio, int(base / height_ratio))
+            best_h, best_w = math.ceil(height / self.p) * self.p, math.ceil(width / self.p) * self.p
+            n_h, n_w, fb_h, fb_w, _ = solve_resize_ratio(
+                height, width, self.p, self.vision_downsample_ratio, self.vision_max_n_token
+            )
+            best_h, best_w = max(best_h, fb_h), max(best_w, fb_w)
+            _, _, num = grid_tokens(fb_h, fb_w, self.p, self.vision_downsample_ratio)
+            if num > best[2]:
+                best = (fb_h, fb_w, num + COMPRESS_PAD_TO - 1)
+        return best
+
+
+def _pad_image(image: Image.Image, width: int, height: int) -> Image.Image:
+    from PIL import ImageOps
+
+    return ImageOps.pad(image, (width, height), color=(127, 127, 127))
+
+
+class DSV4VisionProcessingInfo(BaseProcessingInfo):
+    @cached_property
+    def preparer(self) -> DSV4VisionImagePreparer:
+        return DSV4VisionImagePreparer(self.get_hf_config())
+
+    @cached_property
+    def processor(self) -> "DSV4VisionProcessor":
+        return DSV4VisionProcessor(
+            self.get_tokenizer(),
+            self.preparer,
+            vocab_size=int(self.get_hf_config().vocab_size),
+        )
+
+    def get_hf_processor(self, **kwargs: object) -> "DSV4VisionProcessor":
+        return self.processor
+
+    def get_supported_mm_limits(self) -> Mapping[str, int | None]:
+        return {"image": None}
+
+    @cached_property
+    def max_image_size(self) -> tuple[int, int]:
+        best_h, best_w, _ = self.preparer.max_block_scan()
+        return best_w, best_h
+
+    def get_mm_max_tokens_per_item(
+        self,
+        seq_len: int,
+        mm_counts: Mapping[str, int],
+    ) -> Mapping[str, int]:
+        if not mm_counts.get("image"):
+            return {}
+        _, _, num = self.preparer.max_block_scan()
+        return {"image": num + 4}
+
+
+class DSV4VisionProcessor:
+    """HF-like processor: tokenize text, precompute image tensors."""
+
+    def __init__(self, tokenizer, preparer: DSV4VisionImagePreparer, vocab_size: int) -> None:
+        self.tokenizer = tokenizer
+        self.preparer = preparer
+        self.vocab_size = vocab_size
+        ids = tokenizer.convert_tokens_to_ids(IMAGE_PLACEHOLDER)
+        if ids is None or ids == getattr(tokenizer, "unk_token_id", -1):
+            raise ValueError(f"Token not found in tokenizer: {IMAGE_PLACEHOLDER}")
+        self.image_token_id = int(ids)
+        preparer.image_token_id = self.image_token_id
+
+    def _encode(self, text: str) -> list[int]:
+        try:
+            ids = self.tokenizer.encode(text, add_special_tokens=False)
+        except TypeError:
+            ids = self.tokenizer.encode(text)
+        if ids and isinstance(ids[0], list):
+            ids = ids[0]
+        return list(cast("list[int]", ids))
+
+    def __call__(self, text: str, images: Sequence[Image.Image] | None = None, **_kw: Any) -> BatchFeature:
+        image_list = list(images or [])
+        token_ids = self._encode(text)
+        found = token_ids.count(self.image_token_id)
+        if found < len(image_list):
+            extra = " ".join([IMAGE_PLACEHOLDER] * (len(image_list) - found))
+            text = f"{text}\n{extra}" if found else text + extra
+            token_ids = self._encode(text)
+            found = token_ids.count(self.image_token_id)
+        if found != len(image_list):
+            raise ValueError(
+                f"Found {found} image tokens but got {len(image_list)} images"
+            )
+
+        patches: list[torch.Tensor] = []
+        grids: list[torch.Tensor] = []
+        types: list[torch.Tensor] = []
+        perms: list[torch.Tensor] = []
+        perm_counts: list[int] = []
+        running = 0
+        image_idx = 0
+        for pos, tok in enumerate(token_ids):
+            if tok != self.image_token_id:
+                running += 1
+                continue
+            prep = self.preparer.prepare(image_list[image_idx])
+            n_vit_h, n_vit_w, n_llm_h, n_llm_w = (
+                prep["n_vit_h"],
+                prep["n_vit_w"],
+                prep["n_llm_h"],
+                prep["n_llm_w"],
+            )
+            blk_types, perm = build_image_block(n_llm_h, n_llm_w, running)
+            patches.append(prep["patches"])
+            grids.append(
+                torch.tensor([n_vit_h, n_vit_w, n_llm_h, n_llm_w], dtype=torch.long)
+            )
+            types.append(blk_types)
+            perms.append(perm)
+            perm_counts.append(int(perm.numel()))
+            image_idx += 1
+            running += int(blk_types.numel())
+
+        data: dict[str, Any] = {"input_ids": [token_ids]}
+        if patches:
+            data["image_patches"] = torch.cat(patches)
+            data["image_grids"] = torch.stack(grids)
+            data["image_types"] = torch.cat(types)
+            data["image_perm"] = torch.cat(perms)
+            data["image_patch_counts"] = torch.tensor(
+                [int(pt.shape[0]) for pt in patches], dtype=torch.long
+            )
+            data["image_type_counts"] = torch.tensor(
+                [int(t.numel()) for t in types], dtype=torch.long
+            )
+            data["image_perm_counts"] = torch.tensor(perm_counts, dtype=torch.long)
+        return BatchFeature(data=data)
+
+
+class DSV4VisionDummyInputsBuilder(BaseDummyInputsBuilder[DSV4VisionProcessingInfo]):
+    def get_dummy_text(self, mm_counts: Mapping[str, int]) -> str:
+        return IMAGE_PLACEHOLDER * mm_counts.get("image", 0)
+
+    def get_dummy_mm_data(
+        self,
+        seq_len: int,
+        mm_counts: Mapping[str, int],
+        mm_options: Mapping[str, BaseDummyOptions],
+    ) -> MultiModalDataDict:
+        num = mm_counts.get("image", 0)
+        if not num:
+            return {}
+        width, height = self.info.max_image_size
+        overrides = cast(ImageDummyOptions | None, mm_options.get("image"))
+        if overrides is not None and overrides.width and overrides.height:
+            width, height = overrides.width, overrides.height
+        image = Image.new("RGB", (width, height), color=(127, 127, 127))
+        return {"image": [image] * num}
+
+
+class DSV4VisionMultiModalProcessor(BaseMultiModalProcessor[DSV4VisionProcessingInfo]):
+    def _call_hf_processor(
+        self,
+        prompt: str,
+        mm_data: Mapping[str, object],
+        mm_kwargs: Mapping[str, object],
+        tok_kwargs: Mapping[str, object],
+    ) -> BatchFeature:
+        return self.info.ctx.call_hf_processor(
+            self.info.get_hf_processor(**mm_kwargs),
+            dict(text=prompt, **mm_data),
+            dict(**mm_kwargs, **tok_kwargs),
+        )
+
+    def _hf_processor_applies_updates(
+        self,
+        prompt_text: str,
+        mm_items: MultiModalDataItems,
+        hf_processor_mm_kwargs: Mapping[str, object],
+        tokenization_kwargs: Mapping[str, object],
+    ) -> bool:
+        return False
+
+    def _get_mm_fields_config(
+        self,
+        hf_inputs: BatchFeature,
+        hf_processor_mm_kwargs: Mapping[str, object],
+    ) -> Mapping[str, MultiModalFieldConfig]:
+        if "image_patches" not in hf_inputs:
+            return {}
+        patch_counts = hf_inputs["image_patch_counts"]
+        type_counts = hf_inputs["image_type_counts"]
+        perm_counts = hf_inputs["image_perm_counts"]
+        ones = torch.ones(len(patch_counts), dtype=torch.long)
+        fields: dict[str, MultiModalFieldConfig] = {
+            "image_patches": MultiModalFieldConfig.flat_from_sizes(
+                "image", patch_counts
+            ),
+            "image_perm": MultiModalFieldConfig.flat_from_sizes(
+                "image", perm_counts
+            ),
+            "image_types": MultiModalFieldConfig.flat_from_sizes(
+                "image", type_counts
+            ),
+        }
+        for key in (
+            "image_grids",
+            "image_patch_counts",
+            "image_type_counts",
+            "image_perm_counts",
+        ):
+            fields[key] = MultiModalFieldConfig.flat_from_sizes("image", ones)
+        return fields
+
+    def _get_prompt_updates(
+        self,
+        mm_items: MultiModalDataItems,
+        hf_processor_mm_kwargs: Mapping[str, object],
+        out_mm_kwargs: MultiModalKwargsItems,
+    ) -> Sequence:
+        token_id = self.info.processor.image_token_id
+
+        def replacement(item_idx: int) -> PromptUpdateDetails[list[int]]:
+            count = int(out_mm_kwargs["image"][item_idx]["image_type_counts"].data)
+            return PromptUpdateDetails.select_token_id(
+                [token_id] * count, token_id
+            )
+
+        return [
+            PromptReplacement(
+                modality="image",
+                target=[token_id],
+                replacement=replacement,
+            )
+        ]

+ 121 - 0
docker-images/dsv4-flash-vision/plugin/dsv4_vision/vision.py

@@ -0,0 +1,121 @@
+from functools import lru_cache
+
+import torch
+import torch.nn.functional as F
+from torch import nn
+
+
+@lru_cache(8)
+def get_vision_cos_sin(n_h: int, n_w: int, dim: int, theta: float):
+    inv_freq = 1.0 / (theta ** (torch.arange(0, dim, 2, dtype=torch.float32) / dim))
+    hpos = torch.arange(n_h).unsqueeze(1).expand(n_h, n_w)
+    wpos = torch.arange(n_w).unsqueeze(0).expand(n_h, n_w)
+    freqs = torch.stack([hpos, wpos], dim=-1).reshape(-1, 2, 1).float() * inv_freq
+    freqs = freqs.flatten(1)
+    return freqs.cos().unsqueeze(1), freqs.sin().unsqueeze(1)
+
+
+def apply_rotary(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:
+    dtype = x.dtype
+    x1, x2 = x.float().chunk(2, dim=-1)
+    return torch.cat([x1 * cos - x2 * sin, x2 * cos + x1 * sin], dim=-1).to(dtype)
+
+
+class RMSNorm(nn.Module):
+    def __init__(self, dim: int, eps: float = 1e-6):
+        super().__init__()
+        self.eps = eps
+        self.weight = nn.Parameter(torch.ones(dim, dtype=torch.float32))
+
+    def forward(self, x: torch.Tensor) -> torch.Tensor:
+        dtype = x.dtype
+        x = x.float()
+        x = x * torch.rsqrt(x.square().mean(-1, keepdim=True) + self.eps)
+        return (self.weight * x).to(dtype)
+
+
+class PatchEmbed(nn.Module):
+    def __init__(self, args):
+        super().__init__()
+        self.proj = nn.Linear(3 * args.vision_patch_size ** 2, args.vision_dim)
+
+    def forward(self, x: torch.Tensor) -> torch.Tensor:
+        return self.proj(x.flatten(1))
+
+
+class Attention(nn.Module):
+    def __init__(self, args):
+        super().__init__()
+        self.n_heads = args.vision_n_heads
+        self.head_dim = args.vision_dim // args.vision_n_heads
+        self.wqkv = nn.Linear(args.vision_dim, 3 * args.vision_dim)
+        self.wo = nn.Linear(args.vision_dim, args.vision_dim)
+
+    def forward(self, x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:
+        n = x.size(0)
+        q, k, v = (t.view(n, self.n_heads, self.head_dim) for t in self.wqkv(x).chunk(3, dim=-1))
+        q = apply_rotary(q, cos, sin)
+        k = apply_rotary(k, cos, sin)
+        o = F.scaled_dot_product_attention(q.transpose(0, 1), k.transpose(0, 1), v.transpose(0, 1))
+        return self.wo(o.transpose(0, 1).reshape(n, -1))
+
+
+class MLP(nn.Module):
+    def __init__(self, args):
+        super().__init__()
+        self.w1 = nn.Linear(args.vision_dim, 2 * args.vision_inter_dim, bias=False)
+        self.w2 = nn.Linear(args.vision_inter_dim, args.vision_dim, bias=False)
+
+    def forward(self, x: torch.Tensor) -> torch.Tensor:
+        gate, up = self.w1(x).chunk(2, dim=-1)
+        return self.w2(F.silu(gate) * up)
+
+
+class Block(nn.Module):
+    def __init__(self, args):
+        super().__init__()
+        self.norm1 = RMSNorm(args.vision_dim)
+        self.attn = Attention(args)
+        self.norm2 = RMSNorm(args.vision_dim)
+        self.mlp = MLP(args)
+
+    def forward(self, x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:
+        x = x + self.attn(self.norm1(x), cos, sin)
+        return x + self.mlp(self.norm2(x))
+
+
+class ViT(nn.Module):
+    """DeepSeek ViT: full bidirectional attention over one image with 2D RoPE."""
+    def __init__(self, args):
+        super().__init__()
+        self.rope_dim = args.vision_dim // args.vision_n_heads // 2
+        self.rope_theta = args.vision_rope_theta
+        self.patch_embed = PatchEmbed(args)
+        self.blocks = nn.ModuleList([Block(args) for _ in range(args.vision_n_layers)])
+        self.norm = RMSNorm(args.vision_dim)
+
+    def forward(self, patches: torch.Tensor, n_h: int, n_w: int) -> torch.Tensor:
+        x = self.patch_embed(patches)
+        device = x.device
+        cos, sin = get_vision_cos_sin(n_h, n_w, self.rope_dim, self.rope_theta)
+        # lru_cache builds these on CPU; haul them to the model device per image.
+        cos, sin = cos.to(device, non_blocking=True), sin.to(device, non_blocking=True)
+        for block in self.blocks:
+            x = block(x, cos, sin)
+        return self.norm(x)
+
+
+class Aligner(nn.Module):
+    def __init__(self, args):
+        super().__init__()
+        self.downsample_ratio = args.vision_downsample_ratio
+        in_dim = args.vision_dim * self.downsample_ratio ** 2
+        self.w1 = nn.Linear(in_dim, args.dim)
+        self.w2 = nn.Linear(args.dim, args.dim)
+
+    def forward(self, x: torch.Tensor, n_h: int, n_w: int) -> torch.Tensor:
+        r = self.downsample_ratio
+        x = x.view(n_h, n_w, -1).permute(2, 0, 1)
+        x = F.pad(x, (0, -n_w % r, 0, -n_h % r))
+        x = F.unfold(x.unsqueeze(0), r, stride=r).squeeze(0).transpose(0, 1)
+        return self.w2(F.gelu(self.w1(x)))

+ 16 - 0
docker-images/dsv4-flash-vision/plugin/pyproject.toml

@@ -0,0 +1,16 @@
+[build-system]
+requires = ["setuptools>=68"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "dsv4-vision"
+version = "0.1.0"
+description = "vLLM plugin: official DeepSeek-V4-Flash-Vision-Exp ViT and aligner"
+requires-python = ">=3.10"
+
+[project.entry-points."vllm.general_plugins"]
+dsv4_vision = "dsv4_vision:register"
+
+[tool.setuptools.packages.find]
+where = ["."]
+include = ["dsv4_vision*"]

+ 6 - 0
docker-images/dsv4-flash-vision/versions.env

@@ -0,0 +1,6 @@
+# Authoritative build inputs for the DeepSeek-V4-Flash-Vision-Exp SM121 image:
+# the sfxnz day-0 vision plugin (vLLM general_plugins package + dspark loader
+# patch) on top of a.arm64 DS-V4 serving base. BASE_IMAGE is overridable to
+# compare the two DS-V4 forks ((eugr b12x vs aidendle94 3.76.1); the eugr
+# base is the publishing day-0 recipe's pick.
+BASE_IMAGE="eugr/spark-vllm-b12x:latest@sha256:7dc02f162929943ba2e14514066ed2a04bb7e9ed3592d4eb460ebcbb1f8376bd"