pipenetwork commited on
Commit
affbc49
·
verified ·
1 Parent(s): fc545aa

Add files using upload-large-folder tool

Browse files
Files changed (50) hide show
  1. LICENSE +52 -0
  2. README.md +160 -0
  3. config.json +0 -0
  4. configuration_kimi_k3.py +285 -0
  5. encoding_k3.py +647 -0
  6. generation_config.json +4 -0
  7. kimi_k3.py +954 -0
  8. kimi_k3_processor.py +187 -0
  9. kimi_k3_vision.py +404 -0
  10. kimi_k3_vision_processing.py +179 -0
  11. kimi_k3_vl/__init__.py +14 -0
  12. kimi_k3_vl/config.py +85 -0
  13. kimi_k3_vl/kimi_k3_vl.py +191 -0
  14. kimi_k3_vl/language.py +46 -0
  15. kimi_k3_vl/vision.py +52 -0
  16. media_utils.py +376 -0
  17. model-00003-of-00075.safetensors +3 -0
  18. model-00006-of-00075.safetensors +3 -0
  19. model-00009-of-00075.safetensors +3 -0
  20. model-00011-of-00075.safetensors +3 -0
  21. model-00012-of-00075.safetensors +3 -0
  22. model-00014-of-00075.safetensors +3 -0
  23. model-00017-of-00075.safetensors +3 -0
  24. model-00018-of-00075.safetensors +3 -0
  25. model-00020-of-00075.safetensors +3 -0
  26. model-00025-of-00075.safetensors +3 -0
  27. model-00029-of-00075.safetensors +3 -0
  28. model-00031-of-00075.safetensors +3 -0
  29. model-00032-of-00075.safetensors +3 -0
  30. model-00034-of-00075.safetensors +3 -0
  31. model-00037-of-00075.safetensors +3 -0
  32. model-00038-of-00075.safetensors +3 -0
  33. model-00041-of-00075.safetensors +3 -0
  34. model-00042-of-00075.safetensors +3 -0
  35. model-00044-of-00075.safetensors +3 -0
  36. model-00047-of-00075.safetensors +3 -0
  37. model-00048-of-00075.safetensors +3 -0
  38. model-00053-of-00075.safetensors +3 -0
  39. model-00055-of-00075.safetensors +3 -0
  40. model-00056-of-00075.safetensors +3 -0
  41. model-00061-of-00075.safetensors +3 -0
  42. model-00064-of-00075.safetensors +3 -0
  43. model-00068-of-00075.safetensors +3 -0
  44. model-00070-of-00075.safetensors +3 -0
  45. model-00073-of-00075.safetensors +3 -0
  46. model-00075-of-00075.safetensors +3 -0
  47. model.safetensors.index.json +0 -0
  48. preprocessor_config.json +38 -0
  49. tokenization_kimi.py +408 -0
  50. tokenizer_config.json +157 -0
LICENSE ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Kimi K3 License
2
+
3
+ Copyright (c) 2026 Moonshot AI
4
+
5
+ Permission is hereby granted, free of charge, to any person (the "Licensee")
6
+ obtaining a copy of this software — including the model weights, parameters,
7
+ configuration files, inference and training code, and associated documentation
8
+ (collectively, the "Software") — to deal in the Software without restriction.
9
+ This includes, without limitation, the rights to use, copy, modify, merge,
10
+ publish, distribute, sublicense, and/or sell copies of the Software; to run,
11
+ deploy, fine-tune, or otherwise modify the Software and create derivative works
12
+ from it; and to permit persons to whom the Software is furnished to do so, in
13
+ each case subject to the following conditions:
14
+
15
+ 1. The above copyright notice and this permission notice shall be included in
16
+ all copies or substantial portions of the Software. Licensee's use of the
17
+ Software must comply with applicable laws and regulations.
18
+
19
+ 2. "Model as a Service" means giving a third party access to language model
20
+ inference or fine-tuning (e.g., via API) in a manner that allows such third
21
+ party to exercise meaningful control over the inputs, parameters, or training
22
+ data. This does not include (a) end-user products with model capabilities solely
23
+ embedded within specific features or harnesses, or (b) mere relaying of requests
24
+ to models hosted by others.
25
+
26
+ If the Licensee or any of its affiliates operates a Model as a Service business,
27
+ and the aggregate revenue of the Licensee and its affiliates exceeds 20 million
28
+ US dollars (or the equivalent in other currencies) in total over any consecutive
29
+ 12 months, the Licensee must enter into a separate agreement with Moonshot AI
30
+ before using the Software or its derivative works for any commercial purpose.
31
+
32
+ 3. If the Software (or any derivative works thereof) is used for any of the
33
+ Licensee's commercial products or services that have more than 100 million
34
+ monthly active users, or more than 20 million US dollars (or equivalent in other
35
+ currencies) in monthly revenue, "Kimi K3" must be prominently displayed on the
36
+ user interface of such product or service.
37
+
38
+ 4. The requirements set forth in Sections 2 and 3 do not apply to: (a) internal
39
+ use of the Software, defined as any use that does not make the Software, its
40
+ outputs, or its underlying capabilities available to third parties; or (b) any
41
+ use of the Software accessed through Moonshot AI's official products or
42
+ certified inference partners.
43
+
44
+ 5. THE SOFTWARE AND ANY OUTPUT AND RESULTS THEREFROM ARE PROVIDED ON AN “AS IS”
45
+ BASIS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
46
+ LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE
47
+ AND NONINFRINGEMENT. IN NO EVENT SHALL MOONSHOT AI OR ITS AFFILIATES OR
48
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
49
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
50
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
51
+
52
+ For any questions regarding this license, please contact <license@moonshot.ai>.
README.md ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: other
3
+ license_name: kimi-k3
4
+ base_model: moonshotai/Kimi-K3
5
+ base_model_relation: quantized
6
+ library_name: mlx
7
+ pipeline_tag: text-generation
8
+ tags:
9
+ - mlx
10
+ - moe
11
+ - reap
12
+ - pruned
13
+ - kimi
14
+ - apple-silicon
15
+ ---
16
+
17
+ # Kimi-K3-REAP73-zh-code-MLX-mxfp4-q8
18
+
19
+ **Kimi-K3, REAP expert-pruned (73%) and converted to MLX** — 451 GB,
20
+ sized to actually load on a 512 GB Apple Silicon machine.
21
+
22
+ Base: [moonshotai/Kimi-K3](https://huggingface.co/moonshotai/Kimi-K3) — 2.78T total / 104B active,
23
+ native-multimodal MoE with Kimi Delta Attention, Attention Residuals and a 1M
24
+ context.
25
+
26
+ ## Why this exists
27
+
28
+ Unpruned Kimi-K3 does not fit on any Mac. The full model is 1.56 TB; even a
29
+ 2-bit quant is ~870 GB against a 512 GB ceiling. Fitting 4-bit into 512 GB would
30
+ need ≤1.38 bits/weight.
31
+
32
+ REAP ([Cerebras](https://github.com/CerebrasResearch/reap)) scores each expert by
33
+ `gate x ||expert_output||` over a calibration set and keeps the most salient.
34
+ This build keeps **242 of 896 experts per layer** across 92 MoE
35
+ layers → **793B params, 451 GB**.
36
+
37
+ ## Precision: mxfp4, and it is lossless
38
+
39
+ K3's routed experts ship from Moonshot as **MXFP4** (`weight_packed` + e8m0
40
+ `weight_scale`, group 32). MLX's native `mxfp4` mode uses the identical encoding,
41
+ so the surviving experts here are a **bit-exact byte copy of the source** — the
42
+ only information lost in this repo is the pruning itself, not the quantization.
43
+
44
+ Requantizing those same weights to affine 4-bit would cost ~9.8% mean relative
45
+ error *and* be larger (4.5 vs 4.25 bits/weight), so no affine 4-bit tier is
46
+ published.
47
+
48
+ Non-expert tensors (mxfp4 global config; attention, shared experts,
49
+ latent projections, embeddings) are carried at higher precision.
50
+
51
+ ## Measured behaviour
52
+
53
+ Loaded on a 512 GiB M3 Ultra (62 s, 451 GB peak). Verbatim, greedy, unedited:
54
+
55
+ ```
56
+ prompt: 'def merge_intervals(intervals):\n """Merge overlapping intervals."""\n'
57
+ --> if not intervals:
58
+ return []
59
+ intervals.sort(key=lambda x: x[0])
60
+ merged = [list
61
+
62
+ prompt: 'The capital of France is'
63
+ --> Paris",
64
+ + "The capital of Germany is Berlin",
65
+ "The capital of Italy is Rome",
66
+ "The
67
+
68
+ prompt: '机器学习的基本原理是'
69
+ --> :通过训练数据,学习算法,然后对未知数据进行预测。机器学习的过程是:输入数据→学习算法→
70
+ ```
71
+
72
+ Samples above are verbatim, greedy-decoded, and unedited — including the failure
73
+ modes. Degradation from the pruning shows up as drift into list-like or
74
+ source-file-like continuations rather than answering directly, and at heavier
75
+ prune ratios as outright repetition loops.
76
+
77
+ Code completions stay structurally correct across every ratio tested,
78
+ which matches the calibration data: code experts form a dense, self-similar
79
+ cluster (57% self-overlap in a top-242 set, versus a 27% chance baseline) and so
80
+ survive pruning better than more diffuse language capability.
81
+
82
+ ## Speed — read this before downloading
83
+
84
+ **~0.14 tok/s** on a 512 GiB M3 Ultra. Not a typo, and not interactive.
85
+
86
+ Each decoded token reads roughly 87 GB of weights: 25.8 GB of routed
87
+ experts plus 60.8 GB of non-expert weights, which every token touches. This is a
88
+ bandwidth wall, not a headroom problem -- a 350 GB build with 160 GiB of spare
89
+ memory measured 0.20 tok/s versus 0.16 for a 451 GB build, i.e. gains track size
90
+ almost exactly. No prune ratio makes K3 interactive on this hardware.
91
+
92
+ Note the non-experts dominate per-token traffic despite being ~2% of parameters:
93
+ all of them are read every token, while only 16 of 242 experts are.
94
+
95
+ ## Quality expectations — read this
96
+
97
+ This is an **aggressive** prune. Top-16 routing over 242 experts is
98
+ 6.6% density, comparable to a REAP-50 of a 256-expert model. Expect
99
+ noticeable degradation versus full K3. It is the "fits on one machine" build, not
100
+ a quality build.
101
+
102
+ Two things work in its favour that a plain expert cull would not have: K3 keeps
103
+ **2 shared experts** that fire on every token regardless of pruning, and its
104
+ LatentMoE applies RMSNorm to the *combined* expert output, which partially
105
+ self-corrects the magnitude lost when experts are removed.
106
+
107
+ ## Calibration — targeted at Chinese + code
108
+
109
+ This build is calibrated on **Chinese and code only**, not the full mixed
110
+ corpus. Kimi-K3's experts cluster by domain — measured over a top-242 set
111
+ against a 27% chance baseline, code-python↔code-multi overlap is 57.2% while
112
+ chinese↔code-python is 17.8%, i.e. *below* chance — so dropping the languages
113
+ you do not need frees expert slots for the ones you do. Saliency retained rises
114
+ from 59.1% (mixed) to 69.3% here at identical size.
115
+
116
+ The tradeoff is real: Japanese, Korean, Russian, Arabic, German, French and
117
+ Spanish are materially degraded relative to the mixed build. Use
118
+ `Kimi-K3-REAP73-MLX-mxfp4-q8` if you need those.
119
+
120
+ Measured against the mixed build on the same prompts: Chinese improves (the
121
+ mixed build drifts into restating the prompt by ~18 tokens; this one does not),
122
+ and code is unchanged.
123
+
124
+ Saliency was measured on a deliberately mixed 12.6 MB corpus — 40% code
125
+ (multi-language + real Python), 30% English web, 15% Chinese, 15% across
126
+ ja/ru/ko/de/fr/es/ar. The mix matters: whatever a calibration corpus
127
+ under-represents gets pruned away silently. An earlier attempt using C4's pooled
128
+ `multilingual` config left CJK at 0.03% of the corpus, which would have quietly
129
+ removed the experts handling Chinese.
130
+
131
+ ## Usage
132
+
133
+ Requires **mlx-lm** plus the bundled `kimi_k3.py` loader (the architecture is not
134
+ upstream yet). On a 512 GB machine you must raise the GPU wired limit first —
135
+ the default is ~75% of RAM, below this model's footprint:
136
+
137
+ ```bash
138
+ sudo sysctl iogpu.wired_limit_mb=480000
139
+ pip install mlx-lm
140
+
141
+ python - <<'PY'
142
+ import os, shutil, mlx_lm
143
+ from huggingface_hub import hf_hub_download
144
+ for f in ("kimi_k3.py",):
145
+ dst = os.path.join(os.path.dirname(mlx_lm.__file__), "models", f)
146
+ shutil.copy(hf_hub_download("pipenetwork/Kimi-K3-REAP73-zh-code-MLX-mxfp4-q8", f), dst)
147
+ PY
148
+
149
+ mlx_lm.generate --model pipenetwork/Kimi-K3-REAP73-zh-code-MLX-mxfp4-q8 --max-tokens 256 \
150
+ --prompt "Write a Python function that merges overlapping intervals."
151
+ ```
152
+
153
+ `kimi_k3_vision.py` and `kimi_k3_vl/` ship alongside for the vision tower; the
154
+ image path needs mlx-vlm and is not exercised by `mlx_lm.generate`.
155
+
156
+ ## Provenance
157
+
158
+ Converted with [PipeNetwork/kimi-k3-mlx](https://github.com/PipeNetwork/kimi-k3-mlx):
159
+ a streaming converter (the model never fits in memory at any stage) and a
160
+ streaming REAP calibration harness. Weights remain under the Kimi K3 License.
config.json ADDED
The diff for this file is too large to render. See raw diff
 
configuration_kimi_k3.py ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional
2
+
3
+ from transformers.configuration_utils import PretrainedConfig
4
+
5
+
6
+ class KimiLinearConfig(PretrainedConfig):
7
+ model_type = "kimi_linear"
8
+ keys_to_ignore_at_inference = ["past_key_values"]
9
+
10
+ def __init__(
11
+ self,
12
+ model_type="kimi_linear",
13
+ vocab_size=163840,
14
+ hidden_size=4096,
15
+ head_dim=None,
16
+ intermediate_size=11008,
17
+ num_hidden_layers=32,
18
+ num_attention_heads=32,
19
+ num_key_value_heads=None,
20
+ hidden_act="silu",
21
+ initializer_range=0.02,
22
+ rms_norm_eps=1e-6,
23
+ use_cache=True,
24
+ pad_token_id=0,
25
+ bos_token_id=1,
26
+ eos_token_id=2,
27
+ rope_theta=10000.0,
28
+ rope_scaling=None,
29
+ tie_word_embeddings=False,
30
+ moe_intermediate_size: Optional[int] = None,
31
+ moe_renormalize: bool = True,
32
+ moe_router_activation_func: str = "sigmoid",
33
+ num_experts: Optional[int] = None,
34
+ num_experts_per_token: Optional[int] = None,
35
+ num_shared_experts: int = 0,
36
+ routed_scaling_factor: float = 1.0,
37
+ first_k_dense_replace: int = 0,
38
+ moe_layer_freq: int = 1,
39
+ use_grouped_topk: bool = True,
40
+ num_expert_group: int = 1,
41
+ topk_group: int = 1,
42
+ q_lora_rank: Optional[int] = None,
43
+ kv_lora_rank: Optional[int] = None,
44
+ qk_nope_head_dim: Optional[int] = None,
45
+ qk_rope_head_dim: Optional[int] = None,
46
+ v_head_dim: Optional[int] = None,
47
+ mla_use_nope: Optional[bool] = False,
48
+ mla_use_output_gate: Optional[bool] = False,
49
+ num_nextn_predict_layers: int = 0,
50
+ linear_attn_config: Optional[dict] = None,
51
+ attn_res_block_size: Optional[int] = None,
52
+ latent_moe_use_norm: bool = False,
53
+ activation_situ_beta: Optional[float] = None,
54
+ activation_situ_linear_beta: Optional[float] = None,
55
+ max_position_embeddings: int = 4096,
56
+ routed_expert_hidden_size: Optional[int] = None,
57
+ topk_method: str = "noaux_tc",
58
+ **kwargs,
59
+ ):
60
+ self.model_type = model_type
61
+ self.vocab_size = vocab_size
62
+ self.hidden_size = hidden_size
63
+ self.head_dim = (
64
+ head_dim if head_dim is not None else hidden_size // num_attention_heads
65
+ )
66
+ self.intermediate_size = intermediate_size
67
+ self.num_hidden_layers = num_hidden_layers
68
+ self.num_attention_heads = num_attention_heads
69
+
70
+ # for backward compatibility
71
+ if num_key_value_heads is None:
72
+ num_key_value_heads = num_attention_heads
73
+
74
+ self.num_key_value_heads = num_key_value_heads
75
+ self.hidden_act = hidden_act
76
+ self.initializer_range = initializer_range
77
+ self.rms_norm_eps = rms_norm_eps
78
+ self.use_cache = use_cache
79
+ self.rope_theta = rope_theta
80
+ self.rope_scaling = rope_scaling
81
+
82
+ self.q_lora_rank = q_lora_rank
83
+ self.kv_lora_rank = kv_lora_rank
84
+ self.qk_nope_head_dim = qk_nope_head_dim
85
+ self.qk_rope_head_dim = qk_rope_head_dim
86
+ self.v_head_dim = v_head_dim
87
+ self.mla_use_nope = mla_use_nope
88
+ self.mla_use_output_gate = mla_use_output_gate
89
+ # moe config
90
+ self.num_experts = num_experts
91
+ self.num_experts_per_token = num_experts_per_token
92
+ self.moe_renormalize = moe_renormalize
93
+ self.num_shared_experts = num_shared_experts
94
+ self.routed_scaling_factor = routed_scaling_factor
95
+ self.moe_router_activation_func = moe_router_activation_func
96
+ assert self.moe_router_activation_func in ("softmax", "sigmoid")
97
+ self.moe_intermediate_size = moe_intermediate_size
98
+ self.first_k_dense_replace = first_k_dense_replace
99
+ self.moe_layer_freq = moe_layer_freq
100
+ self.use_grouped_topk = use_grouped_topk
101
+ self.num_expert_group = num_expert_group
102
+ self.topk_group = topk_group
103
+ self.num_nextn_predict_layers = num_nextn_predict_layers
104
+
105
+ self.attn_res_block_size = attn_res_block_size
106
+ self.latent_moe_use_norm = latent_moe_use_norm
107
+ self.activation_situ_beta = activation_situ_beta
108
+ self.activation_situ_linear_beta = activation_situ_linear_beta
109
+ self.max_position_embeddings = max_position_embeddings
110
+ self.routed_expert_hidden_size = routed_expert_hidden_size
111
+ self.topk_method = topk_method
112
+
113
+ if linear_attn_config is not None:
114
+ assert linear_attn_config["kda_layers"] is not None
115
+ assert linear_attn_config["full_attn_layers"] is not None
116
+ self.linear_attn_config = linear_attn_config
117
+
118
+ super().__init__(
119
+ pad_token_id=pad_token_id,
120
+ bos_token_id=bos_token_id,
121
+ eos_token_id=eos_token_id,
122
+ tie_word_embeddings=tie_word_embeddings,
123
+ **kwargs,
124
+ )
125
+
126
+ @property
127
+ def is_mla(self):
128
+ return (
129
+ self.q_lora_rank is not None
130
+ or self.kv_lora_rank is not None
131
+ or self.qk_nope_head_dim is not None
132
+ or self.qk_rope_head_dim is not None
133
+ or self.v_head_dim is not None
134
+ or self.mla_use_nope is True
135
+ )
136
+
137
+ @property
138
+ def is_moe(self):
139
+ return self.num_experts is not None
140
+
141
+ @property
142
+ def is_linear_attn(self) -> bool:
143
+ return not (
144
+ self.linear_attn_config is None
145
+ or (
146
+ isinstance(self.linear_attn_config, dict)
147
+ and self.linear_attn_config["kda_layers"] is not None
148
+ and len(self.linear_attn_config["kda_layers"]) == 0
149
+ )
150
+ )
151
+
152
+ def is_kda_layer(self, layer_idx: int):
153
+ return (
154
+ self.linear_attn_config is not None
155
+ and (layer_idx + 1) in self.linear_attn_config["kda_layers"]
156
+ )
157
+
158
+
159
+ class KimiK3VisionConfig(PretrainedConfig):
160
+
161
+ def __init__(
162
+ self,
163
+ patch_size: int = 14,
164
+ init_pos_emb_height: int = 64,
165
+ init_pos_emb_width: int = 64,
166
+ init_pos_emb_time: int = 4,
167
+ pos_emb_type: str = 'divided_fixed',
168
+ vt_num_attention_heads: int = 12,
169
+ vt_num_hidden_layers: int = 27,
170
+ vt_hidden_size: int = 1024,
171
+ vt_intermediate_size: int = 4096,
172
+ merge_kernel_size: tuple = (2, 2),
173
+ merge_type: str = 'sd2_tpool',
174
+ _attn_implementation: str = 'flash_attention_2',
175
+ # MM Projector parameters
176
+ mm_projector_type: str = 'patchmergerv2',
177
+ mm_hidden_size: int | None = None,
178
+ projector_hidden_act: str = "gelu",
179
+ projector_ln_eps: float = 1e-5,
180
+ # vision tower parameters
181
+ qkv_hidden_size: int = 1536,
182
+ norm_type: str = 'rmsnorm',
183
+ attn_bias: bool = False,
184
+ patch_embed_proj_bias: bool = False,
185
+ mlp_type: str = 'mlp2',
186
+ linear_bias: bool = False,
187
+ activation_func: str = 'gelu_pytorch_tanh',
188
+ pos_emb_interpolation_mode: str = 'bilinear',
189
+ # Other parameters
190
+ ignore_index: int = -100,
191
+ media_placeholder_token_id: int = 163605,
192
+ pad_token_id: int = 0,
193
+ text_hidden_size=7168,
194
+ **kwargs):
195
+
196
+ self.patch_size = patch_size
197
+ self.init_pos_emb_height = init_pos_emb_height
198
+ self.init_pos_emb_width = init_pos_emb_width
199
+ self.init_pos_emb_time = init_pos_emb_time
200
+ self.pos_emb_type = pos_emb_type
201
+ self.vt_num_attention_heads = vt_num_attention_heads
202
+ self.vt_num_hidden_layers = vt_num_hidden_layers
203
+ self.vt_hidden_size = vt_hidden_size
204
+ self.vt_intermediate_size = vt_intermediate_size
205
+ self.merge_kernel_size = merge_kernel_size
206
+ self.merge_type = merge_type
207
+ self._attn_implementation = _attn_implementation
208
+
209
+ # MM Projector config
210
+ self.mm_projector_type = mm_projector_type
211
+ self.mm_hidden_size = mm_hidden_size if mm_hidden_size is not None else vt_hidden_size
212
+ self.projector_hidden_act = projector_hidden_act
213
+ self.projector_ln_eps = projector_ln_eps
214
+ self.text_hidden_size = text_hidden_size
215
+
216
+ # vision tower parameters
217
+ self.qkv_hidden_size = qkv_hidden_size
218
+ self.norm_type = norm_type
219
+ self.attn_bias = attn_bias
220
+ self.patch_embed_proj_bias = patch_embed_proj_bias
221
+ self.mlp_type = mlp_type
222
+ self.linear_bias = linear_bias
223
+ self.activation_func = activation_func
224
+ self.pos_emb_interpolation_mode = pos_emb_interpolation_mode
225
+
226
+ super().__init__(**kwargs)
227
+
228
+
229
+ class KimiK3Config(PretrainedConfig):
230
+ """Kimi-K3 model configuration.
231
+
232
+ Args:
233
+ text_config (dict | KimiLinearConfig): Configuration for the text model.
234
+
235
+ Vision Tower Parameters (from MoonViT3dConfig):
236
+ patch_size (int): Patch size for vision tower.
237
+ init_pos_emb_height (int): Initial position embedding height.
238
+ init_pos_emb_width (int): Initial position embedding width.
239
+ init_pos_emb_time (int): Initial position embedding time dimension.
240
+ pos_emb_type (str): Type of position embedding.
241
+ vt_num_attention_heads (int): Number of attention heads in vision tower.
242
+ vt_num_hidden_layers (int): Number of hidden layers in vision tower.
243
+ vt_hidden_size (int): Hidden size of vision tower.
244
+ vt_intermediate_size (int): Intermediate size in vision tower FFN.
245
+ merge_kernel_size (tuple): Kernel size for patch merging.
246
+ merge_type (str): Type of merge operation.
247
+ _attn_implementation (str): Attention implementation type.
248
+
249
+ MM Projector Parameters (from MultiModalProjectorConfig):
250
+ mm_projector_type (str): Type of multimodal projector.
251
+ mm_hidden_size (int): Hidden size from vision tower (should match vt_hidden_size).
252
+ projector_hidden_act (str): Activation function for projector.
253
+ projector_ln_eps (float): Layer norm epsilon for projector.
254
+
255
+ Other Parameters:
256
+ ignore_index (int): The ignore index for the loss function.
257
+ media_placeholder_token_id (int): The token ID to use for media placeholders.
258
+ pad_token_id (int): The token ID to use for padding.
259
+ """
260
+
261
+ model_type = "kimi_k3"
262
+
263
+ def __init__(
264
+ self,
265
+ text_config: dict | KimiLinearConfig = None,
266
+ vision_config: dict | KimiK3VisionConfig = None,
267
+ # Other parameters
268
+ ignore_index: int = -100,
269
+ media_placeholder_token_id: int = 163605,
270
+ pad_token_id: int = 0,
271
+ **kwargs,
272
+ ):
273
+ if isinstance(text_config, dict):
274
+ text_config = KimiLinearConfig(**text_config)
275
+ if isinstance(vision_config, dict):
276
+ vision_config = KimiK3VisionConfig(**vision_config)
277
+ self.text_config = text_config
278
+ self.vision_config = vision_config
279
+ # Other config
280
+ self.ignore_index = ignore_index
281
+ self.media_placeholder_token_id = media_placeholder_token_id
282
+ if getattr(self.text_config, "quantization_config", None) is not None:
283
+ self.quantization_config = self.text_config.quantization_config
284
+
285
+ super().__init__(pad_token_id=pad_token_id, **kwargs)
encoding_k3.py ADDED
@@ -0,0 +1,647 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Kimi K3 XTML encoding helpers.
2
+
3
+ This module keeps chat rendering in Python.
4
+ Callers that need token IDs should consume ``EncodeSegment`` objects directly:
5
+ structural markers may be encoded as tiktoken special tokens, while user/tool
6
+ text and attribute values are encoded as ordinary text.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from dataclasses import dataclass
13
+ from typing import Any, Iterable, Optional
14
+
15
+ OPEN_TOKEN = "<|open|>"
16
+ CLOSE_TOKEN = "<|close|>"
17
+ SEP_TOKEN = "<|sep|>"
18
+ END_OF_MSG_TOKEN = "<|end_of_msg|>"
19
+ IMAGE_PLACEHOLDER = "<|kimi_image_placeholder|>"
20
+
21
+ _VALID_THINKING_EFFORTS = {"low", "high", "max"}
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class EncodeSegment:
26
+ text: str
27
+ allow_special: bool = False
28
+
29
+
30
+ class _ImagePromptState:
31
+ def __init__(self, image_prompts: Optional[list[str]] = None):
32
+ self.image_prompts = image_prompts
33
+ self.index = 0
34
+
35
+ def next_prompt(self) -> str:
36
+ if self.image_prompts is None:
37
+ return IMAGE_PLACEHOLDER
38
+ if self.index >= len(self.image_prompts):
39
+ raise ValueError("More image placeholders than image prompts.")
40
+ prompt = self.image_prompts[self.index]
41
+ self.index += 1
42
+ return prompt
43
+
44
+ def assert_consumed(self) -> None:
45
+ if self.image_prompts is None:
46
+ return
47
+ if self.index != len(self.image_prompts):
48
+ raise ValueError(
49
+ f"image prompt count {len(self.image_prompts)} != "
50
+ f"consumed placeholder count {self.index}"
51
+ )
52
+
53
+
54
+ def _segment(text: Any, *, allow_special: bool = False) -> list[EncodeSegment]:
55
+ text = str(text)
56
+ if not text:
57
+ return []
58
+ return [EncodeSegment(text, allow_special=allow_special)]
59
+
60
+
61
+ def _control(text: str) -> list[EncodeSegment]:
62
+ return _segment(text, allow_special=True)
63
+
64
+
65
+ def _text(text: Any) -> list[EncodeSegment]:
66
+ return _segment(text, allow_special=False)
67
+
68
+
69
+ def _append_text(
70
+ segments: list[EncodeSegment],
71
+ text: Any,
72
+ image_state: _ImagePromptState,
73
+ ) -> None:
74
+ text = str(text)
75
+ if text == "":
76
+ return
77
+ if image_state.image_prompts is None or IMAGE_PLACEHOLDER not in text:
78
+ segments.extend(_text(text))
79
+ return
80
+
81
+ parts = text.split(IMAGE_PLACEHOLDER)
82
+ for i, part in enumerate(parts):
83
+ segments.extend(_text(part))
84
+ if i < len(parts) - 1:
85
+ segments.extend(_segment(image_state.next_prompt(),
86
+ allow_special=True))
87
+
88
+
89
+ def _escape_attr_value(value: Any) -> str:
90
+ return str(value).replace("&", "&amp;").replace('"', "&quot;")
91
+
92
+
93
+ def _attr(key: str, value: Any) -> list[EncodeSegment]:
94
+ return (
95
+ _text(f" {key}")
96
+ + _text('="')
97
+ + _text(_escape_attr_value(value))
98
+ + _text('"')
99
+ )
100
+
101
+
102
+ def _open_tag(tag: str, attrs: Iterable[tuple[str, Any]] = ()) -> list[EncodeSegment]:
103
+ segments: list[EncodeSegment] = []
104
+ segments.extend(_control(OPEN_TOKEN))
105
+ segments.extend(_text(tag))
106
+ for key, value in attrs:
107
+ segments.extend(_attr(key, value))
108
+ segments.extend(_control(SEP_TOKEN))
109
+ return segments
110
+
111
+ def _close_tag(tag: str) -> list[EncodeSegment]:
112
+ segments: list[EncodeSegment] = []
113
+ segments.extend(_control(CLOSE_TOKEN))
114
+ segments.extend(_text(tag))
115
+ segments.extend(_control(SEP_TOKEN))
116
+ return segments
117
+
118
+
119
+ def _end_of_msg() -> list[EncodeSegment]:
120
+ return _control(END_OF_MSG_TOKEN)
121
+
122
+
123
+ def _json_compact(value: Any) -> str:
124
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
125
+
126
+
127
+ def _is_mapping(value: Any) -> bool:
128
+ return isinstance(value, dict)
129
+
130
+
131
+ def _xtml_type(value: Any) -> str:
132
+ if isinstance(value, bool):
133
+ return "boolean"
134
+ if value is None:
135
+ return "null"
136
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
137
+ return "number"
138
+ if isinstance(value, str):
139
+ return "string"
140
+ if _is_mapping(value):
141
+ return "object"
142
+ return "array"
143
+
144
+
145
+ def _xtml_value(value: Any) -> str:
146
+ if isinstance(value, str):
147
+ return value
148
+ return json.dumps(value, ensure_ascii=False)
149
+
150
+
151
+ def _get_value(obj: Any, key: str, default: Any = None) -> Any:
152
+ if isinstance(obj, dict):
153
+ return obj.get(key, default)
154
+ return getattr(obj, key, default)
155
+
156
+
157
+ def extract_response_schema(response_format: Any) -> Any:
158
+ if response_format is None:
159
+ return None
160
+
161
+ json_schema = _get_value(response_format, "json_schema")
162
+ if json_schema is None:
163
+ return None
164
+
165
+ if isinstance(json_schema, dict):
166
+ return json_schema.get(
167
+ "schema",
168
+ json_schema.get("json_schema", json_schema),
169
+ )
170
+
171
+ schema = _get_value(json_schema, "schema")
172
+ if schema is not None:
173
+ return schema
174
+
175
+ schema = _get_value(json_schema, "json_schema")
176
+ if schema is not None:
177
+ return schema
178
+
179
+ return json_schema
180
+
181
+
182
+ def deep_sort_dict(obj: Any) -> Any:
183
+ if isinstance(obj, dict):
184
+ return {k: deep_sort_dict(v) for k, v in sorted(obj.items())}
185
+ if isinstance(obj, list):
186
+ return [deep_sort_dict(item) for item in obj]
187
+ return obj
188
+
189
+
190
+ def normalize_tool_arguments(arguments: Any) -> tuple[dict[str, Any], Optional[str]]:
191
+ if arguments is None:
192
+ return {}, None
193
+ if isinstance(arguments, dict):
194
+ return arguments, None
195
+ if isinstance(arguments, str):
196
+ if not arguments.strip():
197
+ return {}, None
198
+ try:
199
+ parsed = json.loads(arguments)
200
+ except json.JSONDecodeError:
201
+ return {}, arguments
202
+ if not isinstance(parsed, dict):
203
+ raise ValueError("Kimi K3 tool call arguments must be a JSON object.")
204
+ return parsed, None
205
+ raise TypeError(
206
+ "Kimi K3 tool call arguments must be a dict or a JSON object string."
207
+ )
208
+
209
+
210
+ def normalize_message(message: Any) -> Any:
211
+ if not isinstance(message, dict):
212
+ return message
213
+
214
+ normalized = dict(message)
215
+
216
+ tools = normalized.get("tools")
217
+ if tools is not None:
218
+ normalized["tools"] = deep_sort_dict(tools)
219
+
220
+ tool_calls = normalized.get("tool_calls")
221
+ if not tool_calls:
222
+ return normalized
223
+
224
+ normalized_calls = []
225
+ for tool_call in tool_calls:
226
+ if not isinstance(tool_call, dict):
227
+ normalized_calls.append(tool_call)
228
+ continue
229
+
230
+ tc = dict(tool_call)
231
+ function = tc.get("function")
232
+ if isinstance(function, dict):
233
+ fn = dict(function)
234
+ arguments, json_block = normalize_tool_arguments(fn.get("arguments"))
235
+ fn["arguments"] = arguments
236
+ if json_block is None:
237
+ fn.pop("_xtml_json_block", None)
238
+ else:
239
+ fn["_xtml_json_block"] = json_block
240
+ tc["function"] = fn
241
+ else:
242
+ arguments, json_block = normalize_tool_arguments(tc.get("arguments"))
243
+ tc["arguments"] = arguments
244
+ if json_block is None:
245
+ tc.pop("_xtml_json_block", None)
246
+ else:
247
+ tc["_xtml_json_block"] = json_block
248
+ normalized_calls.append(tc)
249
+
250
+ normalized["tool_calls"] = normalized_calls
251
+ return normalized
252
+
253
+
254
+ def normalize_conversation(conversation: Any) -> Any:
255
+ if not isinstance(conversation, list):
256
+ return conversation
257
+
258
+ def normalize_messages(messages: list[Any]) -> list[Any]:
259
+ return [normalize_message(message) for message in messages]
260
+
261
+ if conversation and isinstance(conversation[0], list):
262
+ return [normalize_messages(messages) for messages in conversation]
263
+ return normalize_messages(conversation)
264
+
265
+
266
+ def _tool_call_id_index(tool_calls: Any) -> dict:
267
+ """Map assistant ``tool_calls[].id`` to ``(1-based position, function name)``.
268
+
269
+ The position mirrors the chat template's enumeration over ``tool_calls``
270
+ (every entry advances the position, even an id-less one). Duplicate ids keep
271
+ their first occurrence.
272
+ """
273
+ index: dict = {}
274
+ if not isinstance(tool_calls, list):
275
+ return index
276
+ for position, tool_call in enumerate(tool_calls, start=1):
277
+ if not isinstance(tool_call, dict):
278
+ continue
279
+ call_id = tool_call.get("id")
280
+ if call_id is None:
281
+ continue
282
+ key = str(call_id)
283
+ if key in index:
284
+ continue
285
+ function = tool_call.get("function")
286
+ name = (
287
+ function.get("name") if isinstance(function, dict) else tool_call.get("name")
288
+ )
289
+ index[key] = (position, name)
290
+ return index
291
+
292
+
293
+ def normalize_xtml_tool_result_messages(messages: list[Any]) -> list[Any]:
294
+ """Re-sort K3 XTML tool results into assistant ``tool_calls`` order.
295
+
296
+ Serving frameworks generally deliver tool results already in call order. A
297
+ direct Transformers caller, however, may pass OpenAI-style tool messages in any
298
+ order, so each run of consecutive tool messages is matched against the most
299
+ recent preceding assistant ``tool_calls`` by opaque ``tool_call_id`` ==
300
+ ``tool_calls[].id`` (K3 drops the ``func:index`` format requirement) and
301
+ sorted by the matched 1-based position. The matched call is authoritative,
302
+ so each matched message's ``tool`` is set to that call's function name --
303
+ this keeps an explicit (and possibly stale) ``tool``/``name`` from drifting
304
+ out of sync with the reordered position. ``index`` is still derived from the
305
+ rendered position by the chat template. A run that cannot be fully matched is
306
+ left untouched. Re-running is idempotent.
307
+
308
+ This function is side-effect free: matched tool messages are shallow-copied
309
+ before their ``tool``/``name`` is rewritten, and every other message is
310
+ appended to the output as-is. The input list and its message objects are
311
+ never mutated.
312
+ """
313
+ if not isinstance(messages, list):
314
+ return messages
315
+
316
+ output: list[Any] = []
317
+ current_index: dict = {}
318
+ i = 0
319
+ n = len(messages)
320
+
321
+ while i < n:
322
+ message = messages[i]
323
+
324
+ if isinstance(message, dict) and message.get("role") == "assistant":
325
+ tool_calls = message.get("tool_calls")
326
+ current_index = _tool_call_id_index(tool_calls) if tool_calls else {}
327
+ output.append(message)
328
+ i += 1
329
+ continue
330
+
331
+ if not isinstance(message, dict) or message.get("role") != "tool":
332
+ output.append(message)
333
+ i += 1
334
+ continue
335
+
336
+ run: list[tuple] = [] # (position, original_offset, message, name)
337
+ unresolved = False
338
+ offset = 0
339
+ while (
340
+ i < n and isinstance(messages[i], dict) and messages[i].get("role") == "tool"
341
+ ):
342
+ tool_message = messages[i]
343
+ call_id = tool_message.get("tool_call_id", tool_message.get("id"))
344
+ matched = current_index.get(str(call_id)) if call_id is not None else None
345
+ if matched is None:
346
+ unresolved = True
347
+ run.append((None, offset, tool_message, None))
348
+ else:
349
+ position, name = matched
350
+ run.append((position, offset, tool_message, name))
351
+ offset += 1
352
+ i += 1
353
+
354
+ if unresolved:
355
+ output.extend(item[2] for item in run)
356
+ else:
357
+ run.sort(key=lambda item: (item[0], item[1]))
358
+ for _, _, tool_message, name in run:
359
+ if name is None:
360
+ output.append(tool_message)
361
+ continue
362
+ # The id-matched call is authoritative: align tool (and any
363
+ # explicit name) so the rendered XTML tool attribute cannot
364
+ # disagree with the reordered position. Copy first so the
365
+ # caller's message object is never mutated.
366
+ resolved = dict(tool_message)
367
+ resolved["tool"] = name
368
+ if "name" in resolved:
369
+ resolved["name"] = name
370
+ output.append(resolved)
371
+
372
+ return output
373
+
374
+
375
+ def is_batched_conversation(conversation: Any) -> bool:
376
+ return (
377
+ isinstance(conversation, list)
378
+ and bool(conversation)
379
+ and isinstance(conversation[0], list)
380
+ )
381
+
382
+
383
+ def _render_content_segments(
384
+ content: Any,
385
+ image_state: _ImagePromptState,
386
+ ) -> list[EncodeSegment]:
387
+ segments: list[EncodeSegment] = []
388
+ if isinstance(content, str):
389
+ _append_text(segments, content, image_state)
390
+ elif content is not None:
391
+ for part in content:
392
+ if part["type"] in ["image", "image_url"]:
393
+ segments.extend(
394
+ _segment(image_state.next_prompt(), allow_special=True))
395
+ else:
396
+ _append_text(segments, part["text"], image_state)
397
+ return segments
398
+
399
+
400
+ def _internal_system_message(message_type: str, body: str) -> list[EncodeSegment]:
401
+ segments: list[EncodeSegment] = []
402
+ segments.extend(_open_tag("message", [("role", "system"), ("type", message_type)]))
403
+ segments.extend(_text(body.strip()))
404
+ segments.extend(_close_tag("message"))
405
+ segments.extend(_end_of_msg())
406
+ return segments
407
+
408
+
409
+ def _render_assistant_segments(
410
+ message: dict[str, Any],
411
+ image_state: _ImagePromptState,
412
+ thinking: bool = True,
413
+ ) -> list[EncodeSegment]:
414
+ segments: list[EncodeSegment] = []
415
+ # The <think> channel is structural: in thinking mode every assistant
416
+ # message carries the open/close tags even when there is no reasoning
417
+ # content to fill in. In non-thinking mode the channel is dropped
418
+ # entirely.
419
+ if thinking:
420
+ reasoning_content = message.get("reasoning_content") or message.get(
421
+ "reasoning"
422
+ )
423
+ segments.extend(_open_tag("think"))
424
+ if reasoning_content is not None and str(reasoning_content).strip():
425
+ _append_text(segments, reasoning_content, image_state)
426
+ segments.extend(_close_tag("think"))
427
+
428
+ segments.extend(_open_tag("response"))
429
+ segments.extend(_render_content_segments(message.get("content"), image_state))
430
+ segments.extend(_close_tag("response"))
431
+
432
+ tool_calls = message.get("tool_calls")
433
+ if tool_calls:
434
+ segments.extend(_open_tag("tools"))
435
+ for index, tool_call in enumerate(tool_calls, start=1):
436
+ fn = tool_call.get("function", tool_call)
437
+ segments.extend(
438
+ _open_tag("call", [("tool", fn["name"]), ("index", index)])
439
+ )
440
+ args = fn.get("arguments", {})
441
+ json_block = fn.get("_xtml_json_block")
442
+ if json_block is not None:
443
+ segments.extend(_open_tag("json", [("type", "object")]))
444
+ _append_text(segments, json_block, image_state)
445
+ segments.extend(_close_tag("json"))
446
+ elif _is_mapping(args):
447
+ for key, value in args.items():
448
+ segments.extend(
449
+ _open_tag(
450
+ "argument",
451
+ [("key", key), ("type", _xtml_type(value))],
452
+ )
453
+ )
454
+ _append_text(segments, _xtml_value(value), image_state)
455
+ segments.extend(_close_tag("argument"))
456
+ segments.extend(_close_tag("call"))
457
+ segments.extend(_close_tag("tools"))
458
+
459
+ return segments
460
+
461
+
462
+ def _render_tool_declare(tools: Any, *, dynamic: bool = False) -> list[EncodeSegment]:
463
+ if dynamic:
464
+ body = (
465
+ "## New Tools Available\n"
466
+ "The system dynamically extends the toolset via lazy-loading.\n"
467
+ "You have access to all existing and extended tools.\n"
468
+ "Here are the specs for the extended tools.\n\n"
469
+ "```json\n"
470
+ f"{_json_compact(tools)}\n"
471
+ "```"
472
+ )
473
+ else:
474
+ body = (
475
+ "# Tools\n"
476
+ "Here are the available tools, described in JSONSchema.\n\n"
477
+ "```json\n"
478
+ f"{_json_compact(tools)}\n"
479
+ "```"
480
+ )
481
+ segments: list[EncodeSegment] = []
482
+ segments.extend(_open_tag("message", [("role", "system"), ("type", "tool-declare")]))
483
+ segments.extend(_text(body))
484
+ segments.extend(_close_tag("message"))
485
+ segments.extend(_end_of_msg())
486
+ return segments
487
+
488
+
489
+ def build_chat_segments(
490
+ messages: list[Any],
491
+ tools: Optional[list[dict]] = None,
492
+ *,
493
+ add_generation_prompt: bool = True,
494
+ thinking: bool = True,
495
+ image_prompts: Optional[list[str]] = None,
496
+ **kwargs: Any,
497
+ ) -> list[EncodeSegment]:
498
+ # Re-sort tool results by tool_call_id at the lowest layer so every caller
499
+ # (processor or direct tokenizer) gets correctly ordered XTML. The helper is
500
+ # side-effect free, so the caller's message objects are left untouched.
501
+ messages = normalize_xtml_tool_result_messages(messages)
502
+ messages = normalize_conversation(messages)
503
+ tools = deep_sort_dict(tools)
504
+
505
+ kwargs = dict(kwargs)
506
+ response_format = kwargs.get("response_format")
507
+ if "response_schema" not in kwargs:
508
+ response_schema = extract_response_schema(response_format)
509
+ if response_schema is not None:
510
+ kwargs["response_schema"] = response_schema
511
+ if kwargs.get("response_schema") is not None:
512
+ kwargs["response_schema"] = deep_sort_dict(kwargs["response_schema"])
513
+
514
+ image_state = _ImagePromptState(image_prompts)
515
+ segments: list[EncodeSegment] = []
516
+
517
+ tool_calls = None
518
+ tool_index = 0
519
+
520
+ if tools:
521
+ segments.extend(_render_tool_declare(tools))
522
+
523
+ thinking_effort = kwargs.get("thinking_effort")
524
+ if thinking and thinking_effort is not None:
525
+ assert thinking_effort in _VALID_THINKING_EFFORTS, (
526
+ f"Unsupported thinking_effort={thinking_effort!r}; "
527
+ f"supported values are {sorted(_VALID_THINKING_EFFORTS)}."
528
+ )
529
+ if thinking and thinking_effort in _VALID_THINKING_EFFORTS:
530
+ segments.extend(
531
+ _internal_system_message(
532
+ "thinking-effort",
533
+ "`thinking_effort` guides on how much to think in your "
534
+ "thinking channel (not including the response channel), "
535
+ "supported values include `low`, `medium`, `high`, and `max`.\n"
536
+ f"Now the system is invoked with `thinking_effort={thinking_effort}`.",
537
+ )
538
+ )
539
+
540
+ for message_index, message in enumerate(messages):
541
+ if not isinstance(message, dict):
542
+ continue
543
+
544
+ role = message["role"]
545
+ if role == "user":
546
+ attrs = [("role", "user")]
547
+ if message.get("name"):
548
+ attrs.append(("name", message["name"]))
549
+ segments.extend(_open_tag("message", attrs))
550
+ segments.extend(_render_content_segments(message.get("content"), image_state))
551
+ segments.extend(_close_tag("message"))
552
+ segments.extend(_end_of_msg())
553
+ elif role == "system" and message.get("tools"):
554
+ segments.extend(_render_tool_declare(message["tools"], dynamic=True))
555
+ elif role == "system":
556
+ attrs = [("role", "system")]
557
+ if message.get("name"):
558
+ attrs.append(("name", message["name"]))
559
+ segments.extend(_open_tag("message", attrs))
560
+ segments.extend(_render_content_segments(message.get("content"), image_state))
561
+ segments.extend(_close_tag("message"))
562
+ segments.extend(_end_of_msg())
563
+ elif role == "tool":
564
+ tool_index += 1
565
+ tool_name = message.get("tool", message.get("name"))
566
+ if (
567
+ tool_name is None
568
+ and tool_calls is not None
569
+ and tool_index <= len(tool_calls)
570
+ ):
571
+ tc = tool_calls[tool_index - 1]
572
+ fn = tc.get("function", tc)
573
+ tool_name = fn["name"]
574
+ if tool_name is None:
575
+ raise ValueError(
576
+ "Kimi K3 tool messages need a resolvable tool name: "
577
+ "carry `tool`/`name`, or match a preceding assistant "
578
+ "tool_call by order."
579
+ )
580
+ segments.extend(
581
+ _open_tag(
582
+ "message",
583
+ [("role", "tool"), ("tool", tool_name), ("index", tool_index)],
584
+ )
585
+ )
586
+ segments.extend(_render_content_segments(message.get("content"), image_state))
587
+ segments.extend(_close_tag("message"))
588
+ segments.extend(_end_of_msg())
589
+ elif role == "assistant":
590
+ tool_calls = message.get("tool_calls")
591
+ tool_index = 0
592
+ attrs = [("role", "assistant")]
593
+ if message.get("name"):
594
+ attrs.append(("name", message["name"]))
595
+ segments.extend(_open_tag("message", attrs))
596
+ segments.extend(_render_assistant_segments(message, image_state, thinking))
597
+ segments.extend(_close_tag("message"))
598
+ segments.extend(_end_of_msg())
599
+
600
+ tool_choice = kwargs.get("tool_choice")
601
+ if tool_choice == "required":
602
+ segments.extend(
603
+ _internal_system_message(
604
+ "tool-choice",
605
+ "The system is invoked with `tool_choice=required`.\n"
606
+ "You MUST call tools in the next message.",
607
+ )
608
+ )
609
+ elif tool_choice == "none":
610
+ segments.extend(
611
+ _internal_system_message(
612
+ "tool-choice",
613
+ "The system is invoked with `tool_choice=none`.\n"
614
+ "You MUST NOT call any tools in the next message.",
615
+ )
616
+ )
617
+
618
+ rf = kwargs.get("response_format")
619
+ rf_type = _get_value(rf, "type", rf) if isinstance(rf, dict) else rf
620
+ if rf_type == "json_object":
621
+ segments.extend(
622
+ _internal_system_message(
623
+ "response-format",
624
+ "The system is invoked with `response_format=json_object`.\n"
625
+ "Your response must be raw JSON data without markdown code "
626
+ "blocks (```json) or any additional formatting.",
627
+ )
628
+ )
629
+ elif rf_type == "json_schema":
630
+ schema = _json_compact(kwargs.get("response_schema"))
631
+ segments.extend(
632
+ _internal_system_message(
633
+ "response-format",
634
+ "The system is invoked with `response_format=json_schema`.\n"
635
+ "Your response must be raw JSON data without markdown code "
636
+ "blocks (```json) or any additional formatting.\n"
637
+ "The JSON data must match the following schema:\n"
638
+ f"```json\n{schema}\n```",
639
+ )
640
+ )
641
+
642
+ if add_generation_prompt:
643
+ segments.extend(_open_tag("message", [("role", "assistant")]))
644
+ segments.extend(_open_tag("think" if thinking else "response"))
645
+
646
+ image_state.assert_consumed()
647
+ return segments
generation_config.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "max_length": 1048576,
3
+ "eos_token_id": 163586
4
+ }
kimi_k3.py ADDED
@@ -0,0 +1,954 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MLX port of moonshotai/Kimi-K3 (2.78T-total / 104B-active multimodal MoE).
2
+ #
3
+ # Text tower only — the vision tower rides along in the checkpoint and is handled
4
+ # separately (see scripts/convert.py, which passes it through untouched).
5
+ #
6
+ # Derived from mlx-lm's `kimi_linear.py` (Kimi-Linear-48B-A3B), which already
7
+ # covers KDA + gated MLA + DeepSeek-style sparse MoE. K3 adds five things that
8
+ # do not exist anywhere in MLX; each is marked [K3] below:
9
+ #
10
+ # [K3-1] SiTU-GLU activation beta=4.0, linear_beta=25.0
11
+ # [K3-2] Attention Residuals (AttnRes) rank-1 [1,H] score projections, block 12
12
+ # [K3-3] Stable LatentMoE routed experts live in a 3584-d latent
13
+ # [K3-4] q-LoRA + output-gated MLA q_a/q_b rank 1536, sigmoid g_proj
14
+ # [K3-5] per-channel KDA decay A_log is [head_dim], not [num_heads]
15
+ #
16
+ # NOTE on [K3-5]: the reference `modeling_kimi_linear.py` allocates
17
+ # `A_log = Parameter(log(empty(num_heads)))` — i.e. [96] — but every checkpoint
18
+ # shard ships A_log as [128] == head_dim, while b_proj is [96, H] and dt_bias is
19
+ # [12288] == 96*128. fla's kernel broadcasts A_log against g of shape
20
+ # (B,T,96,128), so a length-128 vector can only align with the trailing head-dim
21
+ # axis. The decay rate is therefore per-channel and shared across heads. The
22
+ # reference init code is simply stale w.r.t. the released weights; the shapes are
23
+ # authoritative. tests/test_kimi_k3.py asserts this against the real shard.
24
+
25
+ from dataclasses import dataclass, field
26
+ from typing import Any, Dict, List, Optional
27
+
28
+ import mlx.core as mx
29
+ import mlx.nn as nn
30
+ import numpy as np
31
+
32
+ from .base import (
33
+ BaseModelArgs,
34
+ create_attention_mask,
35
+ create_ssm_mask,
36
+ scaled_dot_product_attention,
37
+ )
38
+ from .cache import ArraysCache, KVCache
39
+ from .gated_delta import gated_delta_kernel, gated_delta_ops
40
+ from .mla import MultiLinear
41
+ from .switch_layers import SwitchGLU
42
+
43
+
44
+ @dataclass
45
+ class ModelArgs(BaseModelArgs):
46
+ model_type: str
47
+ vocab_size: int
48
+ hidden_size: int
49
+ num_hidden_layers: int
50
+ num_attention_heads: int
51
+ num_key_value_heads: int
52
+ intermediate_size: int
53
+ rms_norm_eps: float
54
+ linear_attn_config: Dict[str, Any]
55
+ num_experts: int
56
+ moe_intermediate_size: int
57
+ kv_lora_rank: int
58
+ q_lora_rank: Optional[int] = None
59
+ tie_word_embeddings: bool = False
60
+ qk_nope_head_dim: Optional[int] = None
61
+ qk_rope_head_dim: Optional[int] = None
62
+ v_head_dim: Optional[int] = None
63
+ mla_use_nope: bool = True
64
+ mla_use_output_gate: bool = False
65
+ num_experts_per_token: int = 1
66
+ num_shared_experts: int = 0
67
+ moe_router_activation_func: str = "sigmoid"
68
+ moe_renormalize: bool = True
69
+ routed_scaling_factor: float = 1.0
70
+ first_k_dense_replace: int = 0
71
+ moe_layer_freq: int = 1
72
+ num_expert_group: int = 1
73
+ topk_group: int = 1
74
+ max_position_embeddings: int = 1048576
75
+ # [K3-1]
76
+ hidden_act: str = "situ"
77
+ activation_situ_beta: float = 1.0
78
+ activation_situ_linear_beta: Optional[float] = None
79
+ # [K3-2]
80
+ attn_res_block_size: Optional[int] = None
81
+ # [K3-3]
82
+ routed_expert_hidden_size: Optional[int] = None
83
+ latent_moe_use_norm: bool = False
84
+ # REAP-pruned checkpoints keep a different number of experts per layer
85
+ # (0 for dense layers). None => every MoE layer has `num_experts`.
86
+ expert_counts: Optional[List[int]] = None
87
+ # Graded REAP: per layer, how many of that layer's experts sit in the
88
+ # high-precision bank. 0 (or None) => a single uniform-width bank.
89
+ expert_bank_split: Optional[List[int]] = None
90
+ # multimodal wrapper fields (ignored by the text tower)
91
+ vision_config: Dict[str, Any] = field(default_factory=dict)
92
+
93
+ @classmethod
94
+ def from_dict(cls, params: Dict[str, Any]) -> "ModelArgs":
95
+ # K3's config.json nests the language model under `text_config`.
96
+ if "text_config" in params:
97
+ merged = dict(params["text_config"])
98
+ merged["model_type"] = params.get("model_type", "kimi_k3")
99
+ merged["vision_config"] = params.get("vision_config", {})
100
+ for k in ("bos_token_id", "eos_token_id", "pad_token_id"):
101
+ merged.setdefault(k, params.get(k))
102
+ params = merged
103
+ return super().from_dict(params)
104
+
105
+
106
+ # ---------------------------------------------------------------- [K3-1] SiTU
107
+
108
+
109
+ class SituGLU(nn.Module):
110
+ """beta*tanh(gate/beta)*sigmoid(gate) * linear_beta*tanh(up/linear_beta).
111
+
112
+ Matches SituAndMul in the reference, including the float32 upcast — the
113
+ tanh saturation points (beta=4, linear_beta=25) are far enough out that
114
+ bf16 rounding visibly changes the result.
115
+ """
116
+
117
+ def __init__(self, beta: float = 1.0, linear_beta: Optional[float] = None):
118
+ super().__init__()
119
+ self.beta = beta
120
+ self.linear_beta = linear_beta
121
+
122
+ def __call__(self, x_up: mx.array, x_gate: mx.array) -> mx.array:
123
+ dtype = x_up.dtype
124
+ gate = x_gate.astype(mx.float32)
125
+ up = x_up.astype(mx.float32)
126
+ out = self.beta * mx.tanh(gate / self.beta) * mx.sigmoid(gate)
127
+ if self.linear_beta is not None:
128
+ up = self.linear_beta * mx.tanh(up / self.linear_beta)
129
+ return (out * up).astype(dtype)
130
+
131
+
132
+ def _situ_from_args(args: ModelArgs) -> SituGLU:
133
+ return SituGLU(args.activation_situ_beta or 1.0, args.activation_situ_linear_beta)
134
+
135
+
136
+ class KimiMLP(nn.Module):
137
+ def __init__(
138
+ self,
139
+ args: ModelArgs,
140
+ hidden_size: Optional[int] = None,
141
+ intermediate_size: Optional[int] = None,
142
+ ):
143
+ super().__init__()
144
+ h = hidden_size or args.hidden_size
145
+ i = intermediate_size or args.intermediate_size
146
+ self.gate_proj = nn.Linear(h, i, bias=False)
147
+ self.up_proj = nn.Linear(h, i, bias=False)
148
+ self.down_proj = nn.Linear(i, h, bias=False)
149
+ self.act = _situ_from_args(args)
150
+
151
+ def __call__(self, x: mx.array) -> mx.array:
152
+ return self.down_proj(self.act(self.up_proj(x), self.gate_proj(x)))
153
+
154
+
155
+ # ------------------------------------------------------------- [K3-2] AttnRes
156
+
157
+
158
+ def _apply_attn_res(
159
+ prefix_sum: mx.array,
160
+ block_residual: mx.array,
161
+ proj_weight: mx.array,
162
+ norm_weight: mx.array,
163
+ eps: float,
164
+ ) -> mx.array:
165
+ """Softmax mixture over [block residuals..., prefix_sum].
166
+
167
+ prefix_sum: (N, H)
168
+ block_residual: (N, K, H) -> returns (N, H)
169
+
170
+ Each candidate is RMS-normed, scored against a single learned direction
171
+ (norm.weight * proj.weight, both length H), and the candidates are combined
172
+ by a softmax over K+1. All in float32, as in the reference.
173
+ """
174
+ v = mx.concatenate([block_residual, mx.expand_dims(prefix_sum, 1)], axis=1)
175
+ vf = v.astype(mx.float32)
176
+ variance = mx.mean(vf * vf, axis=-1, keepdims=True)
177
+ k = vf * mx.rsqrt(variance + eps)
178
+ score_weight = norm_weight.astype(mx.float32) * proj_weight.reshape(-1).astype(
179
+ mx.float32
180
+ )
181
+ scores = mx.sum(k * score_weight, axis=-1)
182
+ probs = mx.expand_dims(mx.softmax(scores, axis=-1), 1)
183
+ return (probs @ vf).squeeze(1).astype(v.dtype)
184
+
185
+
186
+ class AttnResProj(nn.Module):
187
+ """Holds the rank-1 [1, H] score projection and its paired RMSNorm gain.
188
+
189
+ Kept as plain arrays rather than nn.Linear/nn.RMSNorm so the quantizer skips
190
+ them — they are 7168 floats each and feed a softmax, so quantizing them is
191
+ all downside.
192
+ """
193
+
194
+ def __init__(self, hidden_size: int):
195
+ super().__init__()
196
+ self.proj_weight = mx.zeros((1, hidden_size))
197
+ self.norm_weight = mx.ones((hidden_size,))
198
+
199
+ def __call__(
200
+ self, prefix_sum: mx.array, block_residual: mx.array, eps: float
201
+ ) -> mx.array:
202
+ return _apply_attn_res(
203
+ prefix_sum, block_residual, self.proj_weight, self.norm_weight, eps
204
+ )
205
+
206
+
207
+ # ------------------------------------------------------ [K3-4] q-LoRA gated MLA
208
+
209
+
210
+ class KimiMLAAttention(nn.Module):
211
+ def __init__(self, args: ModelArgs):
212
+ super().__init__()
213
+ self.args = args
214
+ self.num_heads = args.num_attention_heads
215
+ self.qk_nope_head_dim = args.qk_nope_head_dim or 128
216
+ self.qk_rope_head_dim = args.qk_rope_head_dim or 0
217
+ self.q_head_dim = self.qk_nope_head_dim + self.qk_rope_head_dim
218
+ self.v_head_dim = args.v_head_dim or 128
219
+ self.kv_lora_rank = args.kv_lora_rank
220
+ self.q_lora_rank = args.q_lora_rank
221
+ self.scale = self.q_head_dim**-0.5
222
+
223
+ h = args.hidden_size
224
+ if self.q_lora_rank is not None:
225
+ self.q_a_proj = nn.Linear(h, self.q_lora_rank, bias=False)
226
+ self.q_a_layernorm = nn.RMSNorm(self.q_lora_rank, eps=args.rms_norm_eps)
227
+ self.q_b_proj = nn.Linear(
228
+ self.q_lora_rank, self.num_heads * self.q_head_dim, bias=False
229
+ )
230
+ else:
231
+ self.q_proj = nn.Linear(h, self.num_heads * self.q_head_dim, bias=False)
232
+
233
+ self.kv_a_proj_with_mqa = nn.Linear(
234
+ h, self.kv_lora_rank + self.qk_rope_head_dim, bias=False
235
+ )
236
+ self.kv_a_layernorm = nn.RMSNorm(self.kv_lora_rank, eps=args.rms_norm_eps)
237
+
238
+ # kv_b_proj is absorbed into these two at load time (see sanitize), so the
239
+ # KV cache holds the 512-d latent instead of 96 full heads. At 1M context
240
+ # that is the difference between ~110 GB and ~1.2 GB of cache per layer.
241
+ self.embed_q = MultiLinear(self.qk_nope_head_dim, self.kv_lora_rank, self.num_heads)
242
+ self.unembed_out = MultiLinear(self.kv_lora_rank, self.v_head_dim, self.num_heads)
243
+
244
+ self.o_proj = nn.Linear(self.num_heads * self.v_head_dim, h, bias=False)
245
+
246
+ self.use_output_gate = args.mla_use_output_gate
247
+ if self.use_output_gate:
248
+ self.g_proj = nn.Linear(h, self.num_heads * self.v_head_dim, bias=False)
249
+
250
+ def __call__(
251
+ self,
252
+ x: mx.array,
253
+ mask: Optional[mx.array] = None,
254
+ cache: Optional[KVCache] = None,
255
+ ) -> mx.array:
256
+ B, L, _ = x.shape
257
+
258
+ if self.q_lora_rank is not None:
259
+ q = self.q_b_proj(self.q_a_layernorm(self.q_a_proj(x)))
260
+ else:
261
+ q = self.q_proj(x)
262
+ q = q.reshape(B, L, self.num_heads, self.q_head_dim).transpose(0, 2, 1, 3)
263
+ q_nope, q_pe = mx.split(q, [self.qk_nope_head_dim], axis=-1)
264
+
265
+ compressed_kv = self.kv_a_proj_with_mqa(x)
266
+ compressed_kv, k_pe = mx.split(compressed_kv, [self.kv_lora_rank], axis=-1)
267
+ k_pe = k_pe.reshape(B, L, 1, self.qk_rope_head_dim).transpose(0, 2, 1, 3)
268
+ kv_latent = mx.expand_dims(self.kv_a_layernorm(compressed_kv), axis=1)
269
+
270
+ if cache is not None:
271
+ kv_latent, k_pe = cache.update_and_fetch(kv_latent, k_pe)
272
+
273
+ # NoPE: q_pe/k_pe carry no rotary embedding at all (the reference asserts
274
+ # use_nope and leaves rotary_emb=None). They are a plain extra 64 dims,
275
+ # folded in as an additive score term alongside the causal mask.
276
+ pe_scores = (q_pe * self.scale) @ k_pe.swapaxes(-1, -2)
277
+ if mask is not None:
278
+ pe_scores = mx.where(
279
+ mask, pe_scores, mx.array(mx.finfo(pe_scores.dtype).min, pe_scores.dtype)
280
+ )
281
+
282
+ if L == 1:
283
+ q_nope = self.embed_q(q_nope)
284
+ k = v = kv_latent
285
+ else:
286
+ k = self.embed_q(kv_latent, transpose=False)
287
+ v = self.unembed_out(kv_latent)
288
+
289
+ out = scaled_dot_product_attention(
290
+ q_nope, k, v, cache=cache, scale=self.scale, mask=pe_scores
291
+ )
292
+ if L == 1:
293
+ out = self.unembed_out(out)
294
+
295
+ out = out.transpose(0, 2, 1, 3).reshape(B, L, -1)
296
+ if self.use_output_gate:
297
+ out = out * mx.sigmoid(self.g_proj(x))
298
+ return self.o_proj(out)
299
+
300
+
301
+ # ------------------------------------------------------------- [K3-5] KDA
302
+
303
+
304
+ class ShortConv1d(nn.Module):
305
+ def __init__(self, channels: int, kernel_size: int):
306
+ super().__init__()
307
+ self.kernel_size = kernel_size
308
+ self.conv = nn.Conv1d(
309
+ in_channels=channels,
310
+ out_channels=channels,
311
+ kernel_size=kernel_size,
312
+ groups=channels,
313
+ padding=0,
314
+ bias=False,
315
+ )
316
+
317
+ def __call__(self, x, state, mask=None, lengths=None):
318
+ if mask is not None and state is not None:
319
+ x = x * mx.expand_dims(mask, -1) if mask.ndim == 2 else x
320
+ conv_input = mx.concatenate([state, x], axis=1)
321
+ new_state = conv_input[:, -(self.kernel_size - 1) :, :]
322
+ return nn.silu(self.conv(conv_input)), new_state
323
+
324
+
325
+ class KimiDeltaAttention(nn.Module):
326
+ def __init__(self, args: ModelArgs, layer_idx: int):
327
+ super().__init__()
328
+ cfg = args.linear_attn_config
329
+ self.layer_idx = layer_idx
330
+ self.num_heads = cfg["num_heads"]
331
+ self.head_dim = cfg["head_dim"]
332
+ self.conv_kernel = cfg.get("short_conv_kernel_size", 4)
333
+ self.gate_lower_bound = cfg.get("gate_lower_bound", None)
334
+ self.use_full_rank_gate = cfg.get("use_full_rank_gate", False)
335
+
336
+ self.projection_dim = self.num_heads * self.head_dim
337
+ h = args.hidden_size
338
+ self.scale = float(self.head_dim) ** -0.5
339
+
340
+ self.q_proj = nn.Linear(h, self.projection_dim, bias=False)
341
+ self.k_proj = nn.Linear(h, self.projection_dim, bias=False)
342
+ self.v_proj = nn.Linear(h, self.projection_dim, bias=False)
343
+
344
+ self.q_conv = ShortConv1d(self.projection_dim, self.conv_kernel)
345
+ self.k_conv = ShortConv1d(self.projection_dim, self.conv_kernel)
346
+ self.v_conv = ShortConv1d(self.projection_dim, self.conv_kernel)
347
+
348
+ self.f_a_proj = nn.Linear(h, self.head_dim, bias=False)
349
+ self.f_b_proj = nn.Linear(self.head_dim, self.projection_dim, bias=False)
350
+ self.b_proj = nn.Linear(h, self.num_heads, bias=False)
351
+
352
+ # [K3] full-rank output gate (use_full_rank_gate=True) replaces the
353
+ # low-rank g_a/g_b pair used by Kimi-Linear-48B.
354
+ if self.use_full_rank_gate:
355
+ self.g_proj = nn.Linear(h, self.projection_dim, bias=False)
356
+ else:
357
+ self.g_a_proj = nn.Linear(h, self.head_dim, bias=False)
358
+ self.g_b_proj = nn.Linear(self.head_dim, self.projection_dim, bias=False)
359
+
360
+ # [K3-5] per-channel, shared across heads -> broadcast as (1, head_dim).
361
+ self.A_log = mx.zeros((self.head_dim,))
362
+ self.dt_bias = mx.zeros((self.projection_dim,))
363
+
364
+ self.o_norm = nn.RMSNorm(self.head_dim, eps=args.rms_norm_eps)
365
+ self.o_proj = nn.Linear(self.projection_dim, h, bias=False)
366
+
367
+ def _compute_g(self, a: mx.array) -> mx.array:
368
+ # log-decay, then the safe-gate clamp (fla's `lower_bound`), then exp.
369
+ log_g = -mx.exp(self.A_log.reshape(1, self.head_dim).astype(mx.float32)) * nn.softplus(
370
+ a + self.dt_bias.reshape(self.num_heads, self.head_dim)
371
+ )
372
+ if self.gate_lower_bound is not None:
373
+ log_g = mx.maximum(log_g, self.gate_lower_bound)
374
+ return mx.exp(log_g)
375
+
376
+ def __call__(self, x, mask=None, cache=None):
377
+ B, T, _ = x.shape
378
+ dtype = x.dtype
379
+
380
+ if cache is not None:
381
+ q_state, k_state, v_state, ssm_state = cache
382
+ lengths = cache.lengths
383
+ else:
384
+ q_state = k_state = v_state = ssm_state = None
385
+ lengths = None
386
+
387
+ if q_state is None:
388
+ s = mx.zeros((B, self.conv_kernel - 1, self.projection_dim), dtype=dtype)
389
+ q_state = k_state = v_state = s
390
+
391
+ q_conv, q_state = self.q_conv(self.q_proj(x), q_state, mask, lengths)
392
+ k_conv, k_state = self.k_conv(self.k_proj(x), k_state, mask, lengths)
393
+ v_conv, v_state = self.v_conv(self.v_proj(x), v_state, mask, lengths)
394
+
395
+ if cache is not None:
396
+ cache[0], cache[1], cache[2] = q_state, k_state, v_state
397
+
398
+ q = q_conv.reshape(B, T, self.num_heads, self.head_dim)
399
+ k = k_conv.reshape(B, T, self.num_heads, self.head_dim)
400
+ v = v_conv.reshape(B, T, self.num_heads, self.head_dim)
401
+
402
+ # use_qk_l2norm_in_kernel=True in the reference
403
+ q = (self.scale**2) * mx.fast.rms_norm(q, None, 1e-6)
404
+ k = self.scale * mx.fast.rms_norm(k, None, 1e-6)
405
+
406
+ a_logits = self.f_b_proj(self.f_a_proj(x)).reshape(
407
+ B, T, self.num_heads, self.head_dim
408
+ )
409
+ beta = mx.sigmoid(self.b_proj(x).reshape(B, T, self.num_heads))
410
+ g = self._compute_g(a_logits)
411
+
412
+ if ssm_state is None:
413
+ ssm_state = mx.zeros(
414
+ (B, self.num_heads, self.head_dim, self.head_dim), dtype=mx.float32
415
+ )
416
+ use_kernel = mx.default_device() == mx.gpu and mx.metal.is_available()
417
+ fn = gated_delta_kernel if use_kernel else gated_delta_ops
418
+ out, ssm_state = fn(q, k, v, g, beta, ssm_state, mask)
419
+
420
+ if cache is not None:
421
+ cache[3] = ssm_state
422
+ cache.advance(T)
423
+
424
+ if self.use_full_rank_gate:
425
+ gate = self.g_proj(x)
426
+ else:
427
+ gate = self.g_b_proj(self.g_a_proj(x))
428
+ gate = gate.reshape(B, T, self.num_heads, self.head_dim)
429
+ out = (
430
+ self.o_norm(out.reshape(B, T, self.num_heads, self.head_dim))
431
+ * mx.sigmoid(gate)
432
+ ).reshape(B, T, -1)
433
+ return self.o_proj(out)
434
+
435
+
436
+ # --------------------------------------------------------- [K3-3] LatentMoE
437
+
438
+
439
+ def _group_expert_select(
440
+ scores: mx.array,
441
+ bias: mx.array,
442
+ top_k: int,
443
+ num_expert_group: int,
444
+ topk_group: int,
445
+ routed_scaling_factor: float,
446
+ renormalize: bool,
447
+ activation: str,
448
+ ):
449
+ if activation == "sigmoid":
450
+ scores = mx.sigmoid(scores.astype(mx.float32))
451
+ else:
452
+ scores = mx.softmax(scores.astype(mx.float32), axis=-1)
453
+
454
+ orig = scores
455
+ scores = scores + bias
456
+ if num_expert_group > 1 and num_expert_group > topk_group:
457
+ k = scores.shape[-1] // num_expert_group
458
+ scores = mx.unflatten(scores, -1, (num_expert_group, k))
459
+ group_scores = mx.topk(scores, 2, axis=-1).sum(axis=-1, keepdims=True)
460
+ gate = mx.argpartition(-group_scores, kth=topk_group - 1, axis=-2)
461
+ gate = mx.put_along_axis(
462
+ mx.ones_like(group_scores),
463
+ gate[..., :topk_group, :],
464
+ mx.zeros((1,), scores.dtype),
465
+ axis=-2,
466
+ )
467
+ scores = mx.flatten(scores - gate * mx.finfo(scores.dtype).max, -2, -1)
468
+
469
+ inds = mx.stop_gradient(mx.argpartition(-scores, kth=top_k - 1, axis=-1)[..., :top_k])
470
+ weights = mx.take_along_axis(orig, inds, axis=-1)
471
+ if top_k > 1 and renormalize:
472
+ weights = weights / (mx.sum(weights, axis=-1, keepdims=True) + 1e-20)
473
+ weights = weights * routed_scaling_factor
474
+ return inds, weights.astype(orig.dtype)
475
+
476
+
477
+ class TwoBankSwitchGLU(nn.Module):
478
+ """SwitchGLU whose experts are split into two banks at different bit widths.
479
+
480
+ Experts [0, n_hi) live in `bank_hi`, [n_hi, n_hi+n_lo) in `bank_lo`. This
481
+ exists because MLX pins one bit width per expert tensor: QuantizedSwitchLinear
482
+ stores scalar bits/group_size/mode and gather_qmm takes them as scalars. Two
483
+ modules means two widths, which is what graded REAP needs -- most-salient
484
+ experts at mxfp4 (free on K3, it is the source encoding), the rest lower.
485
+
486
+ The naive way to do this costs 2x expert compute: run both banks over every
487
+ routed pair and select. Instead we exploit the sort SwitchGLU already
488
+ performs. After sorting (token, slot) pairs by expert index, a bank owning a
489
+ contiguous index range owns a contiguous *slice* of the sorted pairs, so
490
+ each bank does exactly its own share of the work and no more.
491
+
492
+ Cost: the split point is data-dependent, so it must be read back to the
493
+ host to slice on. Measured at K3 REAP dims (3584->3072, 240 experts,
494
+ top-16), that sync is essentially the *entire* overhead -- the second
495
+ dispatch is nearly free:
496
+
497
+ prefill (256 tok) 1.05x vs single-bank
498
+ decode (1 tok) 1.16x
499
+
500
+ Decode routes only top_k pairs, too few to amortise two kernel launches
501
+ plus a device sync, so below PARTITION_ON_HOST pairs we pull the indices
502
+ down in one transfer and partition with numpy instead. That alone took
503
+ decode from 1.44x to 1.16x.
504
+ """
505
+
506
+ # Below this many (token, slot) pairs, partition on the host.
507
+ PARTITION_ON_HOST = 1024
508
+
509
+ def __init__(self, input_dims, hidden_dims, n_hi, n_lo, activation):
510
+ super().__init__()
511
+ if n_hi <= 0 or n_lo <= 0:
512
+ raise ValueError(f"both banks must be non-empty, got {n_hi}/{n_lo}")
513
+ self.n_hi, self.n_lo = n_hi, n_lo
514
+ self.bank_hi = SwitchGLU(input_dims, hidden_dims, n_hi, activation=activation)
515
+ self.bank_lo = SwitchGLU(input_dims, hidden_dims, n_lo, activation=activation)
516
+
517
+ @property
518
+ def num_experts(self):
519
+ return self.n_hi + self.n_lo
520
+
521
+ @staticmethod
522
+ def _run(bank, xp, idx):
523
+ """One bank over an already-sorted slice of pairs."""
524
+ up = bank.up_proj(xp, idx, sorted_indices=True)
525
+ gate = bank.gate_proj(xp, idx, sorted_indices=True)
526
+ return bank.down_proj(bank.activation(up, gate), idx, sorted_indices=True)
527
+
528
+ def __call__(self, x: mx.array, indices: mx.array) -> mx.array:
529
+ lead = indices.shape[:-1]
530
+ K = indices.shape[-1]
531
+ flat = indices.reshape(-1)
532
+
533
+ total = flat.size
534
+ if total <= self.PARTITION_ON_HOST:
535
+ # one host transfer beats argsort-kernel + sum-kernel + sync
536
+ host = np.array(flat.tolist(), dtype=np.int32)
537
+ order = mx.array(np.argsort(host, kind="stable"))
538
+ split = int((host < self.n_hi).sum())
539
+ else:
540
+ order = mx.argsort(flat)
541
+ split = int(mx.sum(flat < self.n_hi))
542
+
543
+ inv_order = mx.argsort(order)
544
+ sorted_idx = flat[order]
545
+ # one row per (token, slot) pair, in expert order
546
+ pairs = mx.expand_dims(x, (-2, -3)).flatten(0, -3)[order // K]
547
+
548
+ if split == 0:
549
+ out = self._run(self.bank_lo, pairs, sorted_idx - self.n_hi)
550
+ elif split == total:
551
+ out = self._run(self.bank_hi, pairs, sorted_idx)
552
+ else:
553
+ out = mx.concatenate(
554
+ [
555
+ self._run(self.bank_hi, pairs[:split], sorted_idx[:split]),
556
+ self._run(self.bank_lo, pairs[split:], sorted_idx[split:] - self.n_hi),
557
+ ],
558
+ axis=0,
559
+ )
560
+
561
+ return mx.unflatten(out[inv_order], 0, (*lead, K)).squeeze(-2)
562
+
563
+
564
+ class KimiSparseMoE(nn.Module):
565
+ """LatentMoE: 896 experts run in a 3584-d latent, not the 7168-d residual.
566
+
567
+ hidden -> down_proj -> [top-16 of 896 experts] -> norm -> up_proj -> hidden
568
+ plus 2 shared experts applied to the *undown-projected* input.
569
+ """
570
+
571
+ def __init__(
572
+ self,
573
+ args: ModelArgs,
574
+ num_experts: Optional[int] = None,
575
+ bank_split: int = 0,
576
+ ):
577
+ super().__init__()
578
+ self.args = args
579
+ h = args.hidden_size
580
+ self.num_experts = num_experts if num_experts is not None else args.num_experts
581
+ self.bank_split = bank_split
582
+ self.use_latent = args.routed_expert_hidden_size is not None
583
+ self.moe_hidden = args.routed_expert_hidden_size if self.use_latent else h
584
+ self.use_norm = args.latent_moe_use_norm
585
+
586
+ self.gate = nn.Linear(h, self.num_experts, bias=False)
587
+ self.e_score_correction_bias = mx.zeros((self.num_experts,), dtype=mx.float32)
588
+ if bank_split:
589
+ self.switch_mlp = TwoBankSwitchGLU(
590
+ self.moe_hidden,
591
+ args.moe_intermediate_size,
592
+ bank_split,
593
+ self.num_experts - bank_split,
594
+ activation=_situ_from_args(args),
595
+ )
596
+ else:
597
+ self.switch_mlp = SwitchGLU(
598
+ self.moe_hidden,
599
+ args.moe_intermediate_size,
600
+ self.num_experts,
601
+ activation=_situ_from_args(args),
602
+ )
603
+
604
+ if self.use_latent:
605
+ self.routed_expert_down_proj = nn.Linear(h, self.moe_hidden, bias=False)
606
+ self.routed_expert_up_proj = nn.Linear(self.moe_hidden, h, bias=False)
607
+ if self.use_norm:
608
+ self.routed_expert_norm = nn.RMSNorm(
609
+ self.moe_hidden, eps=args.rms_norm_eps
610
+ )
611
+
612
+ if args.num_shared_experts:
613
+ self.shared_experts = KimiMLP(
614
+ args, intermediate_size=args.moe_intermediate_size * args.num_shared_experts
615
+ )
616
+ else:
617
+ self.shared_experts = None
618
+
619
+ # Optional REAP saliency tap (scripts/reap_calibrate.py). Left as None in
620
+ # normal use and costs nothing. It exists so the calibration harness reads
621
+ # gates and per-expert outputs from *this* forward pass rather than a
622
+ # reimplementation that could drift from it. MLX keeps non-array
623
+ # attributes out of parameters()/children(), so this never touches
624
+ # quantization, saving or loading.
625
+ self.expert_stats_hook = None
626
+
627
+ def __call__(self, x: mx.array) -> mx.array:
628
+ identity = x
629
+ inds, weights = _group_expert_select(
630
+ self.gate(x),
631
+ self.e_score_correction_bias,
632
+ min(self.args.num_experts_per_token, self.num_experts),
633
+ self.args.num_expert_group,
634
+ self.args.topk_group,
635
+ self.args.routed_scaling_factor,
636
+ self.args.moe_renormalize,
637
+ self.args.moe_router_activation_func,
638
+ )
639
+ y = self.routed_expert_down_proj(x) if self.use_latent else x
640
+ y = self.switch_mlp(y, inds)
641
+ if self.expert_stats_hook is not None:
642
+ # y here is the per-expert output BEFORE gate weighting, shape
643
+ # (..., top_k, moe_hidden) -- exactly what REAP saliency needs.
644
+ self.expert_stats_hook(inds, weights, y)
645
+ y = (y * weights[..., None]).sum(axis=-2)
646
+ if self.use_latent:
647
+ if self.use_norm:
648
+ y = self.routed_expert_norm(y)
649
+ y = self.routed_expert_up_proj(y)
650
+ if self.shared_experts is not None:
651
+ y = y + self.shared_experts(identity)
652
+ return y
653
+
654
+
655
+ # ------------------------------------------------- checkpoint key remapping
656
+ #
657
+ # Single source of truth, shared by Model.sanitize (whole-checkpoint, used when
658
+ # mlx-lm loads a converted repo) and scripts/convert.py (per-layer, used when
659
+ # streaming the 1.56 TB source). Keeping one implementation is the point: the
660
+ # streaming converter never instantiates a Model, so a second copy of these
661
+ # rules would be free to drift out of sync and silently mis-place weights.
662
+
663
+
664
+ def is_kda_layer(args: ModelArgs, idx: int) -> bool:
665
+ # config lists kda_layers/full_attn_layers 1-BASED: config n -> layer n-1.
666
+ return (idx + 1) in args.linear_attn_config["kda_layers"]
667
+
668
+
669
+ def is_moe_layer(args: ModelArgs, idx: int) -> bool:
670
+ return (
671
+ args.num_experts > 0
672
+ and idx >= args.first_k_dense_replace
673
+ and idx % args.moe_layer_freq == 0
674
+ )
675
+
676
+
677
+ def experts_in_layer(args: ModelArgs, idx: int) -> int:
678
+ """Expert count for one layer — varies after REAP pruning."""
679
+ if args.expert_counts:
680
+ return int(args.expert_counts[idx])
681
+ return args.num_experts
682
+
683
+
684
+ def bank_split_in_layer(args: ModelArgs, idx: int) -> int:
685
+ """How many of a layer's experts sit in the high-precision bank (0 = one bank)."""
686
+ if not args.expert_bank_split:
687
+ return 0
688
+ return int(args.expert_bank_split[idx])
689
+
690
+
691
+ def remap_checkpoint(
692
+ weights: Dict[str, mx.array],
693
+ args: ModelArgs,
694
+ layer_indices: Optional[List[int]] = None,
695
+ stack_experts: bool = True,
696
+ ) -> Dict[str, mx.array]:
697
+ """Map Kimi-K3 checkpoint keys onto this module tree.
698
+
699
+ `layer_indices` restricts the layer loop (the converter passes the single
700
+ layer it is holding). `stack_experts=False` leaves per-expert tensors alone
701
+ so the converter can quantize each expert before stacking, which keeps peak
702
+ memory at ~6 GB per layer instead of ~59 GB.
703
+ """
704
+ out: Dict[str, mx.array] = {}
705
+ for k, v in weights.items():
706
+ # K3 wraps the text tower as `language_model.*`; the vision tower and
707
+ # projector are handled outside mlx-lm.
708
+ if k.startswith("vision_tower.") or k.startswith("mm_projector."):
709
+ continue
710
+ out[k[len("language_model.") :] if k.startswith("language_model.") else k] = v
711
+ weights = out
712
+
713
+ if args.tie_word_embeddings:
714
+ weights.pop("lm_head.weight", None)
715
+
716
+ if layer_indices is None:
717
+ layer_indices = range(args.num_hidden_layers)
718
+
719
+ for i in layer_indices:
720
+ p = f"model.layers.{i}"
721
+
722
+ # [K3-2] AttnRes: fold `*_res_proj` / `*_res_norm` into the AttnResProj
723
+ # containers so the quantizer leaves them alone.
724
+ for src in ("self_attention_res", "mlp_res"):
725
+ for suffix, field_name in (("proj", "proj_weight"), ("norm", "norm_weight")):
726
+ key = f"{p}.{src}_{suffix}.weight"
727
+ if key in weights:
728
+ weights[f"{p}.{src}.{field_name}"] = weights.pop(key)
729
+
730
+ if is_moe_layer(args, i):
731
+ src = f"{p}.block_sparse_moe"
732
+ if stack_experts:
733
+ for w, dst in (("w1", "gate_proj"), ("w3", "up_proj"), ("w2", "down_proj")):
734
+ if f"{src}.experts.0.{w}.weight" in weights:
735
+ weights[f"{src}.switch_mlp.{dst}.weight"] = mx.stack(
736
+ [
737
+ weights.pop(f"{src}.experts.{e}.{w}.weight")
738
+ for e in range(experts_in_layer(args, i))
739
+ ]
740
+ )
741
+ bias_key = f"{src}.gate.e_score_correction_bias"
742
+ if bias_key in weights:
743
+ weights[f"{src}.e_score_correction_bias"] = weights.pop(bias_key)
744
+
745
+ attn = f"{p}.self_attn"
746
+ if is_kda_layer(args, i):
747
+ for src, dst in (("q_conv1d", "q_conv"), ("k_conv1d", "k_conv"), ("v_conv1d", "v_conv")):
748
+ key = f"{attn}.{src}.weight"
749
+ if key in weights:
750
+ w = weights.pop(key)
751
+ if w.ndim == 3:
752
+ w = w.moveaxis(2, 1)
753
+ weights[f"{attn}.{dst}.conv.weight"] = w
754
+ dt = f"{attn}.dt_bias"
755
+ if dt in weights and weights[dt].ndim > 1:
756
+ weights[dt] = weights[dt].reshape(-1)
757
+ else:
758
+ # [K3-4] absorb kv_b_proj -> embed_q / unembed_out so the KV cache
759
+ # stores the 512-d latent rather than 96 materialised heads.
760
+ kv_b = f"{attn}.kv_b_proj.weight"
761
+ if kv_b in weights:
762
+ qk_nope = args.qk_nope_head_dim or 128
763
+ v_head = args.v_head_dim or 128
764
+ v = weights.pop(kv_b).reshape(args.num_attention_heads, qk_nope + v_head, -1)
765
+ weights[f"{attn}.embed_q.weight"] = mx.contiguous(
766
+ v[:, :qk_nope, :].swapaxes(-1, -2)
767
+ )
768
+ weights[f"{attn}.unembed_out.weight"] = mx.contiguous(v[:, qk_nope:, :])
769
+
770
+ for suffix, field_name in (("proj", "proj_weight"), ("norm", "norm_weight")):
771
+ key = f"model.output_attn_res_{suffix}.weight"
772
+ if key in weights:
773
+ weights[f"model.output_attn_res.{field_name}"] = weights.pop(key)
774
+
775
+ return weights
776
+
777
+
778
+ # ------------------------------------------------------------------- layers
779
+
780
+
781
+ class KimiDecoderLayer(nn.Module):
782
+ def __init__(self, args: ModelArgs, layer_idx: int):
783
+ super().__init__()
784
+ self.args = args
785
+ self.layer_idx = layer_idx
786
+ # config lists full_attn_layers/kda_layers 1-BASED: config n -> layer n-1.
787
+ kda_layers = args.linear_attn_config["kda_layers"]
788
+ self.is_linear = (layer_idx + 1) in kda_layers
789
+
790
+ if self.is_linear:
791
+ self.self_attn = KimiDeltaAttention(args, layer_idx)
792
+ else:
793
+ self.self_attn = KimiMLAAttention(args)
794
+
795
+ is_moe = (
796
+ args.num_experts > 0
797
+ and layer_idx >= args.first_k_dense_replace
798
+ and layer_idx % args.moe_layer_freq == 0
799
+ )
800
+ if is_moe:
801
+ self.block_sparse_moe = KimiSparseMoE(
802
+ args,
803
+ experts_in_layer(args, layer_idx),
804
+ bank_split_in_layer(args, layer_idx),
805
+ )
806
+ else:
807
+ self.mlp = KimiMLP(args)
808
+ self.is_moe = is_moe
809
+
810
+ self.input_layernorm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
811
+ self.post_attention_layernorm = nn.RMSNorm(
812
+ args.hidden_size, eps=args.rms_norm_eps
813
+ )
814
+
815
+ self.use_attn_res = args.attn_res_block_size is not None
816
+ if self.use_attn_res:
817
+ self.attn_res_block_size = args.attn_res_block_size
818
+ self.self_attention_res = AttnResProj(args.hidden_size)
819
+ self.mlp_res = AttnResProj(args.hidden_size)
820
+
821
+ def _ffn(self, x: mx.array) -> mx.array:
822
+ return self.block_sparse_moe(x) if self.is_moe else self.mlp(x)
823
+
824
+ def __call__(self, x, mask=None, cache=None, block_residual=None):
825
+ if not self.use_attn_res:
826
+ h = x + self.self_attn(self.input_layernorm(x), mask, cache)
827
+ return h + self._ffn(self.post_attention_layernorm(h)), block_residual
828
+
829
+ B, T, H = x.shape
830
+ eps = self.args.rms_norm_eps
831
+ prefix_sum = x
832
+
833
+ if block_residual is not None and block_residual.shape[1] > 0:
834
+ x = self.self_attention_res(
835
+ prefix_sum.reshape(-1, H), block_residual, eps
836
+ ).reshape(B, T, H)
837
+
838
+ pushed = self.layer_idx % self.attn_res_block_size == 0
839
+ if pushed:
840
+ block_residual = mx.concatenate(
841
+ [block_residual, mx.expand_dims(prefix_sum.reshape(-1, H), 1)], axis=1
842
+ )
843
+
844
+ y = self.self_attn(self.input_layernorm(x), mask, cache)
845
+ prefix_sum = y if pushed else prefix_sum + y
846
+
847
+ x = self.mlp_res(prefix_sum.reshape(-1, H), block_residual, eps).reshape(B, T, H)
848
+ z = self._ffn(self.post_attention_layernorm(x))
849
+ return prefix_sum + z, block_residual
850
+
851
+
852
+ class KimiK3Model(nn.Module):
853
+ def __init__(self, args: ModelArgs):
854
+ super().__init__()
855
+ self.args = args
856
+ self.embed_tokens = nn.Embedding(args.vocab_size, args.hidden_size)
857
+ self.layers = [KimiDecoderLayer(args, i) for i in range(args.num_hidden_layers)]
858
+ self.norm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
859
+
860
+ self.use_attn_res = args.attn_res_block_size is not None
861
+ if self.use_attn_res:
862
+ self.output_attn_res = AttnResProj(args.hidden_size)
863
+
864
+ kda_layers = args.linear_attn_config["kda_layers"]
865
+ self.ssm_idx = kda_layers[0] - 1
866
+ self.attn_idx = next(
867
+ i for i in range(len(self.layers)) if (i + 1) not in kda_layers
868
+ )
869
+
870
+ def __call__(
871
+ self,
872
+ inputs: mx.array,
873
+ cache: Optional[List[Any]] = None,
874
+ inputs_embeds: Optional[mx.array] = None,
875
+ ) -> mx.array:
876
+ # inputs_embeds lets the multimodal wrapper splice image features in
877
+ # place of the <|media_pad|> placeholder before the stack runs.
878
+ h = self.embed_tokens(inputs) if inputs_embeds is None else inputs_embeds
879
+ if cache is None:
880
+ cache = [None] * len(self.layers)
881
+
882
+ ssm_mask = create_ssm_mask(h, cache[self.ssm_idx])
883
+ attn_mask = create_attention_mask(h, cache[self.attn_idx], return_array=True)
884
+
885
+ B, T, H = h.shape
886
+ block_residual = mx.zeros((B * T, 0, H), dtype=h.dtype) if self.use_attn_res else None
887
+
888
+ for layer, layer_cache in zip(self.layers, cache):
889
+ mask = ssm_mask if layer.is_linear else attn_mask
890
+ h, block_residual = layer(h, mask, layer_cache, block_residual)
891
+
892
+ if self.use_attn_res:
893
+ h = self.output_attn_res(
894
+ h.reshape(-1, H), block_residual, self.args.rms_norm_eps
895
+ ).reshape(B, T, H)
896
+ return self.norm(h)
897
+
898
+
899
+ class Model(nn.Module):
900
+ def __init__(self, args: ModelArgs):
901
+ super().__init__()
902
+ self.args = args
903
+ self.model_type = args.model_type
904
+ self.model = KimiK3Model(args)
905
+ self.lm_head = (
906
+ None
907
+ if args.tie_word_embeddings
908
+ else nn.Linear(args.hidden_size, args.vocab_size, bias=False)
909
+ )
910
+
911
+ def __call__(
912
+ self,
913
+ inputs: mx.array,
914
+ cache: Optional[List[Any]] = None,
915
+ inputs_embeds: Optional[mx.array] = None,
916
+ ) -> mx.array:
917
+ out = self.model(inputs, cache, inputs_embeds=inputs_embeds)
918
+ if self.lm_head is None:
919
+ return self.model.embed_tokens.as_linear(out)
920
+ return self.lm_head(out)
921
+
922
+ @property
923
+ def layers(self):
924
+ return self.model.layers
925
+
926
+ def make_cache(self):
927
+ return [
928
+ ArraysCache(size=4) if layer.is_linear else KVCache()
929
+ for layer in self.layers
930
+ ]
931
+
932
+ def sanitize(self, weights: Dict[str, mx.array]) -> Dict[str, mx.array]:
933
+ return remap_checkpoint(weights, self.args)
934
+
935
+ @property
936
+ def cast_predicate(self):
937
+ def predicate(path: str):
938
+ if "e_score_correction_bias" in path:
939
+ return False
940
+ if path.endswith("A_log") or path.endswith("dt_bias"):
941
+ return False
942
+ return True
943
+
944
+ return predicate
945
+
946
+ @property
947
+ def quant_predicate(self):
948
+ def predicate(path, _):
949
+ # router gate drives top-16-of-896 selection; 8 bits is cheap insurance
950
+ if path.endswith("block_sparse_moe.gate"):
951
+ return {"group_size": 64, "bits": 8}
952
+ return True
953
+
954
+ return predicate
kimi_k3_processor.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Kimi-K3 processor: wraps vision processor + tokenizer into a single interface.
2
+
3
+ Chat rendering (including XTML tool-result ordering) is handled by the
4
+ tokenizer's Python encoder; this processor adds multimodal media preprocessing.
5
+ """
6
+
7
+ from transformers.feature_extraction_utils import BatchFeature
8
+ from transformers.processing_utils import ProcessorMixin
9
+ from transformers.utils import logging
10
+
11
+ from .media_utils import ensure_media_type
12
+
13
+ logger = logging.get_logger(__name__)
14
+
15
+ # ── KimiK3Processor ───────────────────────────────────────────────────
16
+
17
+
18
+ class KimiK3Processor(ProcessorMixin):
19
+ r"""
20
+ Constructs a KimiK3 processor which wraps a KimiK3 image processor
21
+ and a tokenizer into a single processor.
22
+
23
+ [`KimiK3Processor`] offers all the functionalities of
24
+ [`KimiK3VisionProcessor`] and [`TikTokenTokenizer`].
25
+
26
+ Args:
27
+ image_processor ([`KimiK3VisionProcessor`], *optional*):
28
+ The image processor is a required input.
29
+ tokenizer ([`TikTokenTokenizer`], *optional*):
30
+ The tokenizer is a required input.
31
+ chat_template (`str`, *optional*): Kept for ProcessorMixin
32
+ compatibility. Kimi K3 chat encoding is implemented in Python by
33
+ the tokenizer.
34
+ """
35
+
36
+ attributes = ["image_processor", "tokenizer"]
37
+ valid_kwargs = ["chat_template"]
38
+ image_processor_class = "AutoImageProcessor"
39
+ tokenizer_class = "AutoTokenizer"
40
+
41
+ def __init__(
42
+ self,
43
+ image_processor=None,
44
+ tokenizer=None,
45
+ chat_template=None,
46
+ **kwargs,
47
+ ):
48
+ super().__init__(image_processor,
49
+ tokenizer,
50
+ chat_template=chat_template)
51
+ self.media_processor = image_processor
52
+ self.image_placeholder = "<|kimi_image_placeholder|>"
53
+
54
+ # ── Media preprocessing ────────────────────────────────────────────
55
+
56
+ def update_raw_text(self, text: str, image_prompts: list[str]) -> str:
57
+ # Replace image placeholders
58
+ image_count = text.count(self.image_placeholder)
59
+ if image_count > 0:
60
+ assert image_count == len(image_prompts), (
61
+ f"image placeholder count {image_count} != "
62
+ f"image_prompts count {len(image_prompts)}")
63
+ text_parts = text.split(self.image_placeholder)
64
+ assert len(text_parts) == len(image_prompts) + 1
65
+ text = "".join([
66
+ text_parts[i] + image_prompts[i]
67
+ for i in range(len(image_prompts))
68
+ ])
69
+ text += text_parts[-1]
70
+
71
+ return text
72
+
73
+ def preprocess_medias(self,
74
+ medias: list[dict]) -> tuple[list[dict], list[str]]:
75
+ """Process media items and generate corresponding prompts.
76
+
77
+ Returns:
78
+ A tuple of (updated_medias, image_prompts).
79
+ """
80
+ updated_medias = []
81
+ image_prompts = []
82
+ for media in medias:
83
+ if media['type'] == 'image':
84
+ updated_medias.append(media)
85
+ img = ensure_media_type(
86
+ media,
87
+ transparent_bg_config=self.media_processor.
88
+ _transparent_bg_config,
89
+ transparent_bg_fill_stage=self.media_processor.
90
+ _transparent_bg_fill_stage,
91
+ )['image']
92
+ w, h = img.size
93
+ image_prompts.append(
94
+ self.media_processor.make_image_prompt(w, h))
95
+ else:
96
+ raise ValueError(f"unsupported media type: {media['type']}")
97
+ return updated_medias, image_prompts
98
+
99
+ # ── Main entry points ──────────────────────────────────────────────
100
+
101
+ def __call__(self,
102
+ messages: list[dict] = None,
103
+ medias: list[dict] = None,
104
+ text: str = None,
105
+ return_tensors: str = "pt",
106
+ **kwargs) -> BatchFeature:
107
+ """
108
+ Process multimodal inputs for Kimi-K3 model.
109
+
110
+ Args:
111
+ messages: List of message dicts with 'role' and 'content' fields.
112
+ If provided, medias and text will be extracted automatically.
113
+ medias: Pre-extracted list of media dicts.
114
+ text: Pre-formatted text string.
115
+ return_tensors: Format of returned tensors. Default: 'pt'.
116
+ **kwargs: Additional arguments passed to apply_chat_template.
117
+
118
+ Returns:
119
+ BatchFeature with fields: input_ids, attention_mask,
120
+ pixel_values, grid_thws.
121
+ """
122
+ if messages is None and (medias is None or text is None):
123
+ raise ValueError(
124
+ "Provide either 'messages' or both 'medias' and 'text'")
125
+
126
+ if medias is not None and text is not None:
127
+ updated_medias, image_prompts = (self.preprocess_medias(medias))
128
+ preprocessed = self.media_processor.preprocess(
129
+ updated_medias, return_tensors=return_tensors)
130
+ text = self.update_raw_text(text, image_prompts)
131
+ text_inputs = self.tokenizer(text, return_tensors=return_tensors)
132
+ return BatchFeature(data={**text_inputs, **preprocessed.data})
133
+
134
+ if medias is None:
135
+ medias = self._extract_medias_from_messages(messages)
136
+ updated_medias, image_prompts = (self.preprocess_medias(medias))
137
+ preprocessed = self.media_processor.preprocess(
138
+ updated_medias, return_tensors=return_tensors)
139
+
140
+ if text is None:
141
+ text_inputs = self.tokenizer.apply_chat_template(
142
+ messages,
143
+ tokenize=True,
144
+ return_tensors=return_tensors,
145
+ return_dict=True,
146
+ image_prompts=image_prompts,
147
+ **kwargs)
148
+ return BatchFeature(data={**text_inputs, **preprocessed.data})
149
+
150
+ text = self.update_raw_text(text, image_prompts)
151
+ text_inputs = self.tokenizer(text, return_tensors=return_tensors)
152
+ return BatchFeature(data={**text_inputs, **preprocessed.data})
153
+
154
+ @staticmethod
155
+ def _extract_medias_from_messages(messages: list[dict]) -> list[dict]:
156
+ """Extract media items from messages in a single pass."""
157
+ medias = []
158
+ for msg in messages:
159
+ if msg['role'] != 'user' or not msg.get('content'):
160
+ continue
161
+
162
+ for content_part in msg['content']:
163
+ if not isinstance(content_part, dict):
164
+ continue
165
+
166
+ content_type = content_part.get('type')
167
+ if content_type in ['image_url', 'image']:
168
+ image_data = content_part.get(content_type)
169
+ assert image_data is not None, f"image data is missing for content part: {content_part}"
170
+ medias.append({
171
+ 'type': 'image',
172
+ 'image': image_data,
173
+ })
174
+ return medias
175
+
176
+ def apply_chat_template(self, messages, **kwargs):
177
+ return self.tokenizer.apply_chat_template(messages, **kwargs)
178
+
179
+ def batch_decode(self, *args, **kwargs):
180
+ return self.tokenizer.batch_decode(*args, **kwargs)
181
+
182
+ def decode(self, *args, **kwargs):
183
+ return self.tokenizer.decode(*args, **kwargs)
184
+
185
+ @property
186
+ def model_input_names(self):
187
+ return ['input_ids', 'attention_mask', 'pixel_values', 'grid_thws']
kimi_k3_vision.py ADDED
@@ -0,0 +1,404 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MLX port of Kimi-K3's vision tower — a 3D (video-capable) MoonViT variant plus
2
+ # the patchmergerv2 projector.
3
+ #
4
+ # 0.49 B params, 0.02% of the model, and the only reason K3 is multimodal.
5
+ #
6
+ # Closest existing MLX code is mlx-vlm's `kimi_vl/vision.py` (MoonViT for
7
+ # Kimi-VL). This shares its complex64 2D-rope approach but differs in six ways,
8
+ # each marked [K3-V*] below:
9
+ #
10
+ # [K3-V1] 3D grids (t,h,w): a 1D sin-cos *time* embedding is added on top of
11
+ # the learnable 2D grid, and rope frequencies repeat per frame.
12
+ # [K3-V2] bilinear position-embedding interpolation (Kimi-VL used bicubic);
13
+ # mlx-vlm ships bicubic/nearest kernels only, so it is written here.
14
+ # [K3-V3] qkv_hidden_size 1536 != hidden 1024 — attention runs wider than the
15
+ # residual stream, so wo is [1024, 1536] and is NOT square.
16
+ # [K3-V4] RMSNorm, not LayerNorm.
17
+ # [K3-V5] no biases anywhere (attn_bias / linear_bias / patch_embed_proj_bias
18
+ # are all false).
19
+ # [K3-V6] sd2_tpool merge: mean-pool over frames, then 2x2 spatial merge, then
20
+ # a 2-layer MLP projector with *exact* (erf) GELU and a post-RMSNorm.
21
+ #
22
+ # The ViT blocks use tanh-approximate GELU (`PytorchGELUTanh` in the reference)
23
+ # while the projector uses exact `nn.GELU()`. They are different functions and
24
+ # the reference deliberately uses both; do not unify them.
25
+
26
+ import math
27
+ from dataclasses import dataclass, field
28
+ from typing import Any, Dict, List, Optional, Tuple
29
+
30
+ import mlx.core as mx
31
+ import mlx.nn as nn
32
+
33
+ # RMSNorm eps for the ViT blocks. The reference constructs `nn.RMSNorm(dim)`
34
+ # with no eps, so PyTorch falls back to finfo(dtype).eps — 1.19e-7 in fp32 but
35
+ # 7.8e-3 in bf16, which are materially different normalizers. We use the fp32
36
+ # value, matching an upcast implementation; `projector_ln_eps` (1e-5) is given
37
+ # explicitly by the config and is used only for the projector's post_norm.
38
+ VIT_NORM_EPS = 1.1920929e-07
39
+
40
+
41
+ @dataclass
42
+ class VisionConfig:
43
+ vt_hidden_size: int = 1024
44
+ vt_intermediate_size: int = 4096
45
+ vt_num_hidden_layers: int = 27
46
+ vt_num_attention_heads: int = 12
47
+ qkv_hidden_size: int = 1536
48
+ patch_size: int = 14
49
+ num_channels: int = 3
50
+ init_pos_emb_height: int = 64
51
+ init_pos_emb_width: int = 64
52
+ init_pos_emb_time: int = 4
53
+ pos_emb_type: str = "divided_fixed"
54
+ pos_emb_interpolation_mode: str = "bilinear"
55
+ merge_kernel_size: Tuple[int, int] = (2, 2)
56
+ merge_type: str = "sd2_tpool"
57
+ norm_type: str = "rmsnorm"
58
+ mlp_type: str = "mlp2"
59
+ mm_projector_type: str = "patchmergerv2"
60
+ mm_hidden_size: int = 1024
61
+ text_hidden_size: int = 7168
62
+ projector_hidden_act: str = "gelu"
63
+ projector_ln_eps: float = 1e-5
64
+ attn_bias: bool = False
65
+ linear_bias: bool = False
66
+ patch_embed_proj_bias: bool = False
67
+ rope_theta_base: float = 10000.0
68
+ rope_max_size: int = 512
69
+
70
+ @classmethod
71
+ def from_dict(cls, d: Dict[str, Any]) -> "VisionConfig":
72
+ known = {f for f in cls.__dataclass_fields__}
73
+ out = {k: v for k, v in d.items() if k in known}
74
+ if "merge_kernel_size" in out:
75
+ out["merge_kernel_size"] = tuple(out["merge_kernel_size"])
76
+ return cls(**out)
77
+
78
+ @property
79
+ def head_dim(self) -> int:
80
+ return self.qkv_hidden_size // self.vt_num_attention_heads
81
+
82
+
83
+ # ------------------------------------------------------- [K3-V2] bilinear
84
+
85
+
86
+ def bilinear_interpolate(x: mx.array, out_h: int, out_w: int) -> mx.array:
87
+ """Resize (H, W, C) -> (out_h, out_w, C), matching torch's bilinear
88
+ `F.interpolate(..., align_corners=False)`.
89
+
90
+ torch maps destination index d to source coordinate
91
+ `max(0, scale * (d + 0.5) - 0.5)` and lerps between the two neighbours,
92
+ clamping the upper index at the edge. Getting the half-pixel offset wrong
93
+ shifts every position embedding by half a patch, which is the kind of error
94
+ that degrades quality without ever looking obviously broken.
95
+ """
96
+ in_h, in_w = x.shape[0], x.shape[1]
97
+ if (in_h, in_w) == (out_h, out_w):
98
+ return x
99
+
100
+ def axis_index(in_n: int, out_n: int):
101
+ scale = in_n / out_n
102
+ src = mx.maximum(
103
+ (mx.arange(out_n, dtype=mx.float32) + 0.5) * scale - 0.5,
104
+ mx.array(0.0, mx.float32),
105
+ )
106
+ i0 = mx.floor(src)
107
+ w1 = src - i0
108
+ i0 = i0.astype(mx.int32)
109
+ i1 = mx.minimum(i0 + 1, mx.array(in_n - 1, mx.int32))
110
+ return i0, i1, w1
111
+
112
+ h0, h1, wh = axis_index(in_h, out_h)
113
+ w0, w1i, ww = axis_index(in_w, out_w)
114
+
115
+ xf = x.astype(mx.float32)
116
+ top = mx.take(xf, h0, axis=0) # (out_h, in_w, C)
117
+ bot = mx.take(xf, h1, axis=0)
118
+ wh = wh.reshape(out_h, 1, 1)
119
+ rows = top * (1.0 - wh) + bot * wh
120
+
121
+ left = mx.take(rows, w0, axis=1) # (out_h, out_w, C)
122
+ right = mx.take(rows, w1i, axis=1)
123
+ ww = ww.reshape(1, out_w, 1)
124
+ return left * (1.0 - ww) + right * ww
125
+
126
+
127
+ def sincos_1d(dim: int, length: int) -> mx.array:
128
+ """(length, dim) sin-cos table; matches get_1d_sincos_pos_embed."""
129
+ omega = mx.arange(dim // 2, dtype=mx.float32) / (dim / 2.0)
130
+ omega = 1.0 / (10000.0**omega)
131
+ out = mx.outer(mx.arange(length, dtype=mx.float32), omega)
132
+ return mx.concatenate([mx.sin(out), mx.cos(out)], axis=1)
133
+
134
+
135
+ # ------------------------------------------------ [K3-V1] 3D position embed
136
+
137
+
138
+ class Learnable2DInterpPosEmbDivided(nn.Module):
139
+ """Learnable 2D grid, bilinearly resized per image, plus a sin-cos time term."""
140
+
141
+ def __init__(self, cfg: VisionConfig):
142
+ super().__init__()
143
+ self.cfg = cfg
144
+ # reference does nn.init.normal_(weight); a zero init would make an
145
+ # unloaded tower emit exactly zero, which hides a failed weight load
146
+ self.weight = mx.random.normal(
147
+ (cfg.init_pos_emb_height, cfg.init_pos_emb_width, cfg.vt_hidden_size)
148
+ )
149
+ self._time = sincos_1d(cfg.vt_hidden_size, cfg.init_pos_emb_time)
150
+
151
+ def __call__(self, x: mx.array, grid_thws: List[Tuple[int, int, int]]) -> mx.array:
152
+ embs = []
153
+ for t, h, w in grid_thws:
154
+ if (h, w) == self.weight.shape[:2]:
155
+ emb2d = self.weight.reshape(-1, self.weight.shape[-1])
156
+ else:
157
+ emb2d = bilinear_interpolate(self.weight, h, w).reshape(
158
+ -1, self.weight.shape[-1]
159
+ )
160
+ if t == 1:
161
+ emb = emb2d
162
+ else:
163
+ emb = mx.expand_dims(emb2d, 0) + mx.expand_dims(self._time[:t], 1)
164
+ emb = emb.reshape(-1, emb.shape[-1])
165
+ embs.append(emb)
166
+ return x + mx.concatenate(embs, axis=0).astype(x.dtype)
167
+
168
+
169
+ class MoonVision3dPatchEmbed(nn.Module):
170
+ """Patch projection. The reference is a Conv2d whose kernel equals the patch
171
+ size applied to pre-extracted (L, 3, 14, 14) patches, which is exactly a
172
+ linear map over the flattened patch — done as a matmul here to sidestep
173
+ MLX's NHWC conv weight layout entirely."""
174
+
175
+ def __init__(self, cfg: VisionConfig):
176
+ super().__init__()
177
+ self.cfg = cfg
178
+ fan_in = cfg.num_channels * cfg.patch_size * cfg.patch_size
179
+ # torch Conv2d default: uniform(-1/sqrt(fan_in), 1/sqrt(fan_in))
180
+ bound = 1.0 / math.sqrt(fan_in)
181
+ self.proj_weight = mx.random.uniform(
182
+ low=-bound, high=bound, shape=(cfg.vt_hidden_size, fan_in)
183
+ )
184
+ self.pos_emb = Learnable2DInterpPosEmbDivided(cfg)
185
+
186
+ def __call__(self, patches: mx.array, grid_thws) -> mx.array:
187
+ x = patches.reshape(patches.shape[0], -1).astype(self.proj_weight.dtype)
188
+ x = x @ self.proj_weight.T
189
+ return self.pos_emb(x, grid_thws)
190
+
191
+
192
+ # --------------------------------------------------------- [K3-V1] 2D rope
193
+
194
+
195
+ class Rope2DPosEmbRepeated(nn.Module):
196
+ """freqs_cis[h, w] interleaves x (width) and y (height) frequencies:
197
+ entry 2i is cis(w * theta^(-4i/dim)), entry 2i+1 is cis(h * ...).
198
+ Repeated t times so every frame shares the same spatial rope."""
199
+
200
+ def __init__(self, dim: int, max_h: int, max_w: int, theta_base: float = 10000.0):
201
+ super().__init__()
202
+ assert dim % 4 == 0, "rope dim must be divisible by 4"
203
+ self.dim, self.max_h, self.max_w, self.theta_base = dim, max_h, max_w, theta_base
204
+ self._cache: Optional[mx.array] = None
205
+
206
+ def _full(self) -> mx.array:
207
+ if self._cache is None:
208
+ n = self.max_h * self.max_w
209
+ flat = mx.arange(0, n, dtype=mx.float32)
210
+ x_pos = flat % self.max_w
211
+ y_pos = mx.floor(flat / self.max_w)
212
+ dim_range = mx.arange(0, self.dim, 4)[: self.dim // 4].astype(mx.float32)
213
+ freqs = 1.0 / (self.theta_base ** (dim_range / self.dim))
214
+ xf, yf = mx.outer(x_pos, freqs), mx.outer(y_pos, freqs)
215
+ x_cis = mx.cos(xf) + 1j * mx.sin(xf)
216
+ y_cis = mx.cos(yf) + 1j * mx.sin(yf)
217
+ self._cache = mx.stack([x_cis, y_cis], axis=-1).reshape(
218
+ self.max_h, self.max_w, -1
219
+ )
220
+ return self._cache
221
+
222
+ def __call__(self, grid_thws: List[Tuple[int, int, int]]) -> mx.array:
223
+ full = self._full()
224
+ parts = []
225
+ for t, h, w in grid_thws:
226
+ assert 1 <= h <= self.max_h and 1 <= w <= self.max_w, (h, w)
227
+ base = full[:h, :w].reshape(-1, self.dim // 2)
228
+ parts.append(mx.tile(base, (t, 1)) if t > 1 else base)
229
+ return mx.concatenate(parts, axis=0)
230
+
231
+
232
+ def apply_rope(q: mx.array, k: mx.array, freqs_cis: mx.array):
233
+ """q, k: (L, heads, head_dim); freqs_cis: (L, head_dim/2) complex64."""
234
+ f = mx.expand_dims(freqs_cis, axis=-2)
235
+
236
+ def rot(t):
237
+ pair = t.astype(mx.float32).reshape(*t.shape[:-1], -1, 2)
238
+ c = (pair[..., 0] + 1j * pair[..., 1]) * f
239
+ out = mx.stack([mx.real(c), mx.imag(c)], axis=-1)
240
+ return out.reshape(*t.shape).astype(t.dtype)
241
+
242
+ return rot(q), rot(k)
243
+
244
+
245
+ # ----------------------------------------------------------- encoder
246
+
247
+
248
+ class MLP2(nn.Module):
249
+ def __init__(self, dims: List[int], bias: bool = False):
250
+ super().__init__()
251
+ self.fc0 = nn.Linear(dims[0], dims[1], bias=bias)
252
+ self.fc1 = nn.Linear(dims[1], dims[2], bias=bias)
253
+
254
+ def __call__(self, x: mx.array) -> mx.array:
255
+ # PytorchGELUTanh -- the tanh approximation, not exact erf GELU.
256
+ return self.fc1(nn.gelu_approx(self.fc0(x)))
257
+
258
+
259
+ class MoonViTEncoderLayer(nn.Module):
260
+ def __init__(self, cfg: VisionConfig):
261
+ super().__init__()
262
+ self.cfg = cfg
263
+ h, qkv = cfg.vt_hidden_size, cfg.qkv_hidden_size
264
+ self.num_heads = cfg.vt_num_attention_heads
265
+ self.head_dim = cfg.head_dim
266
+ self.scale = self.head_dim**-0.5
267
+
268
+ self.norm0 = nn.RMSNorm(h, eps=VIT_NORM_EPS)
269
+ self.norm1 = nn.RMSNorm(h, eps=VIT_NORM_EPS)
270
+ self.wqkv = nn.Linear(h, qkv * 3, bias=cfg.attn_bias)
271
+ self.wo = nn.Linear(qkv, h, bias=cfg.attn_bias) # [K3-V3] not square
272
+ self.mlp = MLP2([h, cfg.vt_intermediate_size, h], bias=cfg.linear_bias)
273
+
274
+ def _attend(self, x, freqs_cis, spans):
275
+ L = x.shape[0]
276
+ qkv = self.wqkv(x).reshape(L, 3, self.num_heads, self.head_dim)
277
+ q, k, v = qkv[:, 0], qkv[:, 1], qkv[:, 2]
278
+ q, k = apply_rope(q, k, freqs_cis)
279
+
280
+ # Images are packed into one sequence but must not attend across each
281
+ # other. Looping the (few) images costs sum(L_i^2) instead of the
282
+ # (sum L_i)^2 an explicit block-diagonal mask would.
283
+ outs = []
284
+ for s, e in spans:
285
+ qi = q[s:e].transpose(1, 0, 2)[None]
286
+ ki = k[s:e].transpose(1, 0, 2)[None]
287
+ vi = v[s:e].transpose(1, 0, 2)[None]
288
+ o = mx.fast.scaled_dot_product_attention(qi, ki, vi, scale=self.scale)
289
+ outs.append(o[0].transpose(1, 0, 2).reshape(e - s, -1))
290
+ return self.wo(mx.concatenate(outs, axis=0))
291
+
292
+ def __call__(self, x, freqs_cis, spans):
293
+ x = x + self._attend(self.norm0(x), freqs_cis, spans)
294
+ return x + self.mlp(self.norm1(x))
295
+
296
+
297
+ class MoonViT3dEncoder(nn.Module):
298
+ def __init__(self, cfg: VisionConfig):
299
+ super().__init__()
300
+ self.rope_2d = Rope2DPosEmbRepeated(
301
+ cfg.head_dim, cfg.rope_max_size, cfg.rope_max_size, cfg.rope_theta_base
302
+ )
303
+ self.blocks = [MoonViTEncoderLayer(cfg) for _ in range(cfg.vt_num_hidden_layers)]
304
+ self.final_layernorm = nn.RMSNorm(cfg.vt_hidden_size, eps=VIT_NORM_EPS)
305
+
306
+ def __call__(self, x: mx.array, grid_thws) -> mx.array:
307
+ freqs_cis = self.rope_2d(grid_thws)
308
+ spans, pos = [], 0
309
+ for t, h, w in grid_thws:
310
+ spans.append((pos, pos + t * h * w))
311
+ pos += t * h * w
312
+ for blk in self.blocks:
313
+ x = blk(x, freqs_cis, spans)
314
+ return self.final_layernorm(x)
315
+
316
+
317
+ # ---------------------------------------------------- [K3-V6] merge + project
318
+
319
+
320
+ def tpool_patch_merger(x: mx.array, grid_thws, merge_kernel_size=(2, 2)):
321
+ """-> list of (h/2 * w/2, 4, C): mean over frames, then 2x2 spatial merge."""
322
+ d = x.shape[-1]
323
+ kh, kw = merge_kernel_size
324
+ out, pos = [], 0
325
+ for t, h, w in grid_thws:
326
+ seq = x[pos : pos + t * h * w]
327
+ pos += t * h * w
328
+ nh, nw = h // kh, w // kw
329
+ s = seq.reshape(t, nh, kh, nw, kw, d).transpose(0, 1, 3, 2, 4, 5)
330
+ s = mx.mean(s, axis=0) # temporal pooling
331
+ out.append(s.reshape(nh * nw, kh * kw, d))
332
+ return out
333
+
334
+
335
+ class PatchMergerMLPV2(nn.Module):
336
+ def __init__(self, cfg: VisionConfig):
337
+ super().__init__()
338
+ inner = cfg.mm_hidden_size * cfg.merge_kernel_size[0] * cfg.merge_kernel_size[1]
339
+ self.inner = inner
340
+ # nn.Sequential(Linear, GELU, Linear) in the reference -> proj.0 / proj.2
341
+ self.proj_0 = nn.Linear(inner, inner, bias=False)
342
+ self.proj_2 = nn.Linear(inner, cfg.text_hidden_size, bias=False)
343
+ self.post_norm = nn.RMSNorm(cfg.text_hidden_size, eps=cfg.projector_ln_eps)
344
+
345
+ def __call__(self, feats: List[mx.array]) -> List[mx.array]:
346
+ lengths = [f.shape[0] for f in feats]
347
+ x = mx.concatenate([f.reshape(f.shape[0], -1) for f in feats], axis=0)
348
+ # exact erf GELU here -- deliberately different from the ViT's tanh GELU
349
+ x = self.post_norm(self.proj_2(nn.gelu(self.proj_0(x))))
350
+ out, pos = [], 0
351
+ for n in lengths:
352
+ out.append(x[pos : pos + n])
353
+ pos += n
354
+ return out
355
+
356
+
357
+ class VisionModel(nn.Module):
358
+ """vision_tower + mm_projector: pixels -> text-space embeddings."""
359
+
360
+ def __init__(self, cfg: VisionConfig):
361
+ super().__init__()
362
+ self.cfg = cfg
363
+ if cfg.pos_emb_type != "divided_fixed":
364
+ raise NotImplementedError(cfg.pos_emb_type)
365
+ if cfg.merge_type != "sd2_tpool":
366
+ raise NotImplementedError(cfg.merge_type)
367
+ if cfg.mm_projector_type != "patchmergerv2":
368
+ raise NotImplementedError(cfg.mm_projector_type)
369
+ if cfg.norm_type != "rmsnorm":
370
+ raise NotImplementedError(cfg.norm_type)
371
+ self.patch_embed = MoonVision3dPatchEmbed(cfg)
372
+ self.encoder = MoonViT3dEncoder(cfg)
373
+ self.mm_projector = PatchMergerMLPV2(cfg)
374
+
375
+ def __call__(self, pixel_values: mx.array, grid_thws) -> List[mx.array]:
376
+ grid_thws = [tuple(int(v) for v in g) for g in grid_thws]
377
+ x = self.patch_embed(pixel_values, grid_thws)
378
+ x = self.encoder(x, grid_thws)
379
+ merged = tpool_patch_merger(x, grid_thws, self.cfg.merge_kernel_size)
380
+ return self.mm_projector(merged)
381
+
382
+ def sanitize(self, weights: Dict[str, mx.array]) -> Dict[str, mx.array]:
383
+ out: Dict[str, mx.array] = {}
384
+ for k, v in weights.items():
385
+ if k.startswith("vision_tower."):
386
+ k = k[len("vision_tower.") :]
387
+ if k == "patch_embed.proj.weight":
388
+ # Conv2d (out, in, kH, kW) -> flat linear (out, in*kH*kW).
389
+ # torch sums over (c, i, j) in that order, so a plain
390
+ # reshape is the correct flattening.
391
+ out["patch_embed.proj_weight"] = v.reshape(v.shape[0], -1)
392
+ elif k == "patch_embed.pos_emb.weight":
393
+ out["patch_embed.pos_emb.weight"] = v
394
+ else:
395
+ out[k] = v
396
+ elif k.startswith("mm_projector."):
397
+ k = k[len("mm_projector.") :]
398
+ if k == "proj.0.weight":
399
+ out["mm_projector.proj_0.weight"] = v
400
+ elif k == "proj.2.weight":
401
+ out["mm_projector.proj_2.weight"] = v
402
+ else:
403
+ out["mm_projector." + k] = v
404
+ return out
kimi_k3_vision_processing.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Image processor class for Kimi-K3.
2
+ """
3
+
4
+ import json
5
+ from typing import Any, Dict, Optional, Union
6
+
7
+ import numpy as np
8
+ import torch
9
+ from PIL import Image
10
+ from transformers.image_processing_utils import (BaseImageProcessor,
11
+ BatchFeature)
12
+ from transformers.utils import TensorType
13
+
14
+ from .media_utils import (MediaInput, TransparentBgConfig, _to_tensor,
15
+ ensure_media_type, image_to_np, navit_patchify,
16
+ navit_resize_image, normalize)
17
+
18
+
19
+ class KimiK3VisionProcessor(BaseImageProcessor):
20
+ model_type = "kimi_k3"
21
+
22
+ def __init__(
23
+ self,
24
+ media_proc_cfg: dict,
25
+ **kwargs,
26
+ ):
27
+ super().__init__(**kwargs)
28
+ self.media_proc_cfg = media_proc_cfg
29
+
30
+ @property
31
+ def _transparent_bg_config(self) -> Optional[TransparentBgConfig]:
32
+ cfg = self.media_proc_cfg.get("transparent_bg_config")
33
+ if cfg is None:
34
+ return None
35
+ if isinstance(cfg, TransparentBgConfig):
36
+ return cfg
37
+ return TransparentBgConfig(**cfg)
38
+
39
+ @property
40
+ def _transparent_bg_fill_stage(self) -> str:
41
+ return self.media_proc_cfg.get("transparent_bg_fill_stage",
42
+ "before_resize")
43
+
44
+ def media_tokens_calculator(self, media: MediaInput):
45
+ media = ensure_media_type(
46
+ media,
47
+ transparent_bg_config=self._transparent_bg_config,
48
+ transparent_bg_fill_stage=self._transparent_bg_fill_stage,
49
+ )
50
+ ret = self.get_resize_config(media)
51
+ return ret['num_tokens']
52
+
53
+ @classmethod
54
+ def make_image_prompt(cls, width: int, height: int) -> str:
55
+ """Build the K3 image placeholder with resolution info."""
56
+ return (f"<|media_begin|>image {width}x{height}"
57
+ f"<|media_content|><|media_pad|><|media_end|>")
58
+
59
+ def get_resize_config(self, media_input: MediaInput) -> dict:
60
+ if media_input['type'] == 'image':
61
+ w, h = media_input['image'].size
62
+ ret = navit_resize_image(
63
+ w, h, self.media_proc_cfg['patch_size'],
64
+ self.media_proc_cfg['merge_kernel_size'],
65
+ self.media_proc_cfg['in_patch_limit'],
66
+ self.media_proc_cfg['patch_limit_on_one_side'],
67
+ self.media_proc_cfg['fixed_output_tokens'])
68
+ return ret
69
+ else:
70
+ raise ValueError("Unsupported type: {}".format(
71
+ media_input['type']))
72
+
73
+ def resize_image(self, image: Image.Image, new_width: int, new_height: int,
74
+ pad_width: int, pad_height: int) -> np.ndarray:
75
+ image_np = image_to_np(
76
+ image,
77
+ (new_width, new_height),
78
+ "resize",
79
+ transparent_bg_config=self._transparent_bg_config,
80
+ transparent_bg_fill_stage=self._transparent_bg_fill_stage,
81
+ )
82
+ image_np = np.pad(
83
+ image_np,
84
+ ((0, pad_height), (0, pad_width), (0, 0)),
85
+ mode="constant",
86
+ constant_values=0,
87
+ )
88
+ return image_np
89
+
90
+ def preprocess(
91
+ self,
92
+ medias: list[MediaInput],
93
+ return_tensors: Optional[Union[str, TensorType]] = None,
94
+ ) -> BatchFeature:
95
+ """
96
+ Preprocess a atom vision input (images) into model-ready tensors.
97
+
98
+ Args:
99
+ medias: List of MediaInput.
100
+ return_tensors: Desired output format ('pt', 'np', 'tf', or None).
101
+
102
+ Returns:
103
+ BatchFeature containing 'pixel_values' and 'grid_thws' tensors.
104
+ """
105
+ if not isinstance(medias, list):
106
+ medias = [medias]
107
+ if medias:
108
+ pixel_values = []
109
+ for item in medias:
110
+ item = ensure_media_type(
111
+ item,
112
+ transparent_bg_config=self._transparent_bg_config,
113
+ transparent_bg_fill_stage=self._transparent_bg_fill_stage,
114
+ )
115
+ resize_config = self.get_resize_config(item)
116
+ new_width, new_height, pad_width, pad_height = resize_config[
117
+ 'new_width'], resize_config['new_height'], resize_config[
118
+ 'pad_width'], resize_config['pad_height']
119
+ if item['type'] == 'image':
120
+ image = item['image']
121
+ image_np = self.resize_image(image, new_width, new_height,
122
+ pad_width, pad_height)
123
+ pixel_values.append(np.expand_dims(image_np, axis=0))
124
+ else:
125
+ raise ValueError("Unsupported type: {}".format(
126
+ item['type']))
127
+ normalized_pixel_values = []
128
+ image_std_inv = 1.0 / np.array(self.media_proc_cfg['image_std'])
129
+ image_mean = np.array(self.media_proc_cfg['image_mean'])
130
+ for pixels in pixel_values:
131
+ pixels = normalize(pixels, image_mean, image_std_inv)
132
+ pixels_and_thw = navit_patchify(
133
+ pixels,
134
+ self.media_proc_cfg['patch_size'],
135
+ )
136
+ normalized_pixel_values.append(pixels_and_thw)
137
+
138
+ pixel_values = torch.cat([
139
+ _to_tensor(pixel_value['pixel_values'])
140
+ for pixel_value in normalized_pixel_values
141
+ ])
142
+ grid_thws = torch.cat([
143
+ _to_tensor(pixel_value['grid_thw'],
144
+ dtype=torch.int64).unsqueeze(0)
145
+ for pixel_value in normalized_pixel_values
146
+ ])
147
+
148
+ data = {
149
+ 'pixel_values': pixel_values,
150
+ 'grid_thws': grid_thws,
151
+ }
152
+
153
+ else:
154
+ data = {}
155
+
156
+ return BatchFeature(data=data, tensor_type=return_tensors)
157
+
158
+ def __repr__(self):
159
+ return f"KimiK3VisionProcessor(media_proc_cfg={self.media_proc_cfg})"
160
+
161
+ def to_dict(self) -> Dict[str, Any]:
162
+ output = super().to_dict()
163
+ output["media_proc_cfg"] = self.media_proc_cfg
164
+ if "media_processor" in output:
165
+ del output["media_processor"]
166
+ return output
167
+
168
+ @classmethod
169
+ def from_dict(cls, config_dict: Dict[str, Any], **kwargs):
170
+ config = config_dict.copy()
171
+ media_proc_cfg = config.pop("media_proc_cfg", {})
172
+ return cls(media_proc_cfg=media_proc_cfg, **config, **kwargs)
173
+
174
+ def to_json_string(self):
175
+ dictionary = self.to_dict()
176
+ for key, value in dictionary.items():
177
+ if hasattr(value, 'tolist'):
178
+ dictionary[key] = value.tolist()
179
+ return json.dumps(dictionary, indent=2, sort_keys=True) + "\n"
kimi_k3_vl/__init__.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .config import ModelConfig, TextConfig, VisionConfig
2
+ from .kimi_k3_vl import Model, merge_image_features
3
+ from .language import LanguageModel
4
+ from .vision import VisionModel
5
+
6
+ __all__ = [
7
+ "ModelConfig",
8
+ "TextConfig",
9
+ "VisionConfig",
10
+ "Model",
11
+ "LanguageModel",
12
+ "VisionModel",
13
+ "merge_image_features",
14
+ ]
kimi_k3_vl/config.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Config plumbing for the Kimi-K3 mlx-vlm wrapper."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Dict, List, Optional
5
+
6
+
7
+ @dataclass
8
+ class BaseCfg:
9
+ @classmethod
10
+ def from_dict(cls, params: Dict[str, Any]):
11
+ known = {f for f in cls.__dataclass_fields__}
12
+ return cls(**{k: v for k, v in params.items() if k in known})
13
+
14
+
15
+ @dataclass
16
+ class TextConfig(BaseCfg):
17
+ """Thin holder: the real parsing lives in kimi_k3.ModelArgs, which already
18
+ understands K3's nested `text_config`. Kept so mlx-vlm's config machinery
19
+ has something with the shape it expects."""
20
+
21
+ model_type: str = "kimi_linear"
22
+ hidden_size: int = 7168
23
+ num_hidden_layers: int = 93
24
+ vocab_size: int = 163840
25
+ raw: Dict[str, Any] = field(default_factory=dict)
26
+
27
+ @classmethod
28
+ def from_dict(cls, params: Dict[str, Any]):
29
+ return cls(
30
+ model_type=params.get("model_type", "kimi_linear"),
31
+ hidden_size=params.get("hidden_size", 7168),
32
+ num_hidden_layers=params.get("num_hidden_layers", 93),
33
+ vocab_size=params.get("vocab_size", 163840),
34
+ raw=dict(params),
35
+ )
36
+
37
+
38
+ @dataclass
39
+ class VisionConfig(BaseCfg):
40
+ model_type: str = "moonvit3d"
41
+ raw: Dict[str, Any] = field(default_factory=dict)
42
+
43
+ @classmethod
44
+ def from_dict(cls, params: Dict[str, Any]):
45
+ return cls(model_type=params.get("model_type", "moonvit3d"), raw=dict(params))
46
+
47
+
48
+ @dataclass
49
+ class ModelConfig(BaseCfg):
50
+ text_config: TextConfig
51
+ vision_config: VisionConfig
52
+ model_type: str = "kimi_k3"
53
+ # <|media_pad|>. Exactly ONE of these per image in the prompt; it expands to
54
+ # that image's full token count. See kimi_k3_vl.merge_image_features.
55
+ media_placeholder_token_id: int = 163605
56
+ image_token_index: Optional[int] = None
57
+ ignore_index: int = -100
58
+ pad_token_id: int = 163839
59
+ bos_token_id: int = 163584
60
+ eos_token_id: Optional[Any] = 163586
61
+ vocab_size: int = 163840
62
+ quantization: Optional[Dict[str, Any]] = None
63
+ raw: Dict[str, Any] = field(default_factory=dict)
64
+
65
+ @classmethod
66
+ def from_dict(cls, params: Dict[str, Any]):
67
+ # TextConfig.raw carries the *whole* config: kimi_k3.ModelArgs.from_dict
68
+ # already knows how to unwrap K3's nested `text_config`, so reusing that
69
+ # path avoids a second, divergent copy of the same unwrapping rules.
70
+ text = TextConfig.from_dict(params.get("text_config", {}))
71
+ text.raw = dict(params)
72
+ return cls(
73
+ text_config=text,
74
+ vision_config=VisionConfig.from_dict(params.get("vision_config", {})),
75
+ model_type=params.get("model_type", "kimi_k3"),
76
+ media_placeholder_token_id=params.get("media_placeholder_token_id", 163605),
77
+ image_token_index=params.get("image_token_index"),
78
+ ignore_index=params.get("ignore_index", -100),
79
+ pad_token_id=params.get("pad_token_id", 163839),
80
+ bos_token_id=params.get("bos_token_id", 163584),
81
+ eos_token_id=params.get("eos_token_id", 163586),
82
+ vocab_size=params.get("text_config", {}).get("vocab_size", 163840),
83
+ quantization=params.get("quantization"),
84
+ raw=dict(params),
85
+ )
kimi_k3_vl/kimi_k3_vl.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Kimi-K3 multimodal glue for mlx-vlm: vision tower -> prompt -> text tower.
2
+
3
+ The only genuinely interesting part is `merge_image_features`.
4
+
5
+ K3's processor rewrites each `<|kimi_image_placeholder|>` in the raw text into
6
+
7
+ <|media_begin|>image {W}x{H}<|media_content|><|media_pad|><|media_end|>
8
+
9
+ so the tokenized prompt carries **exactly one** `<|media_pad|>` (163605) per
10
+ image, which must then EXPAND into that image's full token count. That differs
11
+ from the more common LLaVA-style arrangement (and from mlx-vlm's existing
12
+ Kimi-VL glue), where the processor has already emitted one placeholder per image
13
+ token and merging is a same-length scatter.
14
+
15
+ Getting this wrong is quiet rather than loud: a same-length scatter would write
16
+ one image token and silently drop the rest, leaving a model that still generates
17
+ fluent text while being effectively blind to most of the picture.
18
+ """
19
+
20
+ from typing import Any, List, Optional
21
+
22
+ import mlx.core as mx
23
+ import mlx.nn as nn
24
+
25
+ from .config import ModelConfig
26
+ from .language import LanguageModel
27
+ from .vision import VisionModel
28
+
29
+ try:
30
+ from mlx_vlm.models.base import InputEmbeddingsFeatures
31
+ except ImportError: # allow importing the glue without mlx-vlm present
32
+ from dataclasses import dataclass
33
+
34
+ @dataclass
35
+ class InputEmbeddingsFeatures: # type: ignore[no-redef]
36
+ inputs_embeds: mx.array
37
+ attention_mask: Optional[mx.array] = None
38
+ position_ids: Optional[mx.array] = None
39
+
40
+
41
+ def merge_image_features(
42
+ inputs_embeds: mx.array,
43
+ input_ids: mx.array,
44
+ image_features: List[mx.array],
45
+ placeholder_id: int,
46
+ pad_token_id: Optional[int] = None,
47
+ ):
48
+ """Expand each placeholder token into its image's feature block.
49
+
50
+ inputs_embeds : (B, S, D) token embeddings
51
+ input_ids : (B, S)
52
+ image_features: list of (n_i, D), one per image, in prompt order
53
+ returns : (B, S', D) merged embeddings and a (B, S') attention mask,
54
+ where S' = S - K + sum(n_i) for K placeholders
55
+
56
+ Rows are built by concatenating the spans between placeholders, which is
57
+ O(images) concatenations rather than O(sequence length) scatter indices, and
58
+ is far easier to check than the reference's index arithmetic. With B > 1 the
59
+ rows are LEFT-padded to the longest merged length, matching the reference's
60
+ `left_padding` branch (it is what generation with a KV cache expects).
61
+ """
62
+ B, S = input_ids.shape
63
+ ids = input_ids.tolist()
64
+
65
+ n_ph_total = sum(1 for row in ids for t in row if t == placeholder_id)
66
+ if n_ph_total != len(image_features):
67
+ raise ValueError(
68
+ f"{n_ph_total} <|media_pad|> placeholder(s) in the prompt but "
69
+ f"{len(image_features)} image feature block(s). K3 uses exactly one "
70
+ f"placeholder per image."
71
+ )
72
+
73
+ rows, masks, taken = [], [], 0
74
+ for b in range(B):
75
+ parts, mask_parts, prev = [], [], 0
76
+ for j, tok in enumerate(ids[b]):
77
+ if tok != placeholder_id:
78
+ continue
79
+ if j > prev:
80
+ parts.append(inputs_embeds[b, prev:j])
81
+ mask_parts.append(mx.ones((j - prev,), dtype=mx.int32))
82
+ feat = image_features[taken].astype(inputs_embeds.dtype)
83
+ taken += 1
84
+ parts.append(feat)
85
+ mask_parts.append(mx.ones((feat.shape[0],), dtype=mx.int32))
86
+ prev = j + 1
87
+ if prev < S:
88
+ parts.append(inputs_embeds[b, prev:])
89
+ mask_parts.append(mx.ones((S - prev,), dtype=mx.int32))
90
+ rows.append(mx.concatenate(parts, axis=0))
91
+ masks.append(mx.concatenate(mask_parts, axis=0))
92
+
93
+ if B == 1:
94
+ merged = rows[0][None]
95
+ mask = masks[0][None]
96
+ else:
97
+ longest = max(r.shape[0] for r in rows)
98
+ padded, padded_masks = [], []
99
+ for r, m in zip(rows, masks):
100
+ gap = longest - r.shape[0]
101
+ if gap:
102
+ r = mx.concatenate([mx.zeros((gap, r.shape[-1]), r.dtype), r], axis=0)
103
+ m = mx.concatenate([mx.zeros((gap,), mx.int32), m], axis=0)
104
+ padded.append(r)
105
+ padded_masks.append(m)
106
+ merged = mx.stack(padded)
107
+ mask = mx.stack(padded_masks)
108
+
109
+ # zero out embeddings at pad positions, as the reference does
110
+ if pad_token_id is not None:
111
+ merged = mx.where(mask[..., None] == 0, mx.zeros_like(merged), merged)
112
+ return merged, mask
113
+
114
+
115
+ class Model(nn.Module):
116
+ def __init__(self, config: ModelConfig):
117
+ super().__init__()
118
+ self.config = config
119
+ self.model_type = config.model_type
120
+ self.vision_tower = VisionModel(config.vision_config)
121
+ self.language_model = LanguageModel(config.text_config)
122
+
123
+ # ------------------------------------------------------------------ api
124
+
125
+ @property
126
+ def layers(self):
127
+ return self.language_model.model.model.layers
128
+
129
+ def make_cache(self):
130
+ return self.language_model.model.make_cache()
131
+
132
+ def get_input_embeddings(
133
+ self,
134
+ input_ids: Optional[mx.array] = None,
135
+ pixel_values: Optional[mx.array] = None,
136
+ **kwargs,
137
+ ) -> InputEmbeddingsFeatures:
138
+ embed = self.language_model.model.model.embed_tokens
139
+ inputs_embeds = embed(input_ids)
140
+ if pixel_values is None:
141
+ return InputEmbeddingsFeatures(inputs_embeds=inputs_embeds)
142
+
143
+ grids = (
144
+ kwargs.pop("grid_thws", None)
145
+ or kwargs.pop("image_grid_thw", None)
146
+ or kwargs.pop("image_grid_hws", None)
147
+ )
148
+ if grids is None:
149
+ raise ValueError("grid_thws is required alongside pixel_values")
150
+ if isinstance(grids, mx.array):
151
+ grids = grids.tolist()
152
+ grids = [tuple(int(v) for v in g) for g in grids]
153
+
154
+ feats = kwargs.get("cached_image_features")
155
+ if feats is None:
156
+ feats = self.vision_tower(pixel_values, grids)
157
+
158
+ placeholder = kwargs.pop("image_token_id", None) or self.config.media_placeholder_token_id
159
+ merged, mask = merge_image_features(
160
+ inputs_embeds, input_ids, feats, placeholder, self.config.pad_token_id
161
+ )
162
+ return InputEmbeddingsFeatures(inputs_embeds=merged, attention_mask=mask)
163
+
164
+ def __call__(
165
+ self,
166
+ input_ids: mx.array,
167
+ pixel_values: Optional[mx.array] = None,
168
+ cache: Optional[List[Any]] = None,
169
+ **kwargs,
170
+ ) -> mx.array:
171
+ feats = self.get_input_embeddings(input_ids, pixel_values, **kwargs)
172
+ embeds = feats.inputs_embeds
173
+ # After merging, the token axis no longer lines up with input_ids, so the
174
+ # text tower must be driven by the embeddings' own length.
175
+ ids = mx.zeros(embeds.shape[:2], dtype=mx.int32)
176
+ return self.language_model(ids, cache=cache, inputs_embeds=embeds)
177
+
178
+ def sanitize(self, weights):
179
+ """Route source keys to the two towers.
180
+
181
+ `vision_tower.*` / `mm_projector.*` are consumed by the vision tower;
182
+ `language_model.*` by the text tower. Both delegate to the same sanitize
183
+ functions the standalone models use, so there is one implementation of
184
+ the key mapping rather than a wrapper-specific copy that could drift.
185
+ """
186
+ vis = {k: v for k, v in weights.items()
187
+ if k.startswith(("vision_tower.", "mm_projector."))}
188
+ txt = {k: v for k, v in weights.items() if not k.startswith(("vision_tower.", "mm_projector."))}
189
+ out = {f"vision_tower.{k}": v for k, v in self.vision_tower.sanitize(vis).items()}
190
+ out.update({f"language_model.{k}": v for k, v in self.language_model.sanitize(txt).items()})
191
+ return out
kimi_k3_vl/language.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Text-tower adapter: wraps kimi_k3.Model for mlx-vlm."""
2
+
3
+ from typing import Any, Dict, List, Optional
4
+
5
+ import mlx.core as mx
6
+ import mlx.nn as nn
7
+
8
+ from .config import TextConfig
9
+
10
+ try:
11
+ from mlx_lm.models import kimi_k3 as _k3
12
+ except ImportError as e: # pragma: no cover
13
+ raise ImportError(
14
+ "kimi_k3 is not registered with mlx-lm. It uses mlx-lm-relative imports "
15
+ "(.base, .cache, .switch_layers) so it must live in mlx_lm/models/. Run "
16
+ "`scripts/install_model.sh` once, or copy kimi_k3.py there."
17
+ ) from e
18
+
19
+
20
+ class LanguageModel(nn.Module):
21
+ def __init__(self, config: TextConfig):
22
+ super().__init__()
23
+ args = _k3.ModelArgs.from_dict(config.raw)
24
+ self.args = args
25
+ self.model = _k3.Model(args)
26
+
27
+ def __call__(
28
+ self,
29
+ inputs: mx.array,
30
+ cache: Optional[List[Any]] = None,
31
+ inputs_embeds: Optional[mx.array] = None,
32
+ **kwargs,
33
+ ) -> mx.array:
34
+ return self.model(inputs, cache=cache, inputs_embeds=inputs_embeds)
35
+
36
+ @property
37
+ def layers(self):
38
+ return self.model.model.layers
39
+
40
+ def make_cache(self):
41
+ return self.model.make_cache()
42
+
43
+ def sanitize(self, weights: Dict[str, mx.array]) -> Dict[str, mx.array]:
44
+ # kimi_k3.Model.sanitize already strips `language_model.` and drops the
45
+ # vision keys; prefix the result to sit under this module.
46
+ return {f"model.{k}": v for k, v in self.model.sanitize(weights).items()}
kimi_k3_vl/vision.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Vision-tower adapter: wraps kimi_k3_vision.VisionModel for mlx-vlm."""
2
+
3
+ import importlib.util
4
+ import os
5
+ import sys
6
+ from typing import Dict, List, Tuple
7
+
8
+ import mlx.core as mx
9
+ import mlx.nn as nn
10
+
11
+ from .config import VisionConfig as WrapperVisionConfig
12
+
13
+
14
+ def _load_vision_module():
15
+ """kimi_k3_vision.py ships alongside the weights; import it from wherever
16
+ this package lives so a downloaded repo works without installation."""
17
+ if "kimi_k3_vision" in sys.modules:
18
+ return sys.modules["kimi_k3_vision"]
19
+ try:
20
+ import kimi_k3_vision # noqa: F401
21
+
22
+ return sys.modules["kimi_k3_vision"]
23
+ except ImportError:
24
+ pass
25
+ for d in (os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
26
+ os.path.dirname(os.path.abspath(__file__))):
27
+ p = os.path.join(d, "kimi_k3_vision.py")
28
+ if os.path.exists(p):
29
+ spec = importlib.util.spec_from_file_location("kimi_k3_vision", p)
30
+ mod = importlib.util.module_from_spec(spec)
31
+ sys.modules["kimi_k3_vision"] = mod
32
+ spec.loader.exec_module(mod)
33
+ return mod
34
+ raise ImportError("kimi_k3_vision.py not found next to kimi_k3_vl/")
35
+
36
+
37
+ _v = _load_vision_module()
38
+
39
+
40
+ class VisionModel(nn.Module):
41
+ def __init__(self, config: WrapperVisionConfig):
42
+ super().__init__()
43
+ self.cfg = _v.VisionConfig.from_dict(config.raw)
44
+ self.tower = _v.VisionModel(self.cfg)
45
+
46
+ def __call__(self, pixel_values: mx.array, grid_thws) -> List[mx.array]:
47
+ """pixel_values: (L, 3, patch, patch) pre-extracted patches.
48
+ Returns one (n_i, text_hidden) array per image."""
49
+ return self.tower(pixel_values, grid_thws)
50
+
51
+ def sanitize(self, weights: Dict[str, mx.array]) -> Dict[str, mx.array]:
52
+ return {f"tower.{k}": v for k, v in self.tower.sanitize(weights).items()}
media_utils.py ADDED
@@ -0,0 +1,376 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import functools
3
+ import io
4
+ import math
5
+ from dataclasses import dataclass
6
+ from typing import Literal, TypedDict
7
+
8
+ import numpy as np
9
+ from PIL import Image
10
+
11
+
12
+ class ImageInput(TypedDict):
13
+ type: Literal['image']
14
+ image: Image.Image
15
+
16
+
17
+ MediaInput = ImageInput
18
+
19
+
20
+ @dataclass
21
+ class TransparentBgConfig:
22
+ """The config of the transparent background."""
23
+
24
+ pattern: Literal["white", "black", "gray", "chessboard"] = "black"
25
+ """The pattern of the transparent background."""
26
+
27
+ chessboard_square_size: int = 16
28
+ """The size of the squares in the chessboard background."""
29
+
30
+ chessboard_square_on_top_left: bool = True
31
+ """Whether to start the chessboard with a white square on the top left."""
32
+
33
+ chessboard_white_value: int = 255
34
+ """The value of the white pixels in the background."""
35
+
36
+ chessboard_gray_value: int = 200
37
+ """The value of the gray pixels in the background."""
38
+
39
+
40
+ @functools.lru_cache(maxsize=256)
41
+ def _create_chessboard_background(
42
+ height: int,
43
+ width: int,
44
+ square_size: int,
45
+ square_on_top_left: bool,
46
+ white_value: int,
47
+ gray_value: int,
48
+ ) -> np.ndarray:
49
+ """Create a chessboard background."""
50
+ bg = np.ones((height, width, 3), dtype=np.uint8) * white_value
51
+ for y in range(0, height, square_size):
52
+ for x in range(0, width, square_size):
53
+ if (y // square_size + x // square_size) % 2 == (
54
+ 1 if square_on_top_left else 0):
55
+ bg[y:y + square_size, x:x + square_size] = gray_value
56
+ return bg
57
+
58
+
59
+ def fill_transparent_bg_with(
60
+ image: Image.Image,
61
+ transparent_bg_config: TransparentBgConfig | None = None,
62
+ ) -> Image.Image:
63
+ """Composite a (possibly) transparent image onto a configured background.
64
+
65
+ When ``transparent_bg_config`` is ``None``, the image is simply converted
66
+ to RGB (preserving the historical behavior). Otherwise the alpha channel
67
+ is alpha-composited over a background generated according to the config.
68
+ """
69
+ if transparent_bg_config is None:
70
+ return image.convert("RGB")
71
+
72
+ if image.mode == "RGB":
73
+ return image
74
+
75
+ has_alpha = "A" in image.getbands() or "transparency" in image.info
76
+ if not has_alpha:
77
+ return image.convert("RGB")
78
+
79
+ img = np.array(image.convert("RGBA"))
80
+ height, width = img.shape[:2]
81
+ bg_pattern = transparent_bg_config.pattern
82
+ if bg_pattern == "white":
83
+ bg = np.full((height, width, 3), 255, dtype=np.uint8)
84
+ elif bg_pattern == "black":
85
+ bg = np.zeros((height, width, 3), dtype=np.uint8)
86
+ elif bg_pattern == "gray":
87
+ bg = np.full((height, width, 3), 128, dtype=np.uint8)
88
+ elif bg_pattern == "chessboard":
89
+ bg = _create_chessboard_background(
90
+ height,
91
+ width,
92
+ transparent_bg_config.chessboard_square_size,
93
+ transparent_bg_config.chessboard_square_on_top_left,
94
+ transparent_bg_config.chessboard_white_value,
95
+ transparent_bg_config.chessboard_gray_value,
96
+ )
97
+ else:
98
+ raise ValueError(f"Invalid background pattern: {bg_pattern}")
99
+
100
+ alpha = img[:, :, 3]
101
+ img_rgb = img[:, :, :3]
102
+ alpha_normalized = alpha.astype(np.float32) / 255.0
103
+ alpha_3d = np.stack([alpha_normalized] * 3, axis=2)
104
+ result = alpha_3d * img_rgb + (1 - alpha_3d) * bg
105
+ result = result.astype(np.uint8)
106
+ return Image.fromarray(result)
107
+
108
+
109
+ def navit_resize_image(
110
+ width: int,
111
+ height: int,
112
+ patch_size: int,
113
+ merge_kernel_size: int,
114
+ in_patch_limit: int,
115
+ patch_limit_on_one_side: int,
116
+ fixed_output_tokens: int | None,
117
+ ):
118
+ # Apply the patch limits.
119
+ s1 = math.sqrt(
120
+ in_patch_limit /
121
+ (max(1.0, width // patch_size) * max(1.0, height // patch_size)))
122
+ s2 = patch_limit_on_one_side * patch_size / width
123
+ s3 = patch_limit_on_one_side * patch_size / height
124
+ scale = min(1.0, s1, s2, s3)
125
+ new_w, new_h = max(1, int(width * scale)), max(1, int(height * scale))
126
+ new_w = min(new_w, patch_limit_on_one_side * patch_size)
127
+ new_h = min(new_h, patch_limit_on_one_side * patch_size)
128
+
129
+ # Calculate the padding to make the height and width divisible by the merge kernel size and patch size.
130
+ factor = merge_kernel_size * patch_size
131
+
132
+ pad_height = (factor - new_h % factor) % factor
133
+ pad_width = (factor - new_w % factor) % factor
134
+
135
+ if fixed_output_tokens is not None:
136
+ num_tokens = fixed_output_tokens
137
+ else:
138
+ # Calculate new dimensions after padding and patching
139
+ token_height = (new_h + pad_height) // factor
140
+ token_width = (new_w + pad_width) // factor
141
+
142
+ assert token_height * merge_kernel_size <= patch_limit_on_one_side, (
143
+ f"token_height {token_height} * merge_kernel_size {merge_kernel_size} > patch_limit_on_one_side {patch_limit_on_one_side}"
144
+ )
145
+ assert token_width * merge_kernel_size <= patch_limit_on_one_side, (
146
+ f"token_width {token_width} * merge_kernel_size {merge_kernel_size} > patch_limit_on_one_side {patch_limit_on_one_side}"
147
+ )
148
+
149
+ num_tokens = token_height * token_width
150
+ return {
151
+ "num_tokens": num_tokens,
152
+ "new_width": new_w,
153
+ "new_height": new_h,
154
+ "pad_width": pad_width,
155
+ "pad_height": pad_height,
156
+ "sampled_nframes": 1,
157
+ }
158
+
159
+
160
+ def _to_pil(
161
+ data: str | bytes | Image.Image,
162
+ transparent_bg_config: TransparentBgConfig | None = None,
163
+ to_rgb: bool = True,
164
+ ) -> Image.Image:
165
+ """Load an image and (optionally) composite its transparent background.
166
+
167
+ Args:
168
+ data: A PIL Image, a base64 ``data:`` URL, a file path, or raw bytes.
169
+ transparent_bg_config: The config used to fill the transparent
170
+ background. ``None`` keeps the historical behavior of converting
171
+ to RGB without compositing.
172
+ to_rgb: If ``False`` the image is returned as-is (the
173
+ ``transparent_bg_config`` is ignored). The caller is then
174
+ expected to call :func:`fill_transparent_bg_with` later — e.g.
175
+ after a resize.
176
+ """
177
+ if isinstance(data, Image.Image):
178
+ image = data
179
+ elif isinstance(data, str):
180
+ if data.startswith("data:"):
181
+ raw_base64 = data.split(",")[1]
182
+ image = Image.open(io.BytesIO(base64.b64decode(raw_base64)))
183
+ else:
184
+ image = Image.open(data)
185
+ elif isinstance(data, bytes):
186
+ image = Image.open(io.BytesIO(data))
187
+ else:
188
+ raise ValueError(f"Unsupported data type: {type(data)}")
189
+
190
+ if not to_rgb:
191
+ return image
192
+
193
+ return fill_transparent_bg_with(image, transparent_bg_config)
194
+
195
+
196
+ def ensure_media_type(
197
+ media: MediaInput,
198
+ transparent_bg_config: TransparentBgConfig | None = None,
199
+ transparent_bg_fill_stage: Literal["before_resize",
200
+ "after_resize"] = "before_resize",
201
+ ) -> MediaInput:
202
+ if media['type'] == 'image':
203
+ media['image'] = _to_pil(
204
+ media['image'],
205
+ transparent_bg_config=transparent_bg_config,
206
+ to_rgb=transparent_bg_fill_stage == "before_resize",
207
+ )
208
+ return media
209
+ else:
210
+ raise ValueError(f"Unsupported media type: {media['type']}")
211
+
212
+
213
+ def image_to_np(
214
+ image: Image.Image,
215
+ resize_to: tuple[int, int] | None = None,
216
+ mode: str = "resize",
217
+ raise_error_for_ill_resize: bool = True,
218
+ transparent_bg_config: TransparentBgConfig | None = None,
219
+ transparent_bg_fill_stage: Literal["before_resize",
220
+ "after_resize"] = "before_resize",
221
+ ) -> np.ndarray:
222
+ """Convert an image to a numpy array.
223
+
224
+ Args:
225
+ content: The image to convert.
226
+ resize_to: The size to resize the image to.
227
+ mode: The mode to resize the image to.
228
+ raise_error_for_ill_resize: Whether to raise an error for ill-sized resize.
229
+ transparent_bg_config: The config of the transparent background. Only
230
+ used when ``transparent_bg_fill_stage == "after_resize"`` (the
231
+ caller is responsible for filling before resize otherwise).
232
+ transparent_bg_fill_stage: When to composite the transparent
233
+ background — before or after the resize step.
234
+
235
+ Returns:
236
+ A numpy array.
237
+ """
238
+ assert isinstance(image, Image.Image), "image must be a PIL Image"
239
+ if resize_to is not None:
240
+ if mode == "resize":
241
+ image = image.resize(resize_to, resample=Image.Resampling.BICUBIC)
242
+ if transparent_bg_fill_stage == "after_resize":
243
+ image = fill_transparent_bg_with(image, transparent_bg_config)
244
+
245
+ elif mode == "rescale_and_pad_to_center":
246
+ scale = min(resize_to[0] / image.width,
247
+ resize_to[1] / image.height, 1.0)
248
+ new_width = round(image.width * scale)
249
+ new_height = round(image.height * scale)
250
+ if new_width == 0 or new_height == 0:
251
+ if raise_error_for_ill_resize:
252
+ raise ValueError(
253
+ f"Invalid resize to: {resize_to}, from image size: {image.size}"
254
+ )
255
+ else:
256
+ return np.zeros((resize_to[1], resize_to[0], 3),
257
+ dtype=np.uint8)
258
+
259
+ image = image.resize((new_width, new_height),
260
+ resample=Image.Resampling.BICUBIC)
261
+ if transparent_bg_fill_stage == "after_resize":
262
+ image = fill_transparent_bg_with(image, transparent_bg_config)
263
+ padding_left = (resize_to[0] - new_width) // 2
264
+ padding_right = resize_to[0] - new_width - padding_left
265
+ padding_top = (resize_to[1] - new_height) // 2
266
+ padding_bottom = resize_to[1] - new_height - padding_top
267
+ image = np.asarray(image)
268
+ image = np.pad(
269
+ image,
270
+ ((padding_top, padding_bottom), (padding_left, padding_right),
271
+ (0, 0)),
272
+ mode="constant",
273
+ constant_values=0,
274
+ )
275
+ assert image.shape == (resize_to[1], resize_to[0], 3)
276
+
277
+ elif mode == "rescale_and_pad_to_rightbottom":
278
+ scale = min(resize_to[0] / image.width,
279
+ resize_to[1] / image.height, 1.0)
280
+ new_width = round(image.width * scale)
281
+ new_height = round(image.height * scale)
282
+ if new_width == 0 or new_height == 0:
283
+ if raise_error_for_ill_resize:
284
+ raise ValueError(
285
+ f"Invalid resize to: {resize_to}, from image size: {image.size}"
286
+ )
287
+ else:
288
+ return np.zeros((resize_to[1], resize_to[0], 3),
289
+ dtype=np.uint8)
290
+
291
+ image = image.resize((new_width, new_height),
292
+ resample=Image.Resampling.BICUBIC)
293
+ if transparent_bg_fill_stage == "after_resize":
294
+ image = fill_transparent_bg_with(image, transparent_bg_config)
295
+ padding_right = resize_to[0] - new_width
296
+ padding_bottom = resize_to[1] - new_height
297
+ image = np.asarray(image)
298
+ image = np.pad(
299
+ image,
300
+ ((0, padding_bottom), (0, padding_right), (0, 0)),
301
+ mode="constant",
302
+ constant_values=0,
303
+ )
304
+ assert image.shape == (resize_to[1], resize_to[0], 3)
305
+
306
+ else:
307
+ raise ValueError(f"Invalid mode: {mode}")
308
+
309
+ if isinstance(image, Image.Image):
310
+ return np.asarray(image)
311
+ else:
312
+ return image
313
+
314
+
315
+ def navit_patchify(pixel_values: np.ndarray,
316
+ patch_size: int) -> dict[str, np.ndarray]:
317
+ """Reshape the pixel values to a navit shape.
318
+
319
+ Args:
320
+ pixel_values: np.ndarray, shape (t, h, w, c)
321
+ patch_size: int
322
+
323
+ Returns:
324
+ dict[str, np.ndarray]
325
+ - patches: np.ndarray, shape (t * h//patch_size * w//patch_size, c, patch_size, patch_size)
326
+ - grid_thw: np.ndarray, (t, h//patch_size, w//patch_size)
327
+ """
328
+ T, H, W, C = pixel_values.shape
329
+ assert C == 3, "pixel_values must have 3 channels"
330
+
331
+ patches = pixel_values.reshape(T, H // patch_size, patch_size,
332
+ W // patch_size, patch_size, C)
333
+ # (T, H//patch_size, W//patch_size, C, patch_size, patch_size)
334
+ patches = patches.transpose(0, 1, 3, 5, 2, 4)
335
+ patches = patches.reshape(-1, C, patch_size, patch_size)
336
+ grid_thw = np.array([T, H // patch_size, W // patch_size])
337
+ return {"pixel_values": patches, "grid_thw": grid_thw}
338
+
339
+
340
+ def normalize(x: np.ndarray,
341
+ mean,
342
+ std_inv,
343
+ pixels_dtype: np.dtype = np.float32) -> np.ndarray:
344
+ """Normalize the image.
345
+
346
+ Args:
347
+ x: The image to normalize. The shape is (..., 3). The dtype is uint8. The range is [0, 255].
348
+ mean: The mean of the image.
349
+ std_inv: The inverse of the std of the image.
350
+ pixels_dtype: The dtype of the image.
351
+ Returns:
352
+ The normalized image. The shape is (..., 3). The dtype is determined by the pixels_dtype.
353
+ """
354
+ x = (x / 255.0).astype(pixels_dtype)
355
+ x -= mean
356
+ x *= std_inv
357
+ return x
358
+
359
+
360
+ def _to_tensor(data, **kwargs):
361
+ import torch
362
+
363
+ if isinstance(data, np.ndarray):
364
+ return torch.from_numpy(data).to(**kwargs)
365
+ elif isinstance(data, torch.Tensor):
366
+ return data.to(**kwargs)
367
+ elif isinstance(data, list):
368
+ return [_to_tensor(item, **kwargs) for item in data]
369
+ elif isinstance(data, tuple):
370
+ return tuple(_to_tensor(item, **kwargs) for item in data)
371
+ elif isinstance(data, dict):
372
+ return {k: _to_tensor(v, **kwargs) for k, v in data.items()}
373
+ elif data is None:
374
+ return None
375
+ else:
376
+ raise ValueError(f"Unsupported data type: {type(data)}")
model-00003-of-00075.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ee2a6f6b3b8818de3fa93332eb9d34557be603e7f7d2bb4630da4f4016b91c8a
3
+ size 5436028813
model-00006-of-00075.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6448dfa1de5cd56e634b6c5acdca9ccc1e7559b73f96609738565e7074626336
3
+ size 6105541561
model-00009-of-00075.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a0fe6e59ab5b98b11e7f9c592e92827ae1e74802aaa99a165b45e5c0d37632e5
3
+ size 6105541618
model-00011-of-00075.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:dcd286a358ee62cc9a4bae5584adb6ed295f0f07c1429388cd0f204563cf984b
3
+ size 5379479293
model-00012-of-00075.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:14fd63b64a66c8bc9749b5f9e1a9b1bd044d0c1531d37b15aff966ad68242f96
3
+ size 6309626568
model-00014-of-00075.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:415f3fb772cf0cfe8a03b3d2edb3b073e69ad045286aa694d4b4929dabada170
3
+ size 6330481117
model-00017-of-00075.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b97fa6ceeb9b0b9a03a973942ba88c1ab2d452a4bfa74feb4498728d6509351c
3
+ size 6330481113
model-00018-of-00075.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f391b109cd133843386191e999944c534bd463e3c8ae70292c916e186b9789db
3
+ size 6330481117
model-00020-of-00075.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fd6c6bb53d90d0702e46999d4bd81cba9b960ca410a1f3c924c356b759f6a8f1
3
+ size 6253076934
model-00025-of-00075.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:36ac781716d59e87c5062adbee45106affb467ce107fb7d32725d9b0915da767
3
+ size 6105541618
model-00029-of-00075.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7b6553530aed30872566fe901550585d0dffefe725017b21d7170680d4ee1b58
3
+ size 6330481117
model-00031-of-00075.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0cbd1012010bafcb00271c19414156c9d74668c677490aab480ee9c0c8de9555
3
+ size 5411251298
model-00032-of-00075.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d1c8bcd6a40a00656f30f78f0efeae50916ede029c40eaafa83599c87d98ca65
3
+ size 6277854537
model-00034-of-00075.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1a0efc2be3d2a3eb00ced9ff009e75f607b8ba2a81469d4a66eb4c670de91c71
3
+ size 6330481117
model-00037-of-00075.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3b85a77ab16156cea9916598e385c350511169777d2f96503566ae29de5ba3f6
3
+ size 6330481117
model-00038-of-00075.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:88bca405e82263f293ec8589927a9947cd6853a818f46c00fefc76d959f9231e
3
+ size 6105541618
model-00041-of-00075.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2dcd9787752bbab263c8c3ad9858417080f839353eda5c0ad28434ecb59a4e04
3
+ size 6105541618
model-00042-of-00075.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:450bb4e324453add694f6240e70a7392cadb651f58993be14e301ffba1298362
3
+ size 6330481113
model-00044-of-00075.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:44d6e2abe1472823323f64b3121f7caec9a4cf60defd2f45575362dd53e118fe
3
+ size 6309626462
model-00047-of-00075.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:074f2e094d6529f2e299d8fa2a3da9a908c8bf1a1333d56e30db1bea874c6698
3
+ size 5411251308
model-00048-of-00075.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:80889374efb3add94a2abb8f370cc4c1eaca8724b1800210ea5a5490883f2ca1
3
+ size 6277854585
model-00053-of-00075.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d1eb7ee303b48d504ec1603a28732775692a7ba63b8ee8894c9f19ceca7d9e1e
3
+ size 6330481117
model-00055-of-00075.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1f0341a8bfa50479923e1f3daa907e9a53d6933f37ccd5eba6e7856942b497f4
3
+ size 5379479249
model-00056-of-00075.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2312406654989559f52b41069b9c8ffb6cd77f3c9efef98be07a07992f90147e
3
+ size 6534566067
model-00061-of-00075.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:28b201cb736c90c8e31231bf5a0e4955a6a6412befeeb586262787566f8436f1
3
+ size 6330481117
model-00064-of-00075.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7271ea6542fcfb833be60c5562066aef5bc0c42d98a1633c7cb28b0d573964b1
3
+ size 6277854585
model-00068-of-00075.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:29ea631bae5696c2d12a6629c0a272178ae0dcd5317d258615f6950f6b1c95a1
3
+ size 6253076934
model-00070-of-00075.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d82aaa0ec50044b5f246da82f776ae4fd8ce763f19cf3059e485bdc852a31104
3
+ size 6105541614
model-00073-of-00075.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fb3b5df114d34f45e4edb9fb3dbd39c96c02f90fbdb223def58c72ef1279d89d
3
+ size 6105541618
model-00075-of-00075.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:da4abec65dd10c84a43b425a45e9c867557eaedab7735991e0f20b05df72e359
3
+ size 2225850591
model.safetensors.index.json ADDED
The diff for this file is too large to render. See raw diff
 
preprocessor_config.json ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "media_proc_cfg": {
3
+ "in_patch_limit": 65536,
4
+ "patch_size": 14,
5
+ "image_mean": [
6
+ 0.5,
7
+ 0.5,
8
+ 0.5
9
+ ],
10
+ "image_std": [
11
+ 0.5,
12
+ 0.5,
13
+ 0.5
14
+ ],
15
+ "merge_kernel_size": 2,
16
+ "fixed_output_tokens": null,
17
+ "patch_limit_on_one_side": 512,
18
+ "in_patch_limit_each_frame": 16384,
19
+ "in_patch_limit_video": 655360,
20
+ "sample_fps": 8.0,
21
+ "max_num_frames_each_video": null,
22
+ "temporal_merge_kernel_size": 4,
23
+ "timestamp_mode": "hh:mm:ss.fff",
24
+ "transparent_bg_config": {
25
+ "pattern": "chessboard",
26
+ "chessboard_square_size": 8,
27
+ "chessboard_square_on_top_left": true,
28
+ "chessboard_white_value": 255,
29
+ "chessboard_gray_value": 180
30
+ },
31
+ "transparent_bg_fill_stage": "after_resize",
32
+ "config_type": "media_proc.processors.moonvit.MoonViTMediaProcessorConfig"
33
+ },
34
+ "auto_map": {
35
+ "AutoProcessor": "kimi_k3_processor.KimiK3Processor",
36
+ "AutoImageProcessor": "kimi_k3_vision_processing.KimiK3VisionProcessor"
37
+ }
38
+ }
tokenization_kimi.py ADDED
@@ -0,0 +1,408 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from logging import getLogger
3
+ from pathlib import Path
4
+ from shutil import copyfile
5
+ from typing import Dict, Iterator, List, Optional, Tuple, Union, cast
6
+
7
+ import tiktoken
8
+ from tiktoken.load import load_tiktoken_bpe
9
+ from tokenizers import AddedToken
10
+ from transformers.convert_slow_tokenizer import bytes_to_unicode
11
+ from transformers.tokenization_utils import PreTrainedTokenizer
12
+
13
+ try:
14
+ from .encoding_k3 import build_chat_segments, is_batched_conversation
15
+ except ImportError: # pragma: no cover - supports direct file execution/import.
16
+ from encoding_k3 import build_chat_segments, is_batched_conversation
17
+
18
+ logger = getLogger(__name__)
19
+ VOCAB_FILES_NAMES = {"vocab_file": "tiktoken.model"}
20
+
21
+
22
+ class TikTokenTokenizer(PreTrainedTokenizer):
23
+ """
24
+ Tokenizing and encoding/decoding text using the Tiktoken tokenizer. See megatron/tokenizer/tiktoken_tokenizer.py.
25
+
26
+ This tokenizer inherits from [`PreTrainedTokenizer`] which contains most of the main methods. Users should refer to
27
+ this superclass for more information regarding those methods.
28
+
29
+ Args:
30
+ vocab_file (`str`):
31
+ The path to the Tiktoken model file.
32
+ bos_token (`str` or `tokenizers.AddedToken`, *optional*, defaults to `"<|begin_of_text|>",`):
33
+ The beginning of sequence token that was used during pretraining. Can be used a sequence classifier token.
34
+ eos_token (`str` or `tokenizers.AddedToken`, *optional*, defaults to `"<|end_of_text|>"`):
35
+ The end of sequence token.
36
+ unk_token (`str` or `tokenizers.AddedToken`, *optional*, defaults to `"<|reserved_special_token_249|>"`):
37
+ The unknown token. A token that is not in the vocabulary cannot be converted to an ID and is set to be this
38
+ token instead. The second to last item in special_tokens.
39
+ pad_token (`str` or `tokenizers.AddedToken`, *optional*, defaults to `"<|reserved_special_token_250|>"`):
40
+ The token used for padding, for example when batching sequences of different lengths.
41
+ additional_special_tokens (list of `str`, *optional*):
42
+ A tuple or a list of additional tokens, which will be marked as `special`, meaning that they will be
43
+ skipped when decoding if `skip_special_tokens` is set to `True`.
44
+ """
45
+
46
+ vocab_files_names = VOCAB_FILES_NAMES
47
+
48
+ model_input_names = ["input_ids", "attention_mask"]
49
+
50
+ special_tokens: Dict[str, int]
51
+
52
+ num_reserved_special_tokens = 256
53
+
54
+ pat_str = "|".join([
55
+ r"""[\p{Han}]+""",
56
+ r"""[^\r\n\p{L}\p{N}]?[\p{Lu}\p{Lt}\p{Lm}\p{Lo}\p{M}&&[^\p{Han}]]*[\p{Ll}\p{Lm}\p{Lo}\p{M}&&[^\p{Han}]]+(?i:'s|'t|'re|'ve|'m|'ll|'d)?""",
57
+ r"""[^\r\n\p{L}\p{N}]?[\p{Lu}\p{Lt}\p{Lm}\p{Lo}\p{M}&&[^\p{Han}]]+[\p{Ll}\p{Lm}\p{Lo}\p{M}&&[^\p{Han}]]*(?i:'s|'t|'re|'ve|'m|'ll|'d)?""",
58
+ r"""\p{N}{1,3}""",
59
+ r""" ?[^\s\p{L}\p{N}]+[\r\n]*""",
60
+ r"""\s*[\r\n]+""",
61
+ r"""\s+(?!\S)""",
62
+ r"""\s+""",
63
+ ])
64
+
65
+ def __init__(
66
+ self,
67
+ vocab_file,
68
+ bos_token: Union[str, AddedToken] = "[BOS]",
69
+ eos_token: Union[str, AddedToken] = "[EOS]",
70
+ unk_token: Union[str, AddedToken, None] = None,
71
+ pad_token: Union[str, AddedToken, None] = None,
72
+ additional_special_tokens: List[str] = None,
73
+ added_tokens_decoder: Optional[dict] = None,
74
+ **kwargs,
75
+ ):
76
+ assert os.path.isfile(vocab_file), vocab_file
77
+
78
+ if additional_special_tokens is None:
79
+ additional_special_tokens = [
80
+ "<|im_end|>",
81
+ "<|im_user|>",
82
+ "<|im_assistant|>",
83
+ "<|start_header_id|>",
84
+ "<|end_header_id|>",
85
+ "[EOT]",
86
+ "<|im_system|>",
87
+ "<|im_middle|>",
88
+ ]
89
+
90
+ if added_tokens_decoder:
91
+ special_tokens_mapping = {
92
+ i: added_tokens_decoder[i].content
93
+ for i in added_tokens_decoder
94
+ }
95
+ else:
96
+ special_tokens_mapping = {}
97
+
98
+ self.vocab_file = vocab_file
99
+ mergeable_ranks = load_tiktoken_bpe(vocab_file)
100
+ num_base_tokens = len(mergeable_ranks)
101
+ self.special_tokens = {
102
+ special_tokens_mapping.get(i, f"<|reserved_token_{i}|>"): i
103
+ for i in range(num_base_tokens, num_base_tokens +
104
+ self.num_reserved_special_tokens)
105
+ }
106
+
107
+ self.model = tiktoken.Encoding(
108
+ name=Path(vocab_file).name,
109
+ pat_str=self.pat_str,
110
+ mergeable_ranks=mergeable_ranks,
111
+ special_tokens=self.special_tokens,
112
+ )
113
+ logger.info(f"Reloaded tiktoken model from {vocab_file}")
114
+
115
+ self.n_words: int = self.model.n_vocab
116
+ # BOS / EOS token IDs
117
+ self.bos_id: int = self.special_tokens[str(bos_token)]
118
+ self.eos_id: int = self.special_tokens[str(eos_token)]
119
+ logger.info(
120
+ f"#words: {self.n_words} - BOS ID: {self.bos_id} - EOS ID: {self.eos_id}"
121
+ )
122
+
123
+ self.pad_id: int = self.special_tokens[str(pad_token)]
124
+ self.unk_id: int = self.special_tokens[str(unk_token)]
125
+
126
+ self.byte_encoder = bytes_to_unicode()
127
+ self.byte_decoder = {v: k for k, v in self.byte_encoder.items()}
128
+
129
+ self.decoder = {}
130
+ for i in range(self.n_words):
131
+ # Taken from https://gist.github.com/xenova/a452a6474428de0182b17605a98631ee
132
+ decoding = ''.join([
133
+ self.byte_encoder[ord(char)] for char in
134
+ self.model.decode_single_token_bytes(i).decode('latin-1')
135
+ ])
136
+ self.decoder[i] = decoding
137
+
138
+ self.encoder = {}
139
+ for i in range(self.n_words):
140
+ if i in self.decoder:
141
+ self.encoder[self.decoder[i]] = i
142
+
143
+ super().__init__(
144
+ bos_token=bos_token,
145
+ eos_token=eos_token,
146
+ unk_token=unk_token,
147
+ pad_token=pad_token,
148
+ additional_special_tokens=additional_special_tokens,
149
+ added_tokens_decoder=added_tokens_decoder,
150
+ **kwargs,
151
+ )
152
+ self.all_special_ids_set = set(self.all_special_ids)
153
+
154
+ def _encode_text_piece(self, text: str,
155
+ allow_special_tokens: bool = True) -> List[int]:
156
+ # The tiktoken tokenizer can handle <=400k chars without
157
+ # pyo3_runtime.PanicException.
158
+ TIKTOKEN_MAX_ENCODE_CHARS = 400_000
159
+
160
+ # https://github.com/openai/tiktoken/issues/195
161
+ # Here we iterate over subsequences and split if we exceed the limit
162
+ # of max consecutive non-whitespace or whitespace characters.
163
+ MAX_NO_WHITESPACES_CHARS = 25_000
164
+
165
+ t: List[int] = []
166
+ for i in range(0, len(text), TIKTOKEN_MAX_ENCODE_CHARS):
167
+ for substr in self._split_whitespaces_or_nonwhitespaces(
168
+ text[i:i + TIKTOKEN_MAX_ENCODE_CHARS],
169
+ MAX_NO_WHITESPACES_CHARS,
170
+ ):
171
+ if allow_special_tokens:
172
+ t.extend(
173
+ # structural markers: encode <|...|> as their special token IDs
174
+ self.model.encode(
175
+ substr,
176
+ allowed_special="all",
177
+ ))
178
+ else:
179
+ t.extend(
180
+ # user/tool text: encode any <|...|> as ordinary BPE tokens (never as control tokens)
181
+ self.model.encode(
182
+ substr,
183
+ disallowed_special=(),
184
+ ))
185
+
186
+ return t
187
+
188
+ def encode(self,
189
+ text: str,
190
+ allow_special_tokens: bool = True,
191
+ **kwargs) -> List[int]:
192
+ """
193
+ Encodes a string into a list of token IDs.
194
+
195
+ Args:
196
+ text (str): The input string to be encoded.
197
+
198
+ Returns:
199
+ list[int]: A list of token IDs.
200
+ """
201
+ # If there are other args, we should call super().encode because there are a lot of code
202
+ # to handle those args. supper().encode finally will call _tokenize and _convert_token_to_id.
203
+ # NOTE: our encode method is not compatible with the super().encode method,
204
+ # e.g. split_special_tokens' default is True in our encode method.
205
+ if len(kwargs) > 0:
206
+ logger.warning(f"Calling super().encode with {kwargs}")
207
+ return super().encode(text, **kwargs)
208
+
209
+ assert type(text) is str
210
+ return self._encode_text_piece(text,
211
+ allow_special_tokens=allow_special_tokens)
212
+
213
+ def decode(self, token_ids: Union[int, List[int]], **kwargs) -> str:
214
+ """
215
+ Decodes a list of token IDs into a string.
216
+
217
+ Args:
218
+ token_ids (List[int]): The list of token IDs to be decoded.
219
+
220
+ Returns:
221
+ str: The decoded string.
222
+ """
223
+ # If there are other args, we should call super().decode because there are a lot of code
224
+ # to handle those args. supper().encode finally will call convert_tokens_to_string and _convert_id_to_token.
225
+ if len(kwargs) > 0:
226
+ return super().decode(token_ids, **kwargs)
227
+
228
+ if type(token_ids) is int:
229
+ token_ids = [token_ids]
230
+
231
+ return self.model.decode(cast(List[int], token_ids))
232
+
233
+ @staticmethod
234
+ def _split_whitespaces_or_nonwhitespaces(
235
+ s: str, max_consecutive_slice_len: int) -> Iterator[str]:
236
+ """
237
+ Splits the string `s` so that each substring contains no more than `max_consecutive_slice_len`
238
+ consecutive whitespaces or consecutive non-whitespaces.
239
+ """
240
+ current_slice_len = 0
241
+ current_slice_is_space = s[0].isspace() if len(s) > 0 else False
242
+ slice_start = 0
243
+
244
+ for i in range(len(s)):
245
+ is_now_space = s[i].isspace()
246
+
247
+ if current_slice_is_space ^ is_now_space:
248
+ current_slice_len = 1
249
+ current_slice_is_space = is_now_space
250
+ else:
251
+ current_slice_len += 1
252
+ if current_slice_len > max_consecutive_slice_len:
253
+ yield s[slice_start:i]
254
+ slice_start = i
255
+ current_slice_len = 1
256
+ yield s[slice_start:]
257
+
258
+ def _encode_chat_segments(self, segments) -> List[int]:
259
+ token_ids: List[int] = []
260
+ for segment in segments:
261
+ token_ids.extend(
262
+ self._encode_text_piece(
263
+ segment.text,
264
+ allow_special_tokens=segment.allow_special,
265
+ ))
266
+ return token_ids
267
+
268
+ @staticmethod
269
+ def _truncate(ids: List[int],
270
+ truncation: bool = False,
271
+ max_length: Optional[int] = None) -> List[int]:
272
+ if truncation and max_length is not None:
273
+ return ids[:max_length]
274
+ return ids
275
+
276
+ def _format_chat_token_output(self,
277
+ encoded_inputs: List[List[int]],
278
+ *,
279
+ is_batched: bool,
280
+ padding=False,
281
+ truncation: bool = False,
282
+ max_length: Optional[int] = None,
283
+ return_tensors=None,
284
+ return_dict: bool = False):
285
+ encoded_inputs = [
286
+ self._truncate(ids, truncation=truncation, max_length=max_length)
287
+ for ids in encoded_inputs
288
+ ]
289
+
290
+ needs_batch_encoding = (
291
+ is_batched or padding or return_tensors is not None or return_dict)
292
+ if not needs_batch_encoding:
293
+ return encoded_inputs[0]
294
+
295
+ features = [{
296
+ "input_ids": ids,
297
+ "attention_mask": [1] * len(ids)
298
+ } for ids in encoded_inputs]
299
+ batch = self.pad(features,
300
+ padding=padding,
301
+ max_length=max_length if padding else None,
302
+ return_attention_mask=True,
303
+ return_tensors=return_tensors)
304
+
305
+ if return_dict:
306
+ return batch
307
+ if is_batched:
308
+ return batch["input_ids"]
309
+ return batch["input_ids"][0] if return_tensors is None else batch[
310
+ "input_ids"]
311
+
312
+ """ ----- Below are the abstract methods required by PreTrainedTokenizer ----- """
313
+
314
+ @property
315
+ def vocab_size(self) -> int:
316
+ return self.n_words
317
+
318
+ def get_vocab(self) -> Dict[str, int]:
319
+ return self.encoder
320
+
321
+ def _tokenize(self, text: str, **kwargs) -> List[str]:
322
+ return [self.decoder[t] for t in self.encode(text)]
323
+
324
+ def _convert_token_to_id(self, token: str) -> int:
325
+ return self.encoder.get(token, self.unk_id)
326
+
327
+ def _convert_id_to_token(self, index: int) -> str:
328
+ return self.decoder.get(index)
329
+
330
+ @staticmethod
331
+ def clean_up_tokenization(out_string: str) -> str:
332
+ return out_string
333
+
334
+ def convert_tokens_to_string(self, tokens: List[str]) -> str:
335
+ text = ''.join(tokens)
336
+ text = bytearray([self.byte_decoder[c]
337
+ for c in text]).decode('utf-8', 'replace')
338
+ return text
339
+
340
+ def save_vocabulary(self,
341
+ save_directory: str,
342
+ filename_prefix: Optional[str] = None) -> Tuple[str]:
343
+ if not os.path.isdir(save_directory):
344
+ raise ValueError(
345
+ f"vocabulary path ({save_directory}) should be a directory")
346
+ out_vocab_file = os.path.join(
347
+ save_directory,
348
+ (filename_prefix + "-" if filename_prefix else "") +
349
+ VOCAB_FILES_NAMES["vocab_file"])
350
+
351
+ if os.path.abspath(self.vocab_file) != os.path.abspath(
352
+ out_vocab_file) and os.path.isfile(self.vocab_file):
353
+ copyfile(self.vocab_file, out_vocab_file)
354
+
355
+ return (out_vocab_file, )
356
+
357
+ def apply_chat_template(self,
358
+ conversation,
359
+ tools: Optional[list[dict]] = None,
360
+ tokenize: bool = False,
361
+ add_generation_prompt: bool = True,
362
+ thinking: bool = True,
363
+ padding=False,
364
+ truncation: bool = False,
365
+ max_length: Optional[int] = None,
366
+ return_tensors=None,
367
+ return_dict: bool = False,
368
+ **kwargs):
369
+ # Tokenizer-level rendering reorders tool result messages to match
370
+ # assistant tool_calls, normalizes per-call arguments and response
371
+ # schema, then encodes the resulting XTML structure segment-by-segment.
372
+ is_batched = is_batched_conversation(conversation)
373
+ conversations = conversation if is_batched else [conversation]
374
+ image_prompts = kwargs.pop("image_prompts", None)
375
+ if is_batched and image_prompts is not None:
376
+ raise ValueError("image_prompts is only supported for one chat.")
377
+
378
+ # by default set thinking effort to max
379
+ kwargs.setdefault("thinking_effort", "max")
380
+
381
+ segment_batches = [
382
+ build_chat_segments(
383
+ messages,
384
+ tools=tools,
385
+ add_generation_prompt=add_generation_prompt,
386
+ thinking=thinking,
387
+ image_prompts=image_prompts,
388
+ **kwargs,
389
+ ) for messages in conversations
390
+ ]
391
+
392
+ if not tokenize:
393
+ rendered = ["".join(segment.text for segment in segments)
394
+ for segments in segment_batches]
395
+ return rendered if is_batched else rendered[0]
396
+
397
+ encoded_inputs = [
398
+ self._encode_chat_segments(segments) for segments in segment_batches
399
+ ]
400
+ return self._format_chat_token_output(
401
+ encoded_inputs,
402
+ is_batched=is_batched,
403
+ padding=padding,
404
+ truncation=truncation,
405
+ max_length=max_length,
406
+ return_tensors=return_tensors,
407
+ return_dict=return_dict,
408
+ )
tokenizer_config.json ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "added_tokens_decoder": {
3
+ "163584": {
4
+ "content": "[BOS]",
5
+ "lstrip": false,
6
+ "normalized": false,
7
+ "rstrip": false,
8
+ "single_word": false,
9
+ "special": true
10
+ },
11
+ "163585": {
12
+ "content": "[EOS]",
13
+ "lstrip": false,
14
+ "normalized": false,
15
+ "rstrip": false,
16
+ "single_word": false,
17
+ "special": true
18
+ },
19
+ "163586": {
20
+ "content": "<|end_of_msg|>",
21
+ "lstrip": false,
22
+ "normalized": false,
23
+ "rstrip": false,
24
+ "single_word": false,
25
+ "special": true
26
+ },
27
+ "163587": {
28
+ "content": "<|open|>",
29
+ "lstrip": false,
30
+ "normalized": false,
31
+ "rstrip": false,
32
+ "single_word": false,
33
+ "special": false
34
+ },
35
+ "163588": {
36
+ "content": "<|close|>",
37
+ "lstrip": false,
38
+ "normalized": false,
39
+ "rstrip": false,
40
+ "single_word": false,
41
+ "special": false
42
+ },
43
+ "163589": {
44
+ "content": "<|sep|>",
45
+ "lstrip": false,
46
+ "normalized": false,
47
+ "rstrip": false,
48
+ "single_word": false,
49
+ "special": false
50
+ },
51
+ "163590": {
52
+ "content": "[start_header_id]",
53
+ "lstrip": false,
54
+ "normalized": false,
55
+ "rstrip": false,
56
+ "single_word": false,
57
+ "special": true
58
+ },
59
+ "163591": {
60
+ "content": "[end_header_id]",
61
+ "lstrip": false,
62
+ "normalized": false,
63
+ "rstrip": false,
64
+ "single_word": false,
65
+ "special": true
66
+ },
67
+ "163593": {
68
+ "content": "[EOT]",
69
+ "lstrip": false,
70
+ "normalized": false,
71
+ "rstrip": false,
72
+ "single_word": false,
73
+ "special": true
74
+ },
75
+ "163602": {
76
+ "content": "<|media_begin|>",
77
+ "lstrip": false,
78
+ "normalized": false,
79
+ "rstrip": false,
80
+ "single_word": false,
81
+ "special": true
82
+ },
83
+ "163603": {
84
+ "content": "<|media_content|>",
85
+ "lstrip": false,
86
+ "normalized": false,
87
+ "rstrip": false,
88
+ "single_word": false,
89
+ "special": true
90
+ },
91
+ "163604": {
92
+ "content": "<|media_end|>",
93
+ "lstrip": false,
94
+ "normalized": false,
95
+ "rstrip": false,
96
+ "single_word": false,
97
+ "special": true
98
+ },
99
+ "163605": {
100
+ "content": "<|media_pad|>",
101
+ "lstrip": false,
102
+ "normalized": false,
103
+ "rstrip": false,
104
+ "single_word": false,
105
+ "special": true
106
+ },
107
+ "163649": {
108
+ "content": "<osagent_mode>",
109
+ "lstrip": false,
110
+ "normalized": false,
111
+ "rstrip": false,
112
+ "single_word": false,
113
+ "special": true
114
+ },
115
+ "163838": {
116
+ "content": "[UNK]",
117
+ "lstrip": false,
118
+ "normalized": false,
119
+ "rstrip": false,
120
+ "single_word": false,
121
+ "special": true
122
+ },
123
+ "163839": {
124
+ "content": "[PAD]",
125
+ "lstrip": false,
126
+ "normalized": false,
127
+ "rstrip": false,
128
+ "single_word": false,
129
+ "special": true
130
+ }
131
+ },
132
+ "additional_special_tokens": [
133
+ "<|end_of_msg|>",
134
+ "[start_header_id]",
135
+ "[end_header_id]",
136
+ "[EOT]",
137
+ "<|media_begin|>",
138
+ "<|media_content|>",
139
+ "<|media_end|>",
140
+ "<|media_pad|>",
141
+ "<osagent_mode>"
142
+ ],
143
+ "bos_token": "[BOS]",
144
+ "clean_up_tokenization_spaces": false,
145
+ "eos_token": "[EOS]",
146
+ "extra_special_tokens": {},
147
+ "model_max_length": 1000000000000000019884624838656,
148
+ "pad_token": "[PAD]",
149
+ "tokenizer_class": "TikTokenTokenizer",
150
+ "unk_token": "[UNK]",
151
+ "auto_map": {
152
+ "AutoTokenizer": [
153
+ "tokenization_kimi.TikTokenTokenizer",
154
+ null
155
+ ]
156
+ }
157
+ }