Skip to content

fix(kimi-k3): move the media wrapper and thinking-effort default to the prompt-encoding layer - #1995

Merged
key4ng merged 3 commits into
mainfrom
fix/kimi-k3-media-placeholder
Jul 29, 2026
Merged

fix(kimi-k3): move the media wrapper and thinking-effort default to the prompt-encoding layer#1995
key4ng merged 3 commits into
mainfrom
fix/kimi-k3-media-placeholder

Conversation

@key4ng

@key4ng key4ng commented Jul 29, 2026

Copy link
Copy Markdown
Member

Motivation

Two divergences from the K3 reference implementation, both in prompt encoding rather than in the vision processor.

1. The media wrapper carried no dimensions. K3's image prompt is one wrapper per image:

<|media_begin|>image {width}x{height}<|media_content|><|media_pad|>…<|media_end|>

where the dimensions are the pre-resize decoded size. SMG emitted only the bare <|media_pad|> run, because K3 was routed to the K2.5 registry spec — and K2.5's jinja chat template emits its own, dimensionless wrapper. Every K3 image prompt was 9 tokens short of the reference and told the model nothing about the image's original size.

2. Every request was missing the thinking-effort directive. The K3 checkpoint splits rendering across two layers. encoding_k3.build_chat_segments injects no directive; the entry point above it, tokenization_kimi.apply_chat_template, runs kwargs.setdefault("thinking_effort", "max") first. vLLM calls the latter, so every served K3 request carries the directive. SMG's renderer is a faithful port of the lower layer and was wired directly into TiktokenTokenizer::apply_chat_template, so it omitted the directive — a 67-token divergence on every request, image or not.

Approach

Wrapper. It cannot be built while rendering: the chat template runs before any media is fetched, so the dimensions do not exist yet. A new KimiK3VisionSpec builds it in prompt_replacements, from the sizes the preprocessor reports — the same place vLLM builds it (kimi_k3.py::_get_prompt_updates). The renderer emits a bare <|media_pad|> anchor per image and prompt expansion replaces it with the full block. with_feature_span keeps the encoder-feature positions on the pad run alone; the surrounding wrapper is text.

The K2.5 matcher is narrowed to K2.5 and K3 is registered ahead of it. The two families share the MoonViT transport layout but not a prompt shape — implicit sharing is exactly what produced the pixel-pipeline divergence fixed in #1984.

Thinking effort. apply_kimi_k3_xtml stays a faithful build_chat_segments port; a new apply_kimi_k3_xtml_with_effort_default models the served layer and is what TiktokenTokenizer::apply_chat_template now calls. The default is threaded through the fallback branch rather than pre-seeding template_kwargs, which preserves the precedence explicit thinking_effort > OpenAI reasoning_effort > default — pre-seeding would have permanently starved the reasoning_effort bridge. All seven golden fixtures stay byte-valid.

Verification against the checkpoint

Run inside the K3 container on a B300 node, against moonshotai/Kimi-K3:

  • Wrapper composition. For 1024x768, 4000x3000, 224x448, 3x4 and 512x512, [<|media_begin|>] + encode("image {w}x{h}") + [<|media_content|>, <|media_pad|>, <|media_end|>] is byte-identical to the reference's one-shot encoding of make_image_prompt(w, h). This confirms the one real assumption in the spec: the media tokens are hard segment boundaries for the tiktoken encoder, so the dimension text can be encoded on its own. Wrapper cost beyond the bare pad: 9 tokens.
  • Served entry point. tokenization_kimi.apply_chat_template output is exactly build_chat_segments output prefixed by the thinking_effort=max directive, 67 tokens.

Together these close the token accounting on the reference request: 1092 (SMG before) + 9 + 67 = 1168, matching what vLLM sends.

Behaviour change

Text-only K3 requests get 67 tokens longer, and image requests get 9 more per image. Both are the point — that is what the reference serves.

Tests

  • registry::kimi_k3: matcher by model_id and model_type; the exact wrapper layout and feature span; per-image dimensions across a two-image batch; a checkpoint missing the structural tokens fails loudly rather than silently emitting a bare pad run.
  • registry::kimi_k25: K3 no longer resolves to the K2.5 spec, by model_id and by model_type.
  • encoders::kimi_k3_xtml: the served path defaults to max; the default yields to both an explicit thinking_effort and an OpenAI reasoning_effort; the default is suppressed when thinking is off.
  • tests/kimi_k3_renderer.rs: the end-to-end no-chat-template case now asserts the directive is present and is the only addition.

Full workspace suite: 99 suites, 4151 passed, 0 failed.

Note

cargo clippy --all-features could not be run locally — it pulls the optional opencv dependency and the dev machine has neither pkg-config nor opencv installed. cargo clippy --workspace --all-targets -- -D warnings is clean, and no changed code sits behind a cfg(feature) gate. CI covers the all-features build.

Summary by CodeRabbit

  • New Features
    • Added multimodal support for Kimi-K3 vision models, including media wrapper placeholders, per-image dimension text, and correct image feature-span mapping.
    • Kimi-K3 chat rendering can default thinking-effort to max when no effort is specified.
  • Bug Fixes
    • Kimi-K3 model IDs are no longer matched to Kimi-K2.5 specs.
    • Improved test tokenizer text and batch encoding so each input is encoded correctly.
  • Error Handling
    • Added a dedicated error when a model spec fails to encode provided text.

key4ng added 2 commits July 28, 2026 23:05
K3's image prompt is one wrapper per image --
`<|media_begin|>image {w}x{h}<|media_content|><|media_pad|><|media_end|>`
-- where the dimensions are the pre-resize decoded size. SMG emitted only
the bare `<|media_pad|>` run because K3 was routed to the K2.5 registry
spec, whose chat template emits its own dimensionless wrapper. Every K3
image prompt was therefore 9 tokens short of the reference and carried no
dimensions at all.

The wrapper cannot be built while rendering: the chat template runs
before any media is fetched, so the sizes do not exist yet. Give K3 its
own spec that builds the block in `prompt_replacements` from the sizes
the preprocessor reports -- the same place vLLM builds it
(`kimi_k3.py::_get_prompt_updates`). `with_feature_span` keeps the
encoder-feature positions on the pad run alone; the wrapper is text.

Narrow the K2.5 matcher to K2.5 and register K3 ahead of it. The two
families share the MoonViT transport layout but not a prompt shape, and
implicit sharing is what produced the pixel-pipeline divergence fixed in
PR #1984.

Verified against the checkpoint: for 1024x768, 4000x3000, 224x448, 3x4
and 512x512 the token-by-token build is byte-identical to the reference's
own encoding of `make_image_prompt`, since the media tokens are hard
segment boundaries for the tiktoken encoder.

Signed-off-by: key4ng <rukeyang@gmail.com>
The K3 checkpoint splits prompt rendering across two layers:
`encoding_k3.build_chat_segments` injects no thinking-effort directive,
while the entry point above it, `tokenization_kimi.apply_chat_template`,
runs `kwargs.setdefault("thinking_effort", "max")` first. vLLM calls the
latter, so every served K3 request carries the directive. SMG called the
equivalent of the former and omitted it -- a 67-token divergence on every
request, image or not.

Keep `apply_kimi_k3_xtml` a faithful `build_chat_segments` port and add
`apply_kimi_k3_xtml_with_effort_default` for the served layer, which is
what `TiktokenTokenizer::apply_chat_template` now calls. Threading the
default through the fallback branch rather than pre-seeding
`template_kwargs` preserves the precedence explicit `thinking_effort` >
OpenAI `reasoning_effort` > default, and leaves all seven golden fixtures
byte-valid.

Verified against the checkpoint: the served entry point's output is the
bare render prefixed by exactly this directive, 67 tokens.

Signed-off-by: key4ng <rukeyang@gmail.com>
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@github-actions github-actions Bot added tokenizer Tokenizer related changes tests Test changes multimodal Multimodal crate changes labels Jul 29, 2026
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a dedicated Kimi K3 multimodal processor with image wrapper generation and registry routing. Kimi K3 XTML rendering now applies a served thinking-effort=max default while preserving explicit effort settings and base renderer behavior.

Changes

Kimi K3 multimodal registry and prompt processing

Layer / File(s) Summary
Registry routing and model identification
crates/multimodal/src/registry/mod.rs, crates/multimodal/src/registry/kimi_k25.rs, crates/multimodal/src/registry/kimi_k3.rs
Registers Kimi K3 before Kimi K2.5, narrows K2.5 matching, and adds Kimi K3 model matching and configuration.
Image wrapper prompt processing
crates/multimodal/src/registry/kimi_k3.rs, crates/multimodal/src/registry/traits.rs, crates/multimodal/src/registry/mod.rs
Generates per-image media wrappers with dimension tokens and pad-token feature spans, defines transport layouts, adds text encoding errors, and updates tokenizer test support.

Kimi K3 served rendering

Layer / File(s) Summary
Served XTML effort defaults
crates/tokenizer/src/encoders/kimi_k3_xtml.rs
Adds a served renderer entry point that defaults enabled thinking to max, while preserving explicit efforts and base-renderer behavior.
Tokenizer integration and end-to-end validation
crates/tokenizer/src/tiktoken.rs, crates/tokenizer/tests/kimi_k3_renderer.rs
Routes Kimi K3 chat-template rendering through the served wrapper and updates expected output assertions.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Request
  participant TiktokenTokenizer
  participant KimiK3XTML
  Request->>TiktokenTokenizer: apply KimiK3Xtml chat template
  TiktokenTokenizer->>KimiK3XTML: call served renderer
  KimiK3XTML->>KimiK3XTML: resolve explicit effort or default to max
  KimiK3XTML-->>TiktokenTokenizer: return rendered XTML
Loading

Suggested reviewers: catherinesue

Poem

A rabbit hops through Kimi’s gates,
Wrapping images in media states.
Pads align where features flow,
While thinking defaults softly glow.
“Max!” cries the hare, and tests agree.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main Kimi-K3 prompt-encoding change and mentions both media wrappers and thinking-effort defaults.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/kimi-k3-media-placeholder

Comment @coderabbitai help to get the list of available commands.

@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

👋 The PR description doesn't fully follow
PULL_REQUEST_TEMPLATE.md:

  • Missing header: ## Description
  • Missing header: ### Problem
  • Missing header: ### Solution
  • Missing header: ## Changes
  • Missing header: ## Test Plan

Please update the PR description so reviewers have the context they need.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Clean PR. The two-layer split (bare renderer vs. served entry point with effort default) is well-motivated, the precedence chain is correct, the K3 vision spec builds the reference wrapper faithfully, and test coverage hits all the key scenarios — matcher routing, wrapper layout, per-image dimensions, missing tokens, and effort precedence. No issues found.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/multimodal/src/registry/kimi_k25.rs`:
- Around line 130-148: The test kimi_k3_does_not_use_the_k25_spec must directly
verify KimiK25VisionSpec::matches rejects each K3 metadata case before checking
registry routing. Construct the metadata for each model_id, assert the K2.5 spec
does not match it, then retain the existing registry lookup assertion that
routes to kimi_k3.

In `@crates/multimodal/src/registry/kimi_k3.rs`:
- Around line 112-115: Update the comment near the upstream media-count
validation to explicitly prefix the cardinality assumption with “INVARIANT:”.
Keep the existing explanation unchanged and do not alter the surrounding logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: f2b3cb48-0a98-4c17-80e3-b729576b3544

📥 Commits

Reviewing files that changed from the base of the PR and between 3b72ccd and d14325c.

📒 Files selected for processing (7)
  • crates/multimodal/src/registry/kimi_k25.rs
  • crates/multimodal/src/registry/kimi_k3.rs
  • crates/multimodal/src/registry/mod.rs
  • crates/multimodal/src/registry/traits.rs
  • crates/tokenizer/src/encoders/kimi_k3_xtml.rs
  • crates/tokenizer/src/tiktoken.rs
  • crates/tokenizer/tests/kimi_k3_renderer.rs

Comment on lines +130 to +148
fn kimi_k3_does_not_use_the_k25_spec() {
// K3's prompt carries per-image dimensions that this spec cannot emit,
// so it must route to `kimi_k3` by model_id and by model_type alike.
let tokenizer = TestTokenizer::new(&[("<|media_pad|>", 163605)]);
// Match by model_id containing kimi + k3.
let config = json!({
"model_type": "kimi_k3",
"media_placeholder_token_id": 163605
});
let metadata = ModelMetadata {
model_id: "moonshotai/Kimi-K3",
tokenizer: &tokenizer,
config: &config,
};
let registry = ModelRegistry::new();
let spec = registry
.lookup(&metadata)
.expect("kimi_k3 -> kimi_k25 spec");
assert_eq!(spec.name(), "kimi_k25");

// Also match by model_type alone (id without a k3 hint).
let metadata_by_type = ModelMetadata {
model_id: "internal/checkpoint-final",
tokenizer: &tokenizer,
config: &config,
};
assert!(registry.lookup(&metadata_by_type).is_some());
for model_id in ["moonshotai/Kimi-K3", "internal/checkpoint-final"] {
let metadata = ModelMetadata {
model_id,
tokenizer: &tokenizer,
config: &config,
};
let spec = registry.lookup(&metadata).expect("kimi_k3 spec");
assert_eq!(spec.name(), "kimi_k3", "model_id {model_id}");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Directly test that K2.5 rejects K3 metadata.

This registry lookup still passes if KimiK25VisionSpec::matches() regresses, since K3 is registered first. Assert the K2.5 spec rejects each case before checking registry routing.

Proposed test strengthening
+    use super::KimiK25VisionSpec;
     use crate::{
-        registry::{test_helpers::*, ModelMetadata, ModelRegistry},
+        registry::{test_helpers::*, ModelMetadata, ModelProcessorSpec, ModelRegistry},
         types::ImageSize,
     };
...
             let metadata = ModelMetadata {
                 model_id,
                 tokenizer: &tokenizer,
                 config: &config,
             };
+            let k25 = KimiK25VisionSpec;
+            assert!(
+                !k25.matches(&metadata),
+                "K3 metadata must not match the K2.5 spec: {model_id}"
+            );
             let spec = registry.lookup(&metadata).expect("kimi_k3 spec");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn kimi_k3_does_not_use_the_k25_spec() {
// K3's prompt carries per-image dimensions that this spec cannot emit,
// so it must route to `kimi_k3` by model_id and by model_type alike.
let tokenizer = TestTokenizer::new(&[("<|media_pad|>", 163605)]);
// Match by model_id containing kimi + k3.
let config = json!({
"model_type": "kimi_k3",
"media_placeholder_token_id": 163605
});
let metadata = ModelMetadata {
model_id: "moonshotai/Kimi-K3",
tokenizer: &tokenizer,
config: &config,
};
let registry = ModelRegistry::new();
let spec = registry
.lookup(&metadata)
.expect("kimi_k3 -> kimi_k25 spec");
assert_eq!(spec.name(), "kimi_k25");
// Also match by model_type alone (id without a k3 hint).
let metadata_by_type = ModelMetadata {
model_id: "internal/checkpoint-final",
tokenizer: &tokenizer,
config: &config,
};
assert!(registry.lookup(&metadata_by_type).is_some());
for model_id in ["moonshotai/Kimi-K3", "internal/checkpoint-final"] {
let metadata = ModelMetadata {
model_id,
tokenizer: &tokenizer,
config: &config,
};
let spec = registry.lookup(&metadata).expect("kimi_k3 spec");
assert_eq!(spec.name(), "kimi_k3", "model_id {model_id}");
}
fn kimi_k3_does_not_use_the_k25_spec() {
// K3's prompt carries per-image dimensions that this spec cannot emit,
// so it must route to `kimi_k3` by model_id and by model_type alike.
let tokenizer = TestTokenizer::new(&[("<|media_pad|>", 163605)]);
let config = json!({
"model_type": "kimi_k3",
"media_placeholder_token_id": 163605
});
let registry = ModelRegistry::new();
for model_id in ["moonshotai/Kimi-K3", "internal/checkpoint-final"] {
let metadata = ModelMetadata {
model_id,
tokenizer: &tokenizer,
config: &config,
};
let k25 = KimiK25VisionSpec;
assert!(
!k25.matches(&metadata),
"K3 metadata must not match the K2.5 spec: {model_id}"
);
let spec = registry.lookup(&metadata).expect("kimi_k3 spec");
assert_eq!(spec.name(), "kimi_k3", "model_id {model_id}");
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/multimodal/src/registry/kimi_k25.rs` around lines 130 - 148, The test
kimi_k3_does_not_use_the_k25_spec must directly verify
KimiK25VisionSpec::matches rejects each K3 metadata case before checking
registry routing. Construct the metadata for each model_id, assert the K2.5 spec
does not match it, then retain the existing registry lookup assertion that
routes to kimi_k3.

Comment on lines +112 to +115
// MoonViT reports `item_sizes` as the decoded `(width, height)` before any
// resize, which is exactly the pair the reference prints. The caller
// checks both vectors against the media count, so a short zip here would
// already have been rejected upstream.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Mark the upstream cardinality assumption as an invariant.

Prefix the upstream-validation assumption with INVARIANT: (or remove the assertion from this comment) to follow the repository’s safe-code invariant convention.

Based on learnings, use the marker INVARIANT: to document assumptions in safe code.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/multimodal/src/registry/kimi_k3.rs` around lines 112 - 115, Update the
comment near the upstream media-count validation to explicitly prefix the
cardinality assumption with “INVARIANT:”. Keep the existing explanation
unchanged and do not alter the surrounding logic.

Source: Learnings

Trim the doc and inline comments on the new K3 registry spec and the split
XTML entry points. The reference details they restate are already in the
commit history; keep the part a reader needs at the call site and drop the
retelling.

Signed-off-by: key4ng <rukeyang@gmail.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 879016805a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 151 to +152
.filter(|e| matches!(*e, "low" | "high" | "max"))
.or(default_effort)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve unsupported reasoning-effort suppression

When the served TiktokenTokenizer path receives the valid OpenAI value reasoning_effort="medium", this filter removes it and .or(default_effort) immediately substitutes max, so K3 gets a max-effort directive despite the documented bridge semantics that unsupported K3 levels emit no directive. The existing medium test only exercises the lower-level renderer without a default and therefore misses this regression; distinguish an absent reasoning_effort key from a present but unmappable value before applying the default.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/multimodal/src/registry/kimi_k25.rs (1)

31-38: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Restrict the matcher to Kimi-K2.5 models.

id.contains("k2") also accepts other Kimi K2-prefixed model IDs, contradicting the K2.5-only routing contract and potentially sending them through the wrong vision processor. Match canonical K2.5 identifiers explicitly and add a negative test for Kimi-K2.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/multimodal/src/registry/kimi_k25.rs` around lines 31 - 38, Update the
model matcher near metadata.model_id and config_model_type to accept only
canonical Kimi-K2.5 identifiers, removing the broad id.contains("k2") check.
Preserve the existing kimi_k25 config-model-type match, and add a negative test
confirming that “Kimi-K2” is rejected.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@crates/multimodal/src/registry/kimi_k25.rs`:
- Around line 31-38: Update the model matcher near metadata.model_id and
config_model_type to accept only canonical Kimi-K2.5 identifiers, removing the
broad id.contains("k2") check. Preserve the existing kimi_k25 config-model-type
match, and add a negative test confirming that “Kimi-K2” is rejected.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: b6eeea3b-4d7f-4fbc-9c88-7ddc90eedc4c

📥 Commits

Reviewing files that changed from the base of the PR and between d14325c and 8790168.

📒 Files selected for processing (6)
  • crates/multimodal/src/registry/kimi_k25.rs
  • crates/multimodal/src/registry/kimi_k3.rs
  • crates/multimodal/src/registry/mod.rs
  • crates/tokenizer/src/encoders/kimi_k3_xtml.rs
  • crates/tokenizer/src/tiktoken.rs
  • crates/tokenizer/tests/kimi_k3_renderer.rs

@key4ng

key4ng commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

Node validation: prompt shape and token parity

Ran this branch on the B300 node against the real Kimi-K3 checkpoint, using vLLM's own server as the reference rather than a hand-written expectation.

Setup. One vLLM gRPC engine (:8081, /raid/models/moonshotai/Kimi-K3, TP8). Two gateways built from source and run natively against that same engine, so the only variable is the SMG binary:

Gateway Build
new (:8210) this branch, 87901680
old (:8220) pre-fix baseline, #1984 tip

Reference. Before handing the GPUs to the gRPC engine, I captured the same requests against vLLM's HTTP server (:8200) on the same checkpoint — that path does its own chat templating and its own preprocessing, so it is the behaviour we are trying to match. Asking for prompt_logprobs makes the engine echo back the decoded prompt, which gives a direct read of the wrapper instead of inferring it:

<|media_begin|>image 1024x768<|media_content|><|media_pad|>…<|media_pad|><|media_end|>

for a 1024×768 image — 1036 pads, and 3 marker tokens + 6 tokens of image 1024x768 = the 9-token wrapper.

Token parity

usage.prompt_tokens, same requests to all three:

Case vLLM (ref) new old
1 image, 1024×768 1138 1138 1062
1 image, 4000×3000 15547 15547 15470
2 images, 1024×768 then 4000×3000 16594 16594 16508
2 images, both 1024×768 2183 2183 2098
text only 89 89 22
text, thinking_effort=low 90 90 90
text, thinking_effort=high 90 90 90
1 image, thinking_effort=low 1139 1139 1130

new matches the reference on 8/8. old matches on 2/8 — exactly the two cases that pass an explicit effort and contain no image, i.e. the two things the old build already got right.

Every baseline gap decomposes exactly, with no remainder:

  • text-only, 89 − 22 = 67 → the missing thinking-effort directive.
  • 1 image with explicit effort, 1139 − 1130 = 9 → the missing wrapper, directive already present.
  • 1 image, default effort, 1138 − 1062 = 76 = 67 + 9.
  • 1 image 4000×3000, 15547 − 15470 = 77 = 67 + 10 — the wrapper is 10 here because image 4000x3000 is 7 tokens, not 6.
  • 2 images, 16594 − 16508 = 86 = 67 + 9 + 10, and 2183 − 2098 = 85 = 67 + 9 + 9.

The per-image term tracks the actual dimensions rather than being a constant, which is the part that matters.

Gateway debug logging agrees from the inside — for the 4000×3000 case the new build reports item_sizes=[(4000, 3000)], original_len=94 expanded_len=15547 placeholder_count=1 with 15444 pads, so the wrapper's non-pad cost is 15547 − 93 − 15444 = 10.

Width×height ordering, and pre-resize dimensions

Token counts cannot catch a W/H swap: swapping the operands of x leaves the token multiset unchanged, so image 4000x3000 and image 3000x4000 both cost 7. I verified that against the checkpoint tokenizer rather than assuming it, then tested ordering a different way — asking the model to repeat the dimensions line from its own context. The numbers only exist there as text, so a correct answer is a direct read of the wrapper.

Strip images make this unambiguous: the same two numbers in both orders, and few enough pads that retrieval is reliable.

Image new old
4000×64 image 4000x64 image 1024x1024 (guess)
64×4000 image 64x4000 image 256x2048 (guess)
1536×384 image 1536x384 image 1000x1000 (guess)

The mirror pair both read back correctly, so this is not the model guessing an orientation. old has no dimensions in context and guesses, as expected. Prompt tokens differ by a constant 9 (497 vs 488, 838 vs 829).

This also settles pre- vs post-resize: a 4000×64 strip is heavily resized for patching (~380 pads), yet the wrapper still states the original 4000×64 — item_sizes is captured before the resize.

One caveat

SMG does not forward prompt_logprobs to the engine, so the byte-exact decoded prompt is only available on the vLLM path. On the SMG path the evidence is token parity, the gateway's own expansion counts, and the read-back above. I did try to falsify the wrapper contents rather than only confirm them: for the 4000×3000 image the model answered image 3465x2599, which would be alarming, but that string also costs 7 tokens so parity could not rule it out. It is a long-context retrieval failure across 15444 identical pad tokens — the same probe reads correctly whenever the pad run is short, and the mirror pair pins the ordering independently.

@key4ng
key4ng merged commit 0c2773b into main Jul 29, 2026
83 of 85 checks passed
@key4ng
key4ng deleted the fix/kimi-k3-media-placeholder branch July 29, 2026 09:03
@key4ng

key4ng commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

Vendor-verifier accuracy check

Ran the two image benchmarks from MoonshotAI/Kimi-Vendor-Verifier against this branch, to confirm the new media wrapper doesn't cost anything end-to-end.

Benchmark Build Thinking effort Accuracy Samples Wall time
OCRBench this PR max (explicit) 0.896 ± 0.010 1000 9m43s
OCRBench this PR unset (PR default) 0.895 ± 0.010 1000 9m38s
OCRBench pre-PR (merged #1984) unset 0.890 ± 0.010 1000 8m39s
MMMU Pro this PR max (explicit) 0.813 ± 0.009 1730 2h23m44s

The three OCRBench numbers sit inside one standard error of each other, so this reads as no regression rather than an improvement. The middle row is the useful one for this PR specifically: it sends no thinking_effort at all, so the score comes from the max default that this PR installs in the K3 XTML render path.

Setup:

  • native smg built from this branch (--reasoning-parser kimi_k3 --tool-call-parser kimi_k3), in front of a TP8 gRPC vLLM engine serving moonshotai/Kimi-K3 on a B300 node
  • unmodified KVV harness via eval.py <bench> --thinking --think-mode opensource --temperature 1.0 --top-p 0.95 --max-connections 50 --epochs 1 --stream, --max-tokens 16384 for OCRBench and 98304 for MMMU Pro
  • token usage — OCRBench 1,349,304 (I 1,100,238 / O 249,066); MMMU Pro 10,209,534 (I 1,655,538 / O 8,553,996)

Two things to note about the numbers rather than the code:

  • The harness pins MMMU Pro to the standard (10 options) subset (it reports the dataset as MMMU_Pro_10c), so that row is the standard 10-option split, not the vision-only split.
  • One of the 1000 OCRBench samples hit the 16384-token cap mid-generation and was scored as-is; the harness runs with fail_on_error: False. MMMU Pro finished with no errors.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

multimodal Multimodal crate changes tests Test changes tokenizer Tokenizer related changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant