pipenetwork commited on
Commit
6f3ca96
·
verified ·
1 Parent(s): cabc512

Add files using upload-large-folder tool

Browse files
Files changed (50) hide show
  1. README.md +103 -0
  2. config.json +116 -0
  3. deepseek_v4_mlx/attention.py +239 -0
  4. deepseek_v4_mlx/cache.py +142 -0
  5. deepseek_v4_mlx/compressor.py +154 -0
  6. deepseek_v4_mlx/config.py +152 -0
  7. deepseek_v4_mlx/convert.py +218 -0
  8. deepseek_v4_mlx/dequant.py +92 -0
  9. deepseek_v4_mlx/fakequant.py +95 -0
  10. deepseek_v4_mlx/generate.py +37 -0
  11. deepseek_v4_mlx/hyper_connections.py +118 -0
  12. deepseek_v4_mlx/indexer.py +113 -0
  13. deepseek_v4_mlx/layers.py +132 -0
  14. deepseek_v4_mlx/load.py +68 -0
  15. deepseek_v4_mlx/model.py +102 -0
  16. deepseek_v4_mlx/moe.py +140 -0
  17. deepseek_v4_mlx/sparse_attention.py +71 -0
  18. encoding/README.md +174 -0
  19. encoding/encoding_dsv4.py +760 -0
  20. encoding/test_encoding_dsv4.py +89 -0
  21. encoding/tests/test_input_1.json +81 -0
  22. encoding/tests/test_input_2.json +24 -0
  23. encoding/tests/test_input_3.json +159 -0
  24. encoding/tests/test_input_4.json +28 -0
  25. encoding/tests/test_output_1.txt +36 -0
  26. encoding/tests/test_output_2.txt +1 -0
  27. encoding/tests/test_output_3.txt +38 -0
  28. encoding/tests/test_output_4.txt +29 -0
  29. generation_config.json +9 -0
  30. model-00001-of-00033.safetensors +3 -0
  31. model-00004-of-00033.safetensors +3 -0
  32. model-00005-of-00033.safetensors +3 -0
  33. model-00011-of-00033.safetensors +3 -0
  34. model-00012-of-00033.safetensors +3 -0
  35. model-00013-of-00033.safetensors +3 -0
  36. model-00014-of-00033.safetensors +3 -0
  37. model-00016-of-00033.safetensors +3 -0
  38. model-00017-of-00033.safetensors +3 -0
  39. model-00018-of-00033.safetensors +3 -0
  40. model-00019-of-00033.safetensors +3 -0
  41. model-00020-of-00033.safetensors +3 -0
  42. model-00021-of-00033.safetensors +3 -0
  43. model-00024-of-00033.safetensors +3 -0
  44. model-00025-of-00033.safetensors +3 -0
  45. model-00026-of-00033.safetensors +3 -0
  46. model-00032-of-00033.safetensors +3 -0
  47. model-00033-of-00033.safetensors +3 -0
  48. model.safetensors.index.json +0 -0
  49. tokenizer.json +0 -0
  50. tokenizer_config.json +34 -0
README.md ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ base_model: deepseek-ai/DeepSeek-V4-Flash-0731
4
+ base_model_relation: quantized
5
+ pipeline_tag: text-generation
6
+ library_name: mlx
7
+ tags:
8
+ - mlx
9
+ - moe
10
+ - deepseek
11
+ - deepseek-v4
12
+ ---
13
+
14
+ # DeepSeek-V4-Flash-MLX-mixed-4_8bit
15
+
16
+ MLX (Apple Silicon) conversion of [deepseek-ai/DeepSeek-V4-Flash-0731](https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash-0731),
17
+ quantized with **4-bit routed experts and 8-bit everything else** (affine group quant, group size 64).
18
+
19
+ **Code / loader:** [github.com/PipeNetwork/deepseek-v4-mlx](https://github.com/PipeNetwork/deepseek-v4-mlx)
20
+
21
+ DeepSeek-V4-Flash is a **304B-parameter** sparse-MoE model. Its `deepseek_v4`
22
+ architecture is **not** implemented in any released runtime — not `transformers`,
23
+ and not `mlx-lm` (which carries `deepseek`, `_v2`, `_v3`, `_v32`). This repo bundles
24
+ a from-scratch MLX port under `deepseek_v4_mlx/`, validated against DeepSeek's own
25
+ reference implementation.
26
+
27
+ ## Builds
28
+
29
+ | Variant | Size | wikitext-2 ppl | Notes |
30
+ |---|---:|---:|---|
31
+ | [8bit](https://huggingface.co/pipenetwork/DeepSeek-V4-Flash-MLX-8bit) | ~304 GB | 5.9878 | highest fidelity |
32
+ | [6bit](https://huggingface.co/pipenetwork/DeepSeek-V4-Flash-MLX-6bit) | ~233 GB | 6.0260 | +0.6% vs 8-bit for 70 GB less |
33
+ | [mixed-4_8bit](https://huggingface.co/pipenetwork/DeepSeek-V4-Flash-MLX-mixed-4_8bit) | ~165 GB | 6.1262 | **recommended** — 4-bit experts, 8-bit everything else |
34
+ | [4bit](https://huggingface.co/pipenetwork/DeepSeek-V4-Flash-MLX-4bit) | ~162 GB | 6.3005 | smallest; +5.2% vs 8-bit |
35
+
36
+ Perplexity is teacher-forced over **wikitext-2-raw-v1 test**, 199,485 tokens in 195
37
+ windows of 1024 — identical windows for every build. Differences are established by
38
+ a **paired** bootstrap over shared windows, which removes window-difficulty variance:
39
+ independent intervals overlap almost entirely and settle nothing, while the paired
40
+ interval is ~10x tighter. Every gap quoted here is significant at 95%.
41
+
42
+ ### Why `mixed-4_8bit` is the recommended build
43
+
44
+ The routed experts are **98.1%** of all quantized parameters, and they ship natively
45
+ as FP4. Keeping them at 4 bits while raising everything else — attention,
46
+ embeddings, shared experts, about 5B parameters — to 8 bits costs **3 GB** and
47
+ recovers **56%** of the gap between 4-bit and 8-bit. Per parameter, the non-expert
48
+ weights are roughly 50x more quantization-sensitive.
49
+
50
+ Two variants were built, measured, and **not** published:
51
+
52
+ * **group-size 32** (matching FP4's native 32-value scale blocks) is 0.5% *worse*
53
+ than group 64 and 18 GB larger. Finer scaling improves raw tensor reconstruction
54
+ but not prediction — the mismatch is that FP4's levels are non-uniform while
55
+ affine's are evenly spaced, which no group size fixes.
56
+ * **3-bit experts** collapse the model entirely (perplexity 1.4e7, degenerate token
57
+ loops). 4-bit sits just above a cliff for this checkpoint, not in the middle of a
58
+ gentle curve.
59
+
60
+ ## ⚠️ Loading requires the bundled loader
61
+
62
+ ```bash
63
+ pip install mlx mlx-lm transformers
64
+ ```
65
+ ```python
66
+ from deepseek_v4_mlx.load import load
67
+ from deepseek_v4_mlx.generate import greedy_generate, load_tokenizer
68
+
69
+ model, args = load("/path/to/this/repo")
70
+ tok = load_tokenizer() # DeepSeek-V3's tokenizer — see below
71
+ ids = tok("The capital of France is")["input_ids"]
72
+ print(tok.decode(greedy_generate(model, args, ids, max_new_tokens=32)))
73
+ ```
74
+
75
+ **The base repo ships no tokenizer** — only `encoding/encoding_dsv4.py`, which renders
76
+ prompt *strings*. DeepSeek-V3's tokenizer is the correct one: same padded `vocab_size`
77
+ 129280 over 128815 real tokens, `bos=0`/`eos=1` matching V4's `generation_config.json`,
78
+ and every V4 special token present.
79
+
80
+ ## What this port implements
81
+
82
+ | Mechanism | Notes |
83
+ |---|---|
84
+ | **Hyper-Connections** | the residual stream carries **4 parallel copies**, mixed by a Sinkhorn-normalized combination matrix |
85
+ | **Hash-routed experts** | the first 3 MoE layers pick experts from a `tid2eid[vocab, 6]` table keyed on token id — routing independent of context |
86
+ | **Learned KV compression** | gated pooling over spans, alternating ratio 4/128, overlapping windows at ratio 4 |
87
+ | **Sparse-attention indexer** | scores compressed spans, keeps the top 512 for attention to read |
88
+ | **MLA + grouped output LoRA** | single shared KV vector; `o_groups: 8` block-diagonal output projection |
89
+ | **sqrt-softplus routing**, clamped SwiGLU, attention sinks | |
90
+
91
+ The source checkpoint is **doubly pre-quantized** — FP8 `e4m3` with `e8m0` 128x128
92
+ block scales for most matmuls, FP4 `e2m1` packed two-per-byte for routed experts — so
93
+ conversion dequantizes both formats before requantizing. Both decode paths are
94
+ bit-exact against an independent implementation.
95
+
96
+ Dropped: the multi-token-prediction head and **DSpark** (speculative decode over
97
+ 5-token blocks in the last three layers). Both hang off the reference's `forward_spec`
98
+ path and are unused for ordinary generation.
99
+
100
+ `wo_a` is kept unquantized: it is a block-diagonal projection the model reshapes by
101
+ group, and a packed quantized weight cannot be reshaped that way. Costs ~2% of build size.
102
+
103
+ License: MIT (inherits the base model).
config.json ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "DeepseekV4ForCausalLM"
4
+ ],
5
+ "attention_bias": false,
6
+ "attention_dropout": 0.0,
7
+ "bos_token_id": 0,
8
+ "eos_token_id": 1,
9
+ "hc_eps": 1e-06,
10
+ "hc_mult": 4,
11
+ "hc_sinkhorn_iters": 20,
12
+ "head_dim": 512,
13
+ "hidden_act": "silu",
14
+ "hidden_size": 4096,
15
+ "index_head_dim": 128,
16
+ "index_n_heads": 64,
17
+ "index_topk": 512,
18
+ "initializer_range": 0.02,
19
+ "max_position_embeddings": 1048576,
20
+ "model_type": "deepseek_v4",
21
+ "moe_intermediate_size": 2048,
22
+ "n_routed_experts": 256,
23
+ "n_shared_experts": 1,
24
+ "norm_topk_prob": true,
25
+ "num_attention_heads": 64,
26
+ "num_experts_per_tok": 6,
27
+ "num_hidden_layers": 43,
28
+ "num_hash_layers": 3,
29
+ "num_key_value_heads": 1,
30
+ "num_nextn_predict_layers": 1,
31
+ "o_groups": 8,
32
+ "o_lora_rank": 1024,
33
+ "q_lora_rank": 1024,
34
+ "qk_rope_head_dim": 64,
35
+ "rms_norm_eps": 1e-06,
36
+ "rope_scaling": {
37
+ "beta_fast": 32,
38
+ "beta_slow": 1,
39
+ "factor": 16,
40
+ "original_max_position_embeddings": 65536,
41
+ "type": "yarn"
42
+ },
43
+ "rope_theta": 10000,
44
+ "routed_scaling_factor": 1.5,
45
+ "scoring_func": "sqrtsoftplus",
46
+ "sliding_window": 128,
47
+ "swiglu_limit": 10.0,
48
+ "tie_word_embeddings": false,
49
+ "topk_method": "noaux_tc",
50
+ "torch_dtype": "bfloat16",
51
+ "transformers_version": "4.57.1",
52
+ "use_cache": true,
53
+ "vocab_size": 129280,
54
+ "compress_rope_theta": 160000,
55
+ "compress_ratios": [
56
+ 0,
57
+ 0,
58
+ 4,
59
+ 128,
60
+ 4,
61
+ 128,
62
+ 4,
63
+ 128,
64
+ 4,
65
+ 128,
66
+ 4,
67
+ 128,
68
+ 4,
69
+ 128,
70
+ 4,
71
+ 128,
72
+ 4,
73
+ 128,
74
+ 4,
75
+ 128,
76
+ 4,
77
+ 128,
78
+ 4,
79
+ 128,
80
+ 4,
81
+ 128,
82
+ 4,
83
+ 128,
84
+ 4,
85
+ 128,
86
+ 4,
87
+ 128,
88
+ 4,
89
+ 128,
90
+ 4,
91
+ 128,
92
+ 4,
93
+ 128,
94
+ 4,
95
+ 128,
96
+ 4,
97
+ 128,
98
+ 4,
99
+ 0,
100
+ 0,
101
+ 0
102
+ ],
103
+ "dspark_block_size": 5,
104
+ "dspark_noise_token_id": 128799,
105
+ "dspark_target_layer_ids": [
106
+ 40,
107
+ 41,
108
+ 42
109
+ ],
110
+ "dspark_markov_rank": 256,
111
+ "quantization": {
112
+ "group_size": 64,
113
+ "bits": 8,
114
+ "expert_bits": 4
115
+ }
116
+ }
deepseek_v4_mlx/attention.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DeepSeek-V4 attention — MLA over a sliding window plus selected compressed spans.
2
+
3
+ Shape of one layer:
4
+
5
+ * queries go through a low-rank bottleneck (``wq_a`` -> ``q_norm`` -> ``wq_b``),
6
+ then get a **weightless** per-head RMS normalization;
7
+ * a single shared KV vector per position (MLA) — key and value are the same tensor;
8
+ * every layer attends to the last ``window_size`` tokens; compression layers also
9
+ attend to a set of pooled spans, chosen by :class:`Indexer` at ratio 4 or taken
10
+ wholesale at ratio 128;
11
+ * the output has the rotary embedding applied **in reverse** before a grouped
12
+ low-rank projection (``wo_a`` per group, then a shared ``wo_b``).
13
+
14
+ Both paths live here. ``__call__`` prefills a whole sequence and, given a cache,
15
+ leaves it in the state decode would have produced; ``step`` decodes one token
16
+ against that cache. They share the q/kv/output projections so the two cannot drift
17
+ apart.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import mlx.core as mx
23
+ import mlx.nn as nn
24
+
25
+ from .compressor import Compressor
26
+ from .config import ModelArgs
27
+ from .fakequant import fake_quant_kv
28
+ from .indexer import Indexer
29
+ from .layers import RMSNorm, apply_rotary_emb, precompute_freqs_cis
30
+ from .sparse_attention import sparse_attn
31
+
32
+
33
+ def window_topk_idxs(window_size: int, seqlen: int) -> mx.array:
34
+ """[s, w] indices of the local window for each query, -1 where out of range."""
35
+ base = mx.arange(seqlen).reshape(-1, 1)
36
+ w = min(seqlen, window_size)
37
+ idx = mx.maximum(base - window_size + 1, 0) + mx.arange(w).reshape(1, -1)
38
+ return mx.where(idx > base, mx.array(-1, mx.int32), idx.astype(mx.int32))
39
+
40
+
41
+ def compress_topk_idxs(ratio: int, seqlen: int, offset: int) -> mx.array:
42
+ """[s, s//ratio] indices of every *complete* span, -1 where not yet visible."""
43
+ nb = seqlen // ratio
44
+ m = mx.broadcast_to(mx.arange(nb).reshape(1, -1), (seqlen, nb))
45
+ vis = m < (mx.arange(1, seqlen + 1).reshape(-1, 1) // ratio)
46
+ return mx.where(vis, (m + offset).astype(mx.int32), mx.array(-1, mx.int32))
47
+
48
+
49
+ class Attention(nn.Module):
50
+ def __init__(self, layer_id: int, args: ModelArgs):
51
+ super().__init__()
52
+ self.layer_id = layer_id
53
+ self.dim = args.dim
54
+ self.n_heads = args.n_heads
55
+ self.head_dim = args.head_dim
56
+ self.rope_head_dim = args.rope_head_dim
57
+ self.q_lora_rank = args.q_lora_rank
58
+ self.o_lora_rank = args.o_lora_rank
59
+ self.n_groups = args.o_groups
60
+ self.window_size = args.window_size
61
+ self.compress_ratio = args.compress_ratio(layer_id)
62
+ self.eps = args.norm_eps
63
+ self.softmax_scale = args.head_dim ** -0.5
64
+
65
+ self.attn_sink = mx.zeros((self.n_heads,), dtype=mx.float32)
66
+ self.wq_a = nn.Linear(self.dim, self.q_lora_rank, bias=False)
67
+ self.q_norm = RMSNorm(self.q_lora_rank, self.eps)
68
+ self.wq_b = nn.Linear(self.q_lora_rank, self.n_heads * self.head_dim, bias=False)
69
+ self.wkv = nn.Linear(self.dim, self.head_dim, bias=False)
70
+ self.kv_norm = RMSNorm(self.head_dim, self.eps)
71
+ self.wo_a = nn.Linear(self.n_heads * self.head_dim // self.n_groups,
72
+ self.n_groups * self.o_lora_rank, bias=False)
73
+ self.wo_b = nn.Linear(self.n_groups * self.o_lora_rank, self.dim, bias=False)
74
+
75
+ if self.compress_ratio:
76
+ self.compressor = Compressor(args, self.compress_ratio, self.head_dim)
77
+ self.indexer = Indexer(args, self.compress_ratio) if self.compress_ratio == 4 else None
78
+
79
+ # compression layers use YaRN on a longer rope base; pure sliding-window
80
+ # layers disable YaRN entirely and use the standard base
81
+ if self.compress_ratio:
82
+ orig_len, theta = args.original_seq_len, args.compress_rope_theta
83
+ else:
84
+ orig_len, theta = 0, args.rope_theta
85
+ self._rope = (args.rope_head_dim, orig_len, theta, args.rope_factor,
86
+ args.beta_fast, args.beta_slow)
87
+ self._cos = None
88
+ self._sin = None
89
+
90
+ def _freqs(self, seqlen: int):
91
+ if self._cos is None or self._cos.shape[0] < seqlen:
92
+ rd, orig_len, theta, factor, bf, bs = self._rope
93
+ self._cos, self._sin = precompute_freqs_cis(rd, seqlen, orig_len, theta,
94
+ factor, bf, bs)
95
+ return self._cos[:seqlen], self._sin[:seqlen]
96
+
97
+ def _project_q(self, x, cos, sin, pos0: int):
98
+ """Shared query path: low-rank, weightless per-head RMS, rope."""
99
+ bsz, seqlen, _ = x.shape
100
+ rd = self.rope_head_dim
101
+ qr = self.q_norm(self.wq_a(x))
102
+ q = self.wq_b(qr).reshape(bsz, seqlen, self.n_heads, self.head_dim)
103
+ qf = q.astype(mx.float32)
104
+ q = (qf * mx.rsqrt(mx.mean(mx.square(qf), axis=-1, keepdims=True) + self.eps)).astype(q.dtype)
105
+ c, s = cos[pos0:pos0 + seqlen], sin[pos0:pos0 + seqlen]
106
+ q = mx.concatenate([q[..., :-rd], apply_rotary_emb(q[..., -rd:], c, s)], axis=-1)
107
+ return q, qr
108
+
109
+ def _project_kv(self, x, cos, sin, pos0: int):
110
+ """Shared KV path: single MLA vector, rope on the tail, FP8 on the rest."""
111
+ rd = self.rope_head_dim
112
+ seqlen = x.shape[1]
113
+ kv = self.kv_norm(self.wkv(x))
114
+ c, s = cos[pos0:pos0 + seqlen], sin[pos0:pos0 + seqlen]
115
+ kv = mx.concatenate([kv[..., :-rd], apply_rotary_emb(kv[..., -rd:], c, s)], axis=-1)
116
+ return fake_quant_kv(kv, rd, 64)
117
+
118
+ def _out(self, o, cos, sin, pos0: int, bsz: int, seqlen: int, dtype):
119
+ """Inverse rope, then the grouped low-rank projection.
120
+
121
+ ``dtype`` is the caller's working precision — hardcoding bf16 here silently
122
+ downcasts an fp32 forward.
123
+ """
124
+ rd = self.rope_head_dim
125
+ c, s = cos[pos0:pos0 + seqlen], sin[pos0:pos0 + seqlen]
126
+ o = mx.concatenate([o[..., :-rd], apply_rotary_emb(o[..., -rd:], c, s, inverse=True)],
127
+ axis=-1)
128
+ o = o.reshape(bsz, seqlen, self.n_groups, -1)
129
+ wo_a = self.wo_a.weight.reshape(self.n_groups, self.o_lora_rank, -1)
130
+ o = mx.einsum("bsgd,grd->bsgr", o.astype(mx.float32), wo_a.astype(mx.float32))
131
+ return self.wo_b(o.reshape(bsz, seqlen, -1).astype(dtype))
132
+
133
+ def step(self, x: mx.array, cache) -> mx.array:
134
+ """Decode one token against ``cache``. ``x`` is [b, 1, dim]."""
135
+ bsz = x.shape[0]
136
+ pos = cache.offset
137
+ cos, sin = self._freqs(pos + 1)
138
+
139
+ q, qr = self._project_q(x, cos, sin, pos)
140
+ kv = self._project_kv(x, cos, sin, pos)
141
+ cache.put_window(pos, kv[:, 0])
142
+
143
+ idxs = cache.window_idxs(pos)[None, None, :]
144
+ if self.compress_ratio:
145
+ if self.indexer is not None:
146
+ cidx = self.indexer.step(x, qr, cos, sin, pos, cache, self.window_size)
147
+ else:
148
+ ci = cache.compressed_idxs(pos)
149
+ cidx = ci[None, None, :] if ci.size else None
150
+ pooled = self.compressor.step(x, pos, cache.comp, cos, sin)
151
+ if pooled is not None:
152
+ cache.put_compressed(pos // self.compress_ratio, pooled[:, 0])
153
+ if cidx is not None and cidx.shape[-1] > 0:
154
+ idxs = mx.concatenate([mx.broadcast_to(idxs, (bsz, 1, idxs.shape[-1])),
155
+ mx.broadcast_to(cidx, (bsz, 1, cidx.shape[-1]))], axis=-1)
156
+
157
+ idxs = mx.broadcast_to(idxs, (bsz, 1, idxs.shape[-1]))
158
+ o = sparse_attn(q, cache.kv, self.attn_sink, idxs, self.softmax_scale)
159
+ out = self._out(o, cos, sin, pos, bsz, 1, x.dtype)
160
+ cache.offset = pos + 1
161
+ return out
162
+
163
+ def __call__(self, x: mx.array, cache=None) -> mx.array:
164
+ if cache is not None and x.shape[1] == 1 and cache.offset > 0:
165
+ return self.step(x, cache)
166
+ bsz, seqlen, _ = x.shape
167
+ rd = self.rope_head_dim
168
+ cos, sin = self._freqs(seqlen)
169
+
170
+ # --- queries: low-rank, then a weightless per-head RMS ---
171
+ qr = self.q_norm(self.wq_a(x))
172
+ q = self.wq_b(qr).reshape(bsz, seqlen, self.n_heads, self.head_dim)
173
+ qf = q.astype(mx.float32)
174
+ q = (qf * mx.rsqrt(mx.mean(mx.square(qf), axis=-1, keepdims=True) + self.eps)).astype(q.dtype)
175
+ q = mx.concatenate([q[..., :-rd], apply_rotary_emb(q[..., -rd:], cos, sin)], axis=-1)
176
+
177
+ # --- shared KV (MLA) ---
178
+ kv = self.kv_norm(self.wkv(x))
179
+ kv = mx.concatenate([kv[..., :-rd], apply_rotary_emb(kv[..., -rd:], cos, sin)], axis=-1)
180
+ kv = fake_quant_kv(kv, rd, 64)
181
+
182
+ idxs = mx.broadcast_to(window_topk_idxs(self.window_size, seqlen)[None],
183
+ (bsz, seqlen, min(seqlen, self.window_size)))
184
+
185
+ if self.compress_ratio:
186
+ offset = seqlen # compressed entries follow the window KV
187
+ if self.indexer is not None:
188
+ cidx = self.indexer(x, qr, cos, sin, offset)
189
+ else:
190
+ cidx = mx.broadcast_to(
191
+ compress_topk_idxs(self.compress_ratio, seqlen, offset)[None],
192
+ (bsz, seqlen, seqlen // self.compress_ratio))
193
+ if cidx is not None:
194
+ idxs = mx.concatenate([idxs, cidx], axis=-1)
195
+ kv_c = self.compressor(x, cos, sin)
196
+ if kv_c is not None:
197
+ kv = mx.concatenate([kv, kv_c], axis=1)
198
+
199
+ o = sparse_attn(q, kv, self.attn_sink, idxs, self.softmax_scale)
200
+
201
+ if cache is not None:
202
+ self._seed_cache(cache, x, kv, seqlen, cos, sin)
203
+
204
+ return self._out(o, cos, sin, 0, bsz, seqlen, x.dtype)
205
+
206
+ def _seed_cache(self, cache, x, kv, seqlen, cos, sin):
207
+ """Leave the cache in exactly the state decode would have produced.
208
+
209
+ Prefill pools spans in one shot, so the compressors' rolling state has to be
210
+ replayed. Replay starts at the **last complete span**, not merely at the
211
+ remainder: for overlapping (ratio-4) compressors, closing that span is what
212
+ moves it into the "previous span" half of the state, which the next pooling
213
+ reads. Starting at the remainder would leave that half empty and quietly
214
+ change the first few decoded tokens.
215
+
216
+ Slots beyond the replayed remainder keep stale values, which is harmless —
217
+ a span only closes once every slot in the current half has been rewritten.
218
+ """
219
+ cache.put_window_prefill(kv[:, :seqlen])
220
+ if not self.compress_ratio:
221
+ cache.offset = seqlen
222
+ return
223
+
224
+ ratio = self.compress_ratio
225
+ n_span = seqlen // ratio
226
+ if n_span:
227
+ cache.put_compressed_prefill(kv[:, seqlen:seqlen + n_span])
228
+ if self.indexer is not None:
229
+ idx_kv = self.indexer.compressor(x, cos, sin)
230
+ if idx_kv is not None:
231
+ cache._adopt(idx_kv.dtype)
232
+ cache.idx_kv[:, :idx_kv.shape[1]] = idx_kv
233
+
234
+ start = max(0, (n_span - 1) * ratio)
235
+ for p in range(start, seqlen):
236
+ self.compressor.step(x[:, p:p + 1], p, cache.comp, cos, sin)
237
+ if self.indexer is not None:
238
+ self.indexer.compressor.step(x[:, p:p + 1], p, cache.idx_comp, cos, sin)
239
+ cache.offset = seqlen
deepseek_v4_mlx/cache.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Incremental decode state for DeepSeek-V4.
2
+
3
+ Three things persist between tokens, and they have different shapes:
4
+
5
+ * **the sliding window** — a *circular* buffer of ``window_size`` KV entries.
6
+ Position ``p`` lives at slot ``p % window_size``, so reading it back in
7
+ chronological order means rotating the slot list, not slicing it.
8
+ * **compressed spans** — appended after the window in the same buffer, one entry
9
+ per completed span at slot ``window_size + p // ratio``.
10
+ * **the compressor's partial span** — the tokens seen since the last span closed,
11
+ held as running ``kv``/``score`` state. Ratio-4 layers keep two spans' worth,
12
+ because their pooling overlaps the previous span.
13
+
14
+ The indexer keeps its own compressor state and its own compressed-KV buffer,
15
+ independent of attention's.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import mlx.core as mx
21
+
22
+ NEG_INF = -1e30
23
+
24
+
25
+ class CompressorCache:
26
+ """Rolling state for one compressor's in-progress span."""
27
+
28
+ def __init__(self, bsz: int, ratio: int, head_dim: int, overlap: bool):
29
+ coff = 1 + overlap
30
+ self.ratio = ratio
31
+ self.overlap = overlap
32
+ self.head_dim = head_dim
33
+ self.kv_state = mx.zeros((bsz, coff * ratio, coff * head_dim), dtype=mx.float32)
34
+ self.score_state = mx.full((bsz, coff * ratio, coff * head_dim), NEG_INF,
35
+ dtype=mx.float32)
36
+
37
+ def write(self, slot: int, kv: mx.array, score: mx.array):
38
+ """Record one token's contribution at ``slot``."""
39
+ self.kv_state[:, slot] = kv
40
+ self.score_state[:, slot] = score
41
+
42
+ def shift(self):
43
+ """After closing an overlapping span, the current half becomes the previous."""
44
+ r = self.ratio
45
+ self.kv_state[:, :r] = self.kv_state[:, r:]
46
+ self.score_state[:, :r] = self.score_state[:, r:]
47
+
48
+
49
+ class LayerCache:
50
+ """Per-layer decode state: window + compressed KV, plus compressor state."""
51
+
52
+ def __init__(self, bsz: int, args, layer_id: int, max_seq_len: int,
53
+ dtype=mx.bfloat16):
54
+ self.window = args.window_size
55
+ self.ratio = args.compress_ratio(layer_id)
56
+ self.offset = 0
57
+ self.dtype = dtype
58
+
59
+ n_comp = (max_seq_len // self.ratio) if self.ratio else 0
60
+ self.kv = mx.zeros((bsz, self.window + n_comp, args.head_dim), dtype=dtype)
61
+
62
+ if self.ratio:
63
+ self.comp = CompressorCache(bsz, self.ratio, args.head_dim, self.ratio == 4)
64
+ else:
65
+ self.comp = None
66
+
67
+ if self.ratio == 4:
68
+ self.idx_comp = CompressorCache(bsz, self.ratio, args.index_head_dim, True)
69
+ self.idx_kv = mx.zeros((bsz, n_comp, args.index_head_dim), dtype=dtype)
70
+ else:
71
+ self.idx_comp = None
72
+ self.idx_kv = None
73
+
74
+ def _adopt(self, dtype):
75
+ """Match the precision actually flowing through the model.
76
+
77
+ Allocating bf16 while the forward runs in fp32 truncates every cached KV on
78
+ write. The absolute error is small, but it flips discrete decisions
79
+ downstream — expert top-k and indexer span selection — so decode drifts away
80
+ from full recomputation far faster than the rounding alone suggests.
81
+ """
82
+ if dtype == self.dtype:
83
+ return
84
+ self.dtype = dtype
85
+ self.kv = self.kv.astype(dtype)
86
+ if self.idx_kv is not None:
87
+ self.idx_kv = self.idx_kv.astype(dtype)
88
+
89
+ # ---- window ----
90
+
91
+ def put_window(self, pos: int, kv: mx.array):
92
+ self._adopt(kv.dtype)
93
+ self.kv[:, pos % self.window] = kv
94
+
95
+ def put_window_prefill(self, kv: mx.array):
96
+ """Seed the circular buffer from a prefill of ``s`` tokens.
97
+
98
+ Keeps the last ``window`` entries, placed so that position ``p`` sits at
99
+ slot ``p % window`` — the same invariant decode maintains.
100
+ """
101
+ self._adopt(kv.dtype)
102
+ s = kv.shape[1]
103
+ if s <= self.window:
104
+ self.kv[:, :s] = kv
105
+ return
106
+ tail = kv[:, -self.window:] # positions s-window .. s-1
107
+ cut = s % self.window # where position s-1 lands + 1
108
+ self.kv[:, cut:self.window] = tail[:, :self.window - cut]
109
+ self.kv[:, :cut] = tail[:, self.window - cut:]
110
+
111
+ def put_compressed(self, span: int, kv: mx.array):
112
+ self._adopt(kv.dtype)
113
+ self.kv[:, self.window + span] = kv
114
+
115
+ def put_compressed_prefill(self, kv: mx.array):
116
+ self._adopt(kv.dtype)
117
+ n = kv.shape[1]
118
+ self.kv[:, self.window:self.window + n] = kv
119
+
120
+ # ---- index lists for one decoding step ----
121
+
122
+ def window_idxs(self, pos: int) -> mx.array:
123
+ """Window slots in chronological order for a query at ``pos``."""
124
+ w = self.window
125
+ if pos >= w - 1:
126
+ p = pos % w
127
+ return mx.concatenate([mx.arange(p + 1, w), mx.arange(0, p + 1)]).astype(mx.int32)
128
+ return mx.concatenate([mx.arange(pos + 1),
129
+ mx.full((w - pos - 1,), -1)]).astype(mx.int32)
130
+
131
+ def compressed_idxs(self, pos: int) -> mx.array:
132
+ """Every span completed by ``pos``, addressed in the shared buffer."""
133
+ n = (pos + 1) // self.ratio
134
+ if n <= 0:
135
+ return mx.zeros((0,), dtype=mx.int32)
136
+ return (mx.arange(n) + self.window).astype(mx.int32)
137
+
138
+
139
+ def make_cache(args, bsz: int = 1, max_seq_len: int | None = None):
140
+ """One :class:`LayerCache` per layer."""
141
+ max_seq_len = max_seq_len or 4096
142
+ return [LayerCache(bsz, args, i, max_seq_len) for i in range(args.n_layers)]
deepseek_v4_mlx/compressor.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Learned KV compression — V4 pools spans of tokens into single cache entries.
2
+
3
+ Instead of caching every position, compression layers pool ``compress_ratio``
4
+ consecutive tokens into one entry using a *learned gate*: a projection scores each
5
+ position, the scores are softmaxed across the span, and the KV vectors are summed
6
+ under those weights. A per-slot positional embedding (``ape``) lets the gate know
7
+ where inside the span a token sat.
8
+
9
+ Ratio-4 layers additionally **overlap**: each pooled entry sees 2x ratio positions
10
+ — the current span plus the preceding one — so information is not lost at span
11
+ boundaries. That is what the doubled ``wkv`` / ``wgate`` output width is for: the
12
+ first ``head_dim`` channels carry the overlapping (previous-span) contribution and
13
+ the second ``head_dim`` the current one.
14
+
15
+ Only the prefill path (``start_pos == 0``) lives here. Incremental decode
16
+ maintains rolling state buffers and is added alongside the attention cache.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import mlx.core as mx
22
+ import mlx.nn as nn
23
+
24
+ from .config import ModelArgs
25
+ from .fakequant import fake_quant_fp4, fake_quant_kv
26
+ from .layers import RMSNorm, apply_rotary_emb, hadamard_transform
27
+
28
+ NEG_INF = -1e30
29
+
30
+
31
+ class Compressor(nn.Module):
32
+ """Pools ``compress_ratio`` tokens into one KV entry via a learned softmax gate."""
33
+
34
+ def __init__(self, args: ModelArgs, compress_ratio: int = 4,
35
+ head_dim: int = 512, rotate: bool = False):
36
+ super().__init__()
37
+ self.dim = args.dim
38
+ self.head_dim = head_dim
39
+ self.rope_head_dim = args.rope_head_dim
40
+ self.compress_ratio = compress_ratio
41
+ self.overlap = compress_ratio == 4
42
+ self.rotate = rotate
43
+ coff = 1 + self.overlap
44
+
45
+ self.ape = mx.zeros((compress_ratio, coff * head_dim), dtype=mx.float32)
46
+ self.wkv = nn.Linear(self.dim, coff * head_dim, bias=False)
47
+ self.wgate = nn.Linear(self.dim, coff * head_dim, bias=False)
48
+ self.norm = RMSNorm(head_dim, args.norm_eps)
49
+
50
+ def _overlap_transform(self, t: mx.array, value: float) -> mx.array:
51
+ """[b, n, ratio, 2d] -> [b, n, 2*ratio, d].
52
+
53
+ Slots ``[ratio:]`` take the *second* half of the channels from this span;
54
+ slots ``[:ratio]`` take the *first* half from the **previous** span. The
55
+ first span has no predecessor, so its leading slots stay at ``value``
56
+ (0 for KV, -inf for scores, so the softmax ignores them).
57
+ """
58
+ b, n, r, _ = t.shape
59
+ d = self.head_dim
60
+ cur = t[..., d:] # [b, n, ratio, d]
61
+ prev = t[..., :d] # [b, n, ratio, d]
62
+ pad = mx.full((b, 1, r, d), value, dtype=t.dtype)
63
+ prev_shifted = mx.concatenate([pad, prev[:, :-1]], axis=1)
64
+ return mx.concatenate([prev_shifted, cur], axis=2) # [b, n, 2*ratio, d]
65
+
66
+ def __call__(self, x: mx.array, cos: mx.array, sin: mx.array):
67
+ """Prefill: compress ``x`` [b, s, dim] into [b, s//ratio, head_dim].
68
+
69
+ Returns ``None`` when the sequence is shorter than one full span, matching
70
+ the reference's ``should_compress`` short-circuit.
71
+ """
72
+ bsz, seqlen, _ = x.shape
73
+ ratio, d, rd = self.compress_ratio, self.head_dim, self.rope_head_dim
74
+ dtype = x.dtype
75
+
76
+ if seqlen < ratio:
77
+ return None
78
+
79
+ xf = x.astype(mx.float32)
80
+ kv = self.wkv(xf)
81
+ score = self.wgate(xf)
82
+
83
+ remainder = seqlen % ratio
84
+ cutoff = seqlen - remainder
85
+ if remainder:
86
+ kv = kv[:, :cutoff]
87
+ score = score[:, :cutoff]
88
+
89
+ nb = cutoff // ratio
90
+ kv = kv.reshape(bsz, nb, ratio, -1)
91
+ score = score.reshape(bsz, nb, ratio, -1) + self.ape
92
+
93
+ if self.overlap:
94
+ kv = self._overlap_transform(kv, 0.0)
95
+ score = self._overlap_transform(score, NEG_INF)
96
+
97
+ kv = mx.sum(kv * mx.softmax(score, axis=2), axis=2) # [b, nb, d]
98
+ kv = self.norm(kv.astype(dtype))
99
+
100
+ # one rope position per pooled span, taken at the span's first token
101
+ c = cos[:cutoff:ratio]
102
+ s = sin[:cutoff:ratio]
103
+ rot = apply_rotary_emb(kv[..., -rd:], c, s)
104
+ kv = mx.concatenate([kv[..., :-rd], rot], axis=-1)
105
+
106
+ return self._finish(kv, rd)
107
+
108
+ def _finish(self, kv: mx.array, rd: int) -> mx.array:
109
+ # QAT simulation: the indexer's compressor rotates then fakes FP4 over the
110
+ # whole vector; the attention compressor fakes FP8 on the non-rope half only.
111
+ if self.rotate:
112
+ kv = hadamard_transform(kv)
113
+ return fake_quant_fp4(kv, 32)
114
+ return fake_quant_kv(kv, rd, 64)
115
+
116
+ def step(self, x: mx.array, pos: int, cache, cos: mx.array, sin: mx.array):
117
+ """Decode one token. Returns the pooled entry when ``pos`` closes a span.
118
+
119
+ ``x`` is [b, 1, dim]. Between span boundaries this only accumulates state
120
+ and returns ``None`` — the caller writes nothing to the KV buffer.
121
+ """
122
+ ratio, d, rd = self.compress_ratio, self.head_dim, self.rope_head_dim
123
+ dtype = x.dtype
124
+ xf = x.astype(mx.float32)
125
+ kv = self.wkv(xf)[:, 0] # [b, coff*d]
126
+ score = self.wgate(xf)[:, 0] + self.ape[pos % ratio]
127
+
128
+ if self.overlap:
129
+ cache.write(ratio + pos % ratio, kv, score)
130
+ else:
131
+ cache.write(pos % ratio, kv, score)
132
+
133
+ if (pos + 1) % ratio != 0:
134
+ return None
135
+
136
+ if self.overlap:
137
+ # first half of the channels from the previous span, second half from this
138
+ kv_s = mx.concatenate([cache.kv_state[:, :ratio, :d],
139
+ cache.kv_state[:, ratio:, d:]], axis=1)
140
+ sc_s = mx.concatenate([cache.score_state[:, :ratio, :d],
141
+ cache.score_state[:, ratio:, d:]], axis=1)
142
+ else:
143
+ kv_s, sc_s = cache.kv_state, cache.score_state
144
+
145
+ pooled = mx.sum(kv_s * mx.softmax(sc_s, axis=1), axis=1, keepdims=True)
146
+ if self.overlap:
147
+ cache.shift()
148
+
149
+ pooled = self.norm(pooled.astype(dtype))
150
+ # the span's rope position is its first token
151
+ p = pos + 1 - ratio
152
+ rot = apply_rotary_emb(pooled[..., -rd:], cos[p:p + 1], sin[p:p + 1])
153
+ pooled = mx.concatenate([pooled[..., :-rd], rot], axis=-1)
154
+ return self._finish(pooled, rd)
deepseek_v4_mlx/config.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration for DeepSeek-V4 (MLX port).
2
+
3
+ Parses the HuggingFace ``config.json`` layout published with
4
+ ``deepseek-ai/DeepSeek-V4-Flash-0731``. The reference ``inference/config.json``
5
+ uses different key names for the same values (``dim`` vs ``hidden_size``,
6
+ ``n_layers`` vs ``num_hidden_layers``, ...), so both spellings are accepted —
7
+ handy for running the reference and this port off one file during parity work.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from typing import Any
14
+
15
+
16
+ def _get(d: dict, *names, default=None):
17
+ for n in names:
18
+ if n in d and d[n] is not None:
19
+ return d[n]
20
+ return default
21
+
22
+
23
+ @dataclass
24
+ class ModelArgs:
25
+ vocab_size: int = 129280
26
+ dim: int = 4096
27
+ n_layers: int = 43
28
+ n_hash_layers: int = 3
29
+ moe_inter_dim: int = 2048
30
+
31
+ # attention (MLA)
32
+ n_heads: int = 64
33
+ head_dim: int = 512
34
+ rope_head_dim: int = 64
35
+ q_lora_rank: int = 1024
36
+ o_lora_rank: int = 1024
37
+ o_groups: int = 8
38
+ window_size: int = 128
39
+ norm_eps: float = 1e-6
40
+
41
+ # MoE
42
+ n_routed_experts: int = 256
43
+ n_shared_experts: int = 1
44
+ n_activated_experts: int = 6
45
+ score_func: str = "sqrtsoftplus"
46
+ route_scale: float = 1.5
47
+ swiglu_limit: float = 10.0
48
+
49
+ # KV compression: one ratio per layer (0 = none, pure sliding window)
50
+ compress_ratios: tuple = ()
51
+ compress_rope_theta: float = 160000.0
52
+
53
+ # rope / YaRN
54
+ original_seq_len: int = 65536
55
+ rope_theta: float = 10000.0
56
+ rope_factor: float = 16.0
57
+ beta_fast: int = 32
58
+ beta_slow: int = 1
59
+ max_seq_len: int = 1048576
60
+
61
+ # sparse-attention indexer
62
+ index_n_heads: int = 64
63
+ index_head_dim: int = 128
64
+ index_topk: int = 512
65
+
66
+ # hyper-connections
67
+ hc_mult: int = 4
68
+ hc_sinkhorn_iters: int = 20
69
+ hc_eps: float = 1e-6
70
+
71
+ # REAP: experts kept in the *scored* layers (None = unpruned)
72
+ reap_kept_experts: int | None = None
73
+
74
+ # side-paths dropped for inference (kept for provenance)
75
+ n_mtp_layers: int = 0
76
+ dspark_block_size: int = 0
77
+ dspark_target_layer_ids: tuple = ()
78
+
79
+ @property
80
+ def nope_head_dim(self) -> int:
81
+ return self.head_dim - self.rope_head_dim
82
+
83
+ def compress_ratio(self, layer_id: int) -> int:
84
+ """Compression ratio for a layer; 0 means pure sliding-window attention."""
85
+ if layer_id < len(self.compress_ratios):
86
+ return int(self.compress_ratios[layer_id])
87
+ return 0
88
+
89
+ def is_hash_layer(self, layer_id: int) -> bool:
90
+ """First ``n_hash_layers`` route experts by token-id lookup, not by score."""
91
+ return layer_id < self.n_hash_layers
92
+
93
+ def n_experts_for(self, layer_id: int) -> int:
94
+ """Routed-expert count for a layer — REAP prunes only the scored layers.
95
+
96
+ Hash layers index a fixed ``tid2eid`` table, so dropping an expert there
97
+ would leave table entries pointing at nothing. They keep the full set, and
98
+ a pruned checkpoint therefore has *two* expert counts. Storing one global
99
+ ``n_routed_experts`` would silently build the hash layers too small and
100
+ load garbage into them.
101
+ """
102
+ if self.reap_kept_experts and not self.is_hash_layer(layer_id):
103
+ return self.reap_kept_experts
104
+ return self.n_routed_experts
105
+
106
+ @classmethod
107
+ def from_dict(cls, c: dict) -> "ModelArgs":
108
+ rope = _get(c, "rope_scaling", default={}) or {}
109
+ return cls(
110
+ vocab_size=_get(c, "vocab_size", default=129280),
111
+ dim=_get(c, "hidden_size", "dim", default=4096),
112
+ n_layers=_get(c, "num_hidden_layers", "n_layers", default=43),
113
+ n_hash_layers=_get(c, "num_hash_layers", "n_hash_layers", default=0),
114
+ moe_inter_dim=_get(c, "moe_intermediate_size", "moe_inter_dim", default=2048),
115
+ n_heads=_get(c, "num_attention_heads", "n_heads", default=64),
116
+ head_dim=_get(c, "head_dim", default=512),
117
+ rope_head_dim=_get(c, "qk_rope_head_dim", "rope_head_dim", default=64),
118
+ q_lora_rank=_get(c, "q_lora_rank", default=1024),
119
+ o_lora_rank=_get(c, "o_lora_rank", default=1024),
120
+ o_groups=_get(c, "o_groups", default=8),
121
+ window_size=_get(c, "sliding_window", "window_size", default=128),
122
+ norm_eps=_get(c, "rms_norm_eps", "norm_eps", default=1e-6),
123
+ n_routed_experts=_get(c, "n_routed_experts", default=256),
124
+ n_shared_experts=_get(c, "n_shared_experts", default=1),
125
+ n_activated_experts=_get(c, "num_experts_per_tok", "n_activated_experts", default=6),
126
+ score_func=_get(c, "scoring_func", "score_func", default="sqrtsoftplus"),
127
+ route_scale=_get(c, "routed_scaling_factor", "route_scale", default=1.5),
128
+ swiglu_limit=_get(c, "swiglu_limit", default=0.0),
129
+ compress_ratios=tuple(_get(c, "compress_ratios", default=()) or ()),
130
+ compress_rope_theta=_get(c, "compress_rope_theta", default=160000.0),
131
+ original_seq_len=_get(rope, "original_max_position_embeddings",
132
+ default=_get(c, "original_seq_len", default=65536)),
133
+ rope_theta=_get(c, "rope_theta", default=10000.0),
134
+ rope_factor=_get(rope, "factor", default=_get(c, "rope_factor", default=16.0)),
135
+ beta_fast=_get(rope, "beta_fast", default=_get(c, "beta_fast", default=32)),
136
+ beta_slow=_get(rope, "beta_slow", default=_get(c, "beta_slow", default=1)),
137
+ max_seq_len=_get(c, "max_position_embeddings", "max_seq_len", default=1048576),
138
+ index_n_heads=_get(c, "index_n_heads", default=64),
139
+ index_head_dim=_get(c, "index_head_dim", default=128),
140
+ index_topk=_get(c, "index_topk", default=512),
141
+ hc_mult=_get(c, "hc_mult", default=4),
142
+ hc_sinkhorn_iters=_get(c, "hc_sinkhorn_iters", default=20),
143
+ hc_eps=_get(c, "hc_eps", default=1e-6),
144
+ reap_kept_experts=(c.get("reap") or {}).get("kept_experts"),
145
+ n_mtp_layers=_get(c, "num_nextn_predict_layers", "n_mtp_layers", default=0),
146
+ dspark_block_size=_get(c, "dspark_block_size", default=0),
147
+ dspark_target_layer_ids=tuple(_get(c, "dspark_target_layer_ids", default=()) or ()),
148
+ )
149
+
150
+ @property
151
+ def raw(self) -> dict[str, Any]:
152
+ return {"model_type": "deepseek_v4"}
deepseek_v4_mlx/convert.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Streaming DeepSeek-V4 checkpoint -> MLX, with optional affine quantization.
2
+
3
+ The published weights are FP8/FP4 (see :mod:`.dequant`), so conversion is
4
+ dequantize-then-requantize rather than a straight cast. It runs one source shard
5
+ at a time and the layout cooperates: no shard spans more than one layer, so a
6
+ layer's 256 experts are always resident together and can be stacked without
7
+ buffering the model.
8
+
9
+ Three transforms are applied:
10
+
11
+ * **dequantize** — every ``(weight, scale)`` pair to bf16; FP4 for routed experts,
12
+ FP8 for everything else;
13
+ * **stack experts** — the checkpoint stores ``ffn.experts.{0..255}.w{1,2,3}``
14
+ separately; MLX's ``SwitchGLU`` wants one ``[n_experts, out, in]`` tensor per
15
+ projection (``w1 -> gate_proj``, ``w3 -> up_proj``, ``w2 -> down_proj``);
16
+ * **drop the side-paths** — ``mtp.*`` covers both the multi-token-prediction head
17
+ and DSpark, neither of which is used for ordinary generation.
18
+
19
+ ``bits=None`` writes bf16; ``bits in {2,3,4,5,6,8}`` affine-quantizes the matmul
20
+ weights, leaving norms, the router, ``tid2eid``, the compressor projections and the
21
+ Hyper-Connection parameters in their original precision.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import glob
27
+ import json
28
+ import os
29
+ import re
30
+ import shutil
31
+
32
+ import mlx.core as mx
33
+
34
+ from .dequant import dequant_pair, is_fp4_expert
35
+
36
+ _EXPERT_RE = re.compile(r"^(layers\.\d+\.ffn)\.experts\.(\d+)\.(w[123])$")
37
+ _PROJ = {"w1": "gate_proj", "w3": "up_proj", "w2": "down_proj"}
38
+
39
+ _SHARD_CAP_BYTES = 5_000_000_000
40
+
41
+ # Leaves that get affine-quantized; everything else keeps its source precision.
42
+ #
43
+ # `wo_a` is deliberately excluded. It is a *block-diagonal* projection — input
44
+ # group g multiplies only block g of the weight — which attention implements by
45
+ # reshaping the weight to [n_groups, o_lora_rank, -1]. After nn.quantize a
46
+ # Linear's `.weight` is the packed uint32 tensor, so that reshape would carve up
47
+ # packed bytes rather than logical weights. Keeping it unquantized costs ~1.4B
48
+ # parameters (about 2% of a 4-bit build) and keeps the reshape meaningful. Moving
49
+ # it to a SwitchLinear would recover that, at the cost of a layout change.
50
+ _QUANT_LEAVES = {"wq_a", "wq_b", "wkv", "wo_b", "w1", "w2", "w3",
51
+ "gate_proj", "up_proj", "down_proj", "embed", "head"}
52
+
53
+
54
+ def should_drop(name: str) -> bool:
55
+ """MTP and DSpark live under `mtp.` and are inference-irrelevant here."""
56
+ return name.startswith("mtp.")
57
+
58
+
59
+ def is_routed_expert(out_name: str) -> bool:
60
+ """The stacked routed-expert matmuls — 98% of all quantized parameters.
61
+
62
+ They ship natively as FP4 (16 levels), which 4-bit affine reproduces almost
63
+ exactly, so precision spent above 4 bits here buys little while costing nearly
64
+ the whole build size. Everything else is ~5B parameters and effectively free to
65
+ keep at high precision.
66
+ """
67
+ return ".ffn.experts." in out_name and ".shared_experts." not in out_name
68
+
69
+
70
+ def is_quant_target(out_name: str, in_dim: int, group_size: int) -> bool:
71
+ if not out_name.endswith(".weight"):
72
+ return False
73
+ leaf = out_name[: -len(".weight")].rsplit(".", 1)[-1]
74
+ if leaf not in _QUANT_LEAVES:
75
+ return False
76
+ # the router gate and tid2eid share the `gate` prefix but are not in _QUANT_LEAVES
77
+ return in_dim % group_size == 0
78
+
79
+
80
+ def bits_for(out_name: str, bits: int, expert_bits: int | None) -> int:
81
+ """Per-tensor bit width. ``expert_bits`` splits routed experts from the rest."""
82
+ if expert_bits is not None and is_routed_expert(out_name):
83
+ return expert_bits
84
+ return bits
85
+
86
+
87
+ def _collect(tensors: dict) -> dict:
88
+ """Dequantize pairs, stack experts, drop side-paths. Returns MLX-named bf16."""
89
+ out: dict[str, mx.array] = {}
90
+ experts: dict[str, dict[int, mx.array]] = {}
91
+
92
+ names = [n for n in tensors if not should_drop(n)]
93
+ for name in names:
94
+ if name.endswith(".scale"):
95
+ continue # consumed with its weight
96
+ arr = tensors[name]
97
+ base = name[: -len(".weight")] if name.endswith(".weight") else name
98
+ scale_name = base + ".scale"
99
+ if scale_name in tensors:
100
+ arr = dequant_pair(base, arr, tensors[scale_name], mx.bfloat16)
101
+
102
+ m = _EXPERT_RE.match(base)
103
+ if m:
104
+ prefix, idx, w = m.group(1), int(m.group(2)), m.group(3)
105
+ key = f"{prefix}.experts.{_PROJ[w]}.weight"
106
+ experts.setdefault(key, {})[idx] = arr
107
+ continue
108
+
109
+ if base.endswith("tid2eid"):
110
+ out[base] = arr.astype(mx.int32) # stored int64, only needs int32
111
+ continue
112
+
113
+ out[name if name.endswith(".weight") else base] = arr
114
+
115
+ for key, byidx in experts.items():
116
+ stacked = mx.stack([byidx[i] for i in sorted(byidx)], axis=0)
117
+ out[key] = stacked
118
+ return out
119
+
120
+
121
+ def convert(src: str, dst: str, bits: int | None = None, group_size: int = 64,
122
+ out_dtype=mx.bfloat16, verbose: bool = True,
123
+ expert_bits: int | None = None) -> str:
124
+ os.makedirs(dst, exist_ok=True)
125
+ index = json.load(open(os.path.join(src, "model.safetensors.index.json")))
126
+ weight_map = index["weight_map"]
127
+
128
+ shard_to_names: dict[str, list[str]] = {}
129
+ for n, s in weight_map.items():
130
+ shard_to_names.setdefault(s, []).append(n)
131
+
132
+ out_index: dict[str, str] = {}
133
+ buf: dict[str, mx.array] = {}
134
+ nbytes = 0
135
+ shard_id = 0
136
+ dropped = 0
137
+
138
+ def flush():
139
+ nonlocal buf, nbytes, shard_id
140
+ if not buf:
141
+ return
142
+ shard_id += 1
143
+ fname = f"model-{shard_id:05d}.safetensors"
144
+ mx.save_safetensors(os.path.join(dst, fname), buf, metadata={"format": "mlx"})
145
+ for k in buf:
146
+ out_index[k] = fname
147
+ buf = {}
148
+ nbytes = 0
149
+
150
+ for shard in sorted(shard_to_names):
151
+ tensors = mx.load(os.path.join(src, shard))
152
+ dropped += sum(1 for n in tensors if should_drop(n))
153
+ converted = _collect(tensors)
154
+ for out_name, arr in converted.items():
155
+ if bits is not None and is_quant_target(out_name, arr.shape[-1], group_size):
156
+ b = bits_for(out_name, bits, expert_bits)
157
+ qw, sc, bi = mx.quantize(arr, group_size=group_size, bits=b)
158
+ base = out_name[: -len(".weight")]
159
+ items = ((out_name, qw), (base + ".scales", sc), (base + ".biases", bi))
160
+ else:
161
+ keep_fp32 = arr.dtype == mx.float32 or arr.dtype == mx.int32
162
+ items = ((out_name, arr if keep_fp32 else arr.astype(out_dtype)),)
163
+ for k, v in items:
164
+ mx.eval(v)
165
+ buf[k] = v
166
+ nbytes += v.nbytes
167
+ if nbytes >= _SHARD_CAP_BYTES:
168
+ flush()
169
+ del tensors, converted
170
+ if verbose:
171
+ print(f"[convert] {shard} -> {shard_id} shards written", flush=True)
172
+ flush()
173
+
174
+ _finalize(dst, out_index, shard_id)
175
+ _write_config(src, dst, bits, group_size, expert_bits)
176
+ _copy_aux(src, dst)
177
+ if verbose:
178
+ print(f"[convert] dropped {dropped} mtp/dspark tensors")
179
+ return dst
180
+
181
+
182
+ def _finalize(dst, out_index, n):
183
+ remap = {}
184
+ for i in range(1, n + 1):
185
+ old, new = f"model-{i:05d}.safetensors", f"model-{i:05d}-of-{n:05d}.safetensors"
186
+ if os.path.exists(os.path.join(dst, old)):
187
+ os.rename(os.path.join(dst, old), os.path.join(dst, new))
188
+ remap[old] = new
189
+ wm = {k: remap[v] for k, v in out_index.items()}
190
+ total = sum(os.path.getsize(os.path.join(dst, f)) for f in set(wm.values()))
191
+ json.dump({"metadata": {"total_size": total}, "weight_map": wm},
192
+ open(os.path.join(dst, "model.safetensors.index.json"), "w"), indent=2)
193
+
194
+
195
+ def _write_config(src, dst, bits, group_size, expert_bits=None):
196
+ cfg = json.load(open(os.path.join(src, "config.json")))
197
+ # the source quantization_config describes FP8/FP4 weights that no longer exist
198
+ cfg.pop("quantization_config", None)
199
+ cfg.pop("expert_dtype", None)
200
+ if bits is not None:
201
+ q = {"group_size": group_size, "bits": bits}
202
+ if expert_bits is not None:
203
+ q["expert_bits"] = expert_bits
204
+ cfg["quantization"] = q
205
+ json.dump(cfg, open(os.path.join(dst, "config.json"), "w"), indent=2)
206
+
207
+
208
+ def _copy_aux(src, dst):
209
+ for pat in ("tokenizer*", "*.json", "*.jinja", "*.txt", "encoding"):
210
+ for p in glob.glob(os.path.join(src, pat)):
211
+ base = os.path.basename(p)
212
+ if base in ("config.json", "model.safetensors.index.json"):
213
+ continue
214
+ target = os.path.join(dst, base)
215
+ if os.path.isdir(p):
216
+ shutil.copytree(p, target, dirs_exist_ok=True)
217
+ else:
218
+ shutil.copy2(p, target)
deepseek_v4_mlx/dequant.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dequantize DeepSeek-V4's natively-quantized checkpoint to bf16.
2
+
3
+ The published checkpoint is quantized in *two* different formats, and neither is
4
+ a format MLX can consume directly:
5
+
6
+ * **Most matmul weights** — FP8 ``e4m3`` stored as ``uint8`` ``[out, in]``, with
7
+ one ``e8m0`` (exponent-only) scale per ``128x128`` block:
8
+ ``scale[ceil(out/128), ceil(in/128)]``.
9
+ * **Routed experts** (``expert_dtype: "fp4"``) — FP4 ``e2m1`` packed **two values
10
+ per byte** into ``int8 [out, in//2]``, with one ``e8m0`` scale per 32 values
11
+ along the input dim: ``scale[out, in//32]``.
12
+
13
+ Everything else (norms, gate weights, compressor projections, embeddings, the LM
14
+ head, the HC parameters) is already bf16 or fp32 and passes through untouched.
15
+
16
+ The `e8m0` format is exponent-only: byte ``b`` denotes ``2**(b - 127)``, with
17
+ ``b == 0`` reserved for zero. That makes every scale an exact power of two, so
18
+ applying it is lossless in fp32.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import mlx.core as mx
24
+
25
+ FP8_BLOCK = 128 # e4m3 weights: 128x128 scale blocks
26
+ FP4_BLOCK = 32 # e2m1 experts: 32 values per scale, along the input dim
27
+
28
+ # FP4 e2m1 code -> value. 1 sign bit, 2 exponent bits, 1 mantissa bit; the 8
29
+ # representable magnitudes are {0, .5, 1, 1.5, 2, 3, 4, 6} with a sign.
30
+ # Transcribed from FP4_TABLE in the reference inference/convert.py — note code 8
31
+ # ("negative zero") maps to +0.0 there, so it does the same here.
32
+ _E2M1 = [0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0,
33
+ 0.0, -0.5, -1.0, -1.5, -2.0, -3.0, -4.0, -6.0]
34
+
35
+
36
+ def e8m0_to_float(scale_u8: mx.array) -> mx.array:
37
+ """Decode exponent-only fp8 scales: byte b -> 2**(b-127), with 0 -> 0."""
38
+ e = scale_u8.astype(mx.int32)
39
+ val = mx.power(mx.array(2.0, mx.float32), (e - 127).astype(mx.float32))
40
+ return mx.where(e == 0, mx.array(0.0, mx.float32), val)
41
+
42
+
43
+ def _expand_blocks(scale: mx.array, rows: int, cols: int, br: int, bc: int) -> mx.array:
44
+ """Broadcast a per-block scale grid up to full [rows, cols], then crop.
45
+
46
+ Crop matters: block counts use ceil, so the expanded grid can overhang the
47
+ weight when a dimension is not a multiple of the block size.
48
+ """
49
+ s = mx.repeat(mx.repeat(scale, br, axis=0), bc, axis=1)
50
+ return s[:rows, :cols]
51
+
52
+
53
+ def dequant_fp8(weight_u8: mx.array, scale_u8: mx.array, dtype=mx.bfloat16) -> mx.array:
54
+ """FP8 e4m3 ``[out, in]`` + e8m0 ``[out/128, in/128]`` block scales -> ``dtype``."""
55
+ w = mx.from_fp8(weight_u8, mx.float32)
56
+ rows, cols = w.shape
57
+ s = _expand_blocks(e8m0_to_float(scale_u8), rows, cols, FP8_BLOCK, FP8_BLOCK)
58
+ return (w * s).astype(dtype)
59
+
60
+
61
+ def unpack_fp4(packed_i8: mx.array) -> mx.array:
62
+ """Unpack ``int8 [out, in//2]`` of two e2m1 nibbles into float32 ``[out, in]``.
63
+
64
+ Low nibble holds the even (lower) input index, high nibble the odd one — the
65
+ same ordering torch uses for ``float4_e2m1fn_x2``.
66
+ """
67
+ b = packed_i8.view(mx.uint8).astype(mx.int32)
68
+ lo = b & 0x0F
69
+ hi = (b >> 4) & 0x0F
70
+ lut = mx.array(_E2M1, mx.float32)
71
+ vals = mx.stack([lut[lo], lut[hi]], axis=-1) # [out, in//2, 2]
72
+ return vals.reshape(vals.shape[0], -1) # [out, in]
73
+
74
+
75
+ def dequant_fp4(weight_i8: mx.array, scale_u8: mx.array, dtype=mx.bfloat16) -> mx.array:
76
+ """Packed FP4 e2m1 ``[out, in//2]`` + e8m0 ``[out, in//32]`` scales -> ``dtype``."""
77
+ w = unpack_fp4(weight_i8) # [out, in]
78
+ rows, cols = w.shape
79
+ s = _expand_blocks(e8m0_to_float(scale_u8), rows, cols, 1, FP4_BLOCK)
80
+ return (w * s).astype(dtype)
81
+
82
+
83
+ def is_fp4_expert(name: str) -> bool:
84
+ """Routed-expert matmuls are the FP4 ones; shared experts are FP8."""
85
+ return ".experts." in name and ".shared_experts." not in name
86
+
87
+
88
+ def dequant_pair(name: str, weight: mx.array, scale: mx.array, dtype=mx.bfloat16) -> mx.array:
89
+ """Dequantize one (weight, scale) pair, choosing the format from the name."""
90
+ if is_fp4_expert(name):
91
+ return dequant_fp4(weight, scale, dtype)
92
+ return dequant_fp8(weight, scale, dtype)
deepseek_v4_mlx/fakequant.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Activation fake-quantization — the inference-time half of DeepSeek-V4's QAT.
2
+
3
+ V4 was quantization-aware trained, and the reference *simulates* that quantization
4
+ during inference: several activations are rounded through FP8 (or FP4) and back to
5
+ bf16 before use. These are the ``inplace=True`` calls to ``act_quant`` /
6
+ ``fp4_act_quant`` in ``inference/model.py``, and they are not optional — skipping
7
+ them changes the numbers the model was trained to see.
8
+
9
+ Both follow the same shape:
10
+
11
+ 1. take the absolute max over each block along the last axis,
12
+ 2. round the scale **up to a power of two**: ``2**ceil(log2(amax / dtype_max))``
13
+ (this is what ``scale_fmt="ue8m0"`` means),
14
+ 3. divide, round to the target format, multiply back.
15
+
16
+ Applied at:
17
+
18
+ * ``kv[..., :-rope_dim]`` in attention and the compressor — FP8, 64-wide blocks;
19
+ the rope channels stay bf16 so positional precision is not degraded,
20
+ * indexer queries and its compressed KV — FP4, 32-wide blocks, after a Hadamard
21
+ rotation.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import mlx.core as mx
27
+
28
+ FP8_MAX = 448.0 # e4m3fn
29
+ FP4_MAX = 6.0 # e2m1
30
+
31
+ # e2m1 magnitudes; used to round a scaled value to the nearest representable FP4.
32
+ _E2M1_POS = mx.array([0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0], dtype=mx.float32)
33
+
34
+
35
+ def _pow2_scale(amax: mx.array, dtype_max: float) -> mx.array:
36
+ """``2**ceil(log2(amax / dtype_max))``, matching ``fast_round_scale``.
37
+
38
+ Guarded so an all-zero block yields scale 1 rather than 0 or a NaN.
39
+ """
40
+ r = amax / dtype_max
41
+ r = mx.maximum(r, 1e-38)
42
+ return mx.power(mx.array(2.0, mx.float32), mx.ceil(mx.log2(r)))
43
+
44
+
45
+ def _blockify(x: mx.array, block: int):
46
+ n = x.shape[-1]
47
+ if n % block:
48
+ raise ValueError(f"last dim {n} not divisible by block {block}")
49
+ return x.reshape(*x.shape[:-1], n // block, block)
50
+
51
+
52
+ def fake_quant_fp8(x: mx.array, block: int = 64) -> mx.array:
53
+ """Round ``x`` through e4m3 with per-block power-of-two scales, back to input dtype."""
54
+ dtype = x.dtype
55
+ shape = x.shape
56
+ xb = _blockify(x.astype(mx.float32), block)
57
+ amax = mx.max(mx.abs(xb), axis=-1, keepdims=True)
58
+ s = _pow2_scale(amax, FP8_MAX)
59
+ q = mx.from_fp8(mx.to_fp8(xb / s), mx.float32) * s
60
+ return q.reshape(shape).astype(dtype)
61
+
62
+
63
+ def fake_quant_fp4(x: mx.array, block: int = 32) -> mx.array:
64
+ """Round ``x`` through e2m1 with per-block power-of-two scales, back to input dtype.
65
+
66
+ MLX has no fp4 cast, so rounding is done by snapping |v| to the nearest of the
67
+ eight e2m1 magnitudes (ties-to-even is not modelled; the reference kernel casts
68
+ via hardware, which rounds to nearest-even, so extreme midpoints can differ by
69
+ one code — well below the noise this simulation represents).
70
+ """
71
+ dtype = x.dtype
72
+ shape = x.shape
73
+ xb = _blockify(x.astype(mx.float32), block)
74
+ amax = mx.max(mx.abs(xb), axis=-1, keepdims=True)
75
+ s = _pow2_scale(amax, FP4_MAX)
76
+ v = mx.clip(xb / s, -FP4_MAX, FP4_MAX)
77
+
78
+ sign = mx.sign(v)
79
+ mag = mx.abs(v)
80
+ # nearest of the 8 magnitudes: compare against midpoints
81
+ lut = _E2M1_POS
82
+ diffs = mx.abs(mag[..., None] - lut.reshape(*([1] * mag.ndim), -1))
83
+ nearest = mx.argmin(diffs, axis=-1)
84
+ q = sign * lut[nearest]
85
+ return (q * s).reshape(shape).astype(dtype)
86
+
87
+
88
+ def fake_quant_kv(kv: mx.array, rope_dim: int, block: int = 64) -> mx.array:
89
+ """FP8-simulate the non-rope channels of a KV vector, leaving rope channels alone.
90
+
91
+ Mirrors ``act_quant(kv[..., :-rd], 64, ..., inplace=True)`` in the reference,
92
+ which quantizes a *slice* in place.
93
+ """
94
+ nope = fake_quant_fp8(kv[..., :-rope_dim], block)
95
+ return mx.concatenate([nope, kv[..., -rope_dim:]], axis=-1)
deepseek_v4_mlx/generate.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Greedy generation with an incremental cache."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import mlx.core as mx
6
+
7
+ from .cache import make_cache
8
+
9
+
10
+ def greedy_generate(model, args, prompt_ids, max_new_tokens: int = 32,
11
+ eos_id: int | None = 1, max_seq_len: int | None = None,
12
+ verbose: bool = False):
13
+ """Prefill the prompt, then decode one token at a time against the cache."""
14
+ ids = list(prompt_ids)
15
+ cache = make_cache(args, bsz=1,
16
+ max_seq_len=max_seq_len or (len(ids) + max_new_tokens + 8))
17
+
18
+ logits = model(mx.array([ids]), last_logit_only=True, cache=cache)
19
+ nxt = int(mx.argmax(logits[0, -1]).item())
20
+ ids.append(nxt)
21
+ if verbose:
22
+ print(f"[gen] prefilled {len(prompt_ids)} tokens", flush=True)
23
+
24
+ for _ in range(max_new_tokens - 1):
25
+ if eos_id is not None and nxt == eos_id:
26
+ break
27
+ logits = model(mx.array([[nxt]]), last_logit_only=True, cache=cache)
28
+ nxt = int(mx.argmax(logits[0, -1]).item())
29
+ ids.append(nxt)
30
+ return ids
31
+
32
+
33
+ def load_tokenizer(path_or_repo: str = "deepseek-ai/DeepSeek-V3"):
34
+ """DeepSeek-V4 ships no tokenizer; V3's matches (same padded 129280 vocab,
35
+ same bos/eos, all V4 special tokens present)."""
36
+ from transformers import AutoTokenizer
37
+ return AutoTokenizer.from_pretrained(path_or_repo, trust_remote_code=True)
deepseek_v4_mlx/hyper_connections.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hyper-Connections (HC) — DeepSeek-V4's replacement for the plain residual stream.
2
+
3
+ Instead of carrying one hidden state, a V4 block carries ``hc_mult`` (=4) parallel
4
+ copies. Each sub-layer:
5
+
6
+ * ``hc_pre`` reduces the 4 copies to 1 via learned per-copy weights, and also
7
+ emits the ``post`` (expand) weights and a ``comb`` (combination) matrix,
8
+ * the sub-layer (attention or MoE) runs on that single reduced state,
9
+ * ``hc_post`` writes the result back out to 4 copies, mixing in the previous 4
10
+ via ``comb``.
11
+
12
+ All three weight sets come from one projection of the flattened state, split and
13
+ normalized by :func:`split_sinkhorn`. ``comb`` is made doubly-stochastic-ish by
14
+ Sinkhorn iteration (alternating row/column normalization), which is what keeps the
15
+ 4 streams from collapsing into each other.
16
+
17
+ Ported from the reference ``hc_split_sinkhorn_kernel`` in ``inference/kernel.py``
18
+ and ``Block.hc_pre`` / ``Block.hc_post`` in ``inference/model.py``. The kernel is
19
+ pure elementwise + reductions, so this is a faithful transcription rather than an
20
+ approximation.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import mlx.core as mx
26
+
27
+
28
+ def split_sinkhorn(mixes: mx.array, hc_scale: mx.array, hc_base: mx.array,
29
+ hc_mult: int = 4, sinkhorn_iters: int = 20, eps: float = 1e-6):
30
+ """Split one projection into (pre, post, comb).
31
+
32
+ ``mixes``: [..., (2 + hc) * hc] -- the projected+normalized state
33
+ ``hc_scale``: [3] -- one scale per output group
34
+ ``hc_base``: [(2 + hc) * hc] -- one bias per output element
35
+
36
+ Returns ``pre`` [..., hc], ``post`` [..., hc], ``comb`` [..., hc, hc].
37
+
38
+ Layout matches the reference kernel exactly: the first ``hc`` entries feed
39
+ ``pre``, the next ``hc`` feed ``post``, and the remaining ``hc*hc`` form
40
+ ``comb`` in row-major order.
41
+ """
42
+ hc = hc_mult
43
+ mixes = mixes.astype(mx.float32)
44
+ scale = hc_scale.astype(mx.float32)
45
+ base = hc_base.astype(mx.float32)
46
+
47
+ # pre: sigmoid(x*s0 + b) + eps -- strictly positive reduce weights
48
+ pre = mx.sigmoid(mixes[..., :hc] * scale[0] + base[:hc]) + eps
49
+ # post: 2*sigmoid(x*s1 + b) -- expand weights, mean 1 at init
50
+ post = 2.0 * mx.sigmoid(mixes[..., hc:2 * hc] * scale[1] + base[hc:2 * hc])
51
+
52
+ comb = mixes[..., 2 * hc:] * scale[2] + base[2 * hc:]
53
+ comb = comb.reshape(*comb.shape[:-1], hc, hc)
54
+
55
+ # Row softmax, then one column normalization, then (iters-1) full Sinkhorn
56
+ # sweeps. The eps terms are inside the divisions in the reference kernel;
57
+ # keeping them there matters because comb rows can get very small.
58
+ comb = mx.softmax(comb, axis=-1) + eps
59
+ comb = comb / (comb.sum(axis=-2, keepdims=True) + eps)
60
+ for _ in range(sinkhorn_iters - 1):
61
+ comb = comb / (comb.sum(axis=-1, keepdims=True) + eps)
62
+ comb = comb / (comb.sum(axis=-2, keepdims=True) + eps)
63
+
64
+ return pre, post, comb
65
+
66
+
67
+ def hc_pre(x: mx.array, hc_fn: mx.array, hc_scale: mx.array, hc_base: mx.array,
68
+ hc_mult: int, sinkhorn_iters: int, norm_eps: float, hc_eps: float):
69
+ """Reduce the ``hc`` residual copies to one state.
70
+
71
+ ``x``: [b, s, hc, d] -> returns ([b, s, d], post [b,s,hc], comb [b,s,hc,hc]).
72
+
73
+ The projection input is the *flattened* [b, s, hc*d] state scaled by its RMS
74
+ reciprocal — note the reference multiplies by rsqrt AFTER the linear, which is
75
+ equivalent here only because rsqrt is per-token; it is transcribed in that
76
+ order to keep the arithmetic bit-for-bit comparable.
77
+ """
78
+ shape = x.shape
79
+ dtype = x.dtype
80
+ xf = x.reshape(*shape[:2], -1).astype(mx.float32)
81
+ rsqrt = mx.rsqrt(mx.mean(mx.square(xf), axis=-1, keepdims=True) + norm_eps)
82
+ mixes = (xf @ hc_fn.astype(mx.float32).T) * rsqrt
83
+ pre, post, comb = split_sinkhorn(mixes, hc_scale, hc_base, hc_mult, sinkhorn_iters, hc_eps)
84
+ y = mx.sum(pre[..., None] * xf.reshape(shape), axis=2)
85
+ return y.astype(dtype), post, comb
86
+
87
+
88
+ def hc_post(x: mx.array, residual: mx.array, post: mx.array, comb: mx.array) -> mx.array:
89
+ """Expand the sub-layer output back to ``hc`` copies and mix in the residual.
90
+
91
+ ``x``: [b, s, d]; ``residual``: [b, s, hc, d]; returns [b, s, hc, d].
92
+
93
+ Output copy ``k`` is ``post[k]*x + sum_j comb[j, k] * residual[j]`` — residual is
94
+ indexed by ``j``, the axis being summed, so it must sit on axis 2 of the product
95
+ (the reference's ``residual.unsqueeze(-2)``). Broadcasting it onto axis 3 instead
96
+ silently computes ``(sum_j comb[j, k]) * residual[k]``, which has the right shape
97
+ and stays finite, so only a value comparison catches it.
98
+ """
99
+ prod = comb[..., None] * residual[..., :, None, :] # [b, s, j, k, d]
100
+ out = post[..., None] * x[..., None, :] + mx.sum(prod, axis=2)
101
+ return out.astype(x.dtype)
102
+
103
+
104
+ def hc_head(x: mx.array, hc_fn: mx.array, hc_scale: mx.array, hc_base: mx.array,
105
+ norm_eps: float, hc_eps: float) -> mx.array:
106
+ """Final reduction before the LM head: like :func:`hc_pre` but weights only.
107
+
108
+ Uses the raw sigmoid gate without the Sinkhorn split, matching
109
+ ``Block.hc_head`` in the reference.
110
+ """
111
+ shape = x.shape
112
+ dtype = x.dtype
113
+ xf = x.reshape(*shape[:2], -1).astype(mx.float32)
114
+ rsqrt = mx.rsqrt(mx.mean(mx.square(xf), axis=-1, keepdims=True) + norm_eps)
115
+ mixes = (xf @ hc_fn.astype(mx.float32).T) * rsqrt
116
+ pre = mx.sigmoid(mixes * hc_scale + hc_base) + hc_eps
117
+ y = mx.sum(pre[..., None] * xf.reshape(shape), axis=2)
118
+ return y.astype(dtype)
deepseek_v4_mlx/indexer.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The sparse-attention indexer — a second attention that decides what the first reads.
2
+
3
+ Each ratio-4 layer runs this alongside its sliding window. It keeps its own
4
+ :class:`Compressor` (the Hadamard-rotated, FP4 variant), scores every compressed
5
+ span against the current queries, and returns the indices of the top
6
+ ``index_topk`` spans. Attention then reads its local window **plus exactly those**.
7
+
8
+ Scoring is deliberately cheap and low-precision: queries are Hadamard-rotated and
9
+ FP4-simulated, scores are ReLU'd before a learned per-head weighting collapses the
10
+ head axis. Positions the query may not see are marked ``-1`` rather than dropped,
11
+ so the returned index block stays rectangular for the gather downstream.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import mlx.core as mx
17
+ import mlx.nn as nn
18
+
19
+ from .compressor import Compressor
20
+ from .config import ModelArgs
21
+ from .fakequant import fake_quant_fp4
22
+ from .layers import apply_rotary_emb, hadamard_transform
23
+
24
+ NEG_INF = -1e30
25
+
26
+
27
+ class Indexer(nn.Module):
28
+ def __init__(self, args: ModelArgs, compress_ratio: int = 4):
29
+ super().__init__()
30
+ self.n_heads = args.index_n_heads
31
+ self.head_dim = args.index_head_dim
32
+ self.rope_head_dim = args.rope_head_dim
33
+ self.index_topk = args.index_topk
34
+ self.compress_ratio = compress_ratio
35
+ self.softmax_scale = self.head_dim ** -0.5
36
+
37
+ self.wq_b = nn.Linear(args.q_lora_rank, self.n_heads * self.head_dim, bias=False)
38
+ self.weights_proj = nn.Linear(args.dim, self.n_heads, bias=False)
39
+ self.compressor = Compressor(args, compress_ratio, self.head_dim, rotate=True)
40
+
41
+ def __call__(self, x: mx.array, qr: mx.array, cos: mx.array, sin: mx.array,
42
+ offset: int):
43
+ """Prefill: choose compressed spans for every query position.
44
+
45
+ ``x`` [b, s, dim] — hidden state, feeds the compressor and weighting
46
+ ``qr`` [b, s, q_lora_rank] — the shared low-rank query, before wq_b
47
+ ``offset`` — where compressed entries begin in attention's KV cache, so the
48
+ returned indices address that cache directly.
49
+
50
+ Returns ``[b, s, k]`` int32 indices, with ``-1`` marking "not visible".
51
+ """
52
+ bsz, seqlen, _ = x.shape
53
+ ratio, rd = self.compress_ratio, self.rope_head_dim
54
+
55
+ q = self.wq_b(qr).reshape(bsz, seqlen, self.n_heads, self.head_dim)
56
+ rot = apply_rotary_emb(q[..., -rd:], cos[:seqlen], sin[:seqlen])
57
+ q = mx.concatenate([q[..., :-rd], rot], axis=-1)
58
+ q = hadamard_transform(q)
59
+ q = fake_quant_fp4(q, 32)
60
+
61
+ kv = self.compressor(x, cos, sin) # [b, nb, head_dim] or None
62
+ if kv is None:
63
+ return None
64
+ nb = kv.shape[1]
65
+
66
+ # per-head score, ReLU'd, then collapsed by a learned per-head weight
67
+ w = self.weights_proj(x) * (self.softmax_scale * self.n_heads ** -0.5)
68
+ scores = mx.einsum("bshd,btd->bsht", q.astype(mx.float32), kv.astype(mx.float32))
69
+ scores = mx.maximum(scores, 0.0) * w[..., None].astype(mx.float32)
70
+ scores = mx.sum(scores, axis=2) # [b, s, nb]
71
+
72
+ # span t is visible to query s only once the span is complete: t < (s+1)//ratio
73
+ vis = mx.arange(nb)[None, :] < (mx.arange(1, seqlen + 1)[:, None] // ratio)
74
+ scores = scores + mx.where(vis, 0.0, NEG_INF)[None]
75
+
76
+ k = min(self.index_topk, nb)
77
+ # argpartition yields unsigned indices; go to int32 before the -1 sentinel
78
+ idx = mx.argpartition(-scores, k - 1, axis=-1)[..., :k].astype(mx.int32)
79
+
80
+ # invisible spans may still be picked when fewer than k are visible; mark them
81
+ picked_vis = mx.take_along_axis(
82
+ mx.broadcast_to(vis[None], (bsz, seqlen, nb)), idx, axis=-1)
83
+ return mx.where(picked_vis, idx + offset, mx.array(-1, mx.int32))
84
+
85
+ def step(self, x: mx.array, qr: mx.array, cos: mx.array, sin: mx.array,
86
+ pos: int, cache, offset: int):
87
+ """Decode one token: extend the compressed index, then pick top-k spans."""
88
+ rd = self.rope_head_dim
89
+ bsz = x.shape[0]
90
+
91
+ pooled = self.compressor.step(x, pos, cache.idx_comp, cos, sin)
92
+ if pooled is not None:
93
+ cache._adopt(pooled.dtype)
94
+ cache.idx_kv[:, pos // self.compress_ratio] = pooled[:, 0]
95
+
96
+ nb = (pos + 1) // self.compress_ratio
97
+ if nb <= 0:
98
+ return None
99
+
100
+ q = self.wq_b(qr).reshape(bsz, 1, self.n_heads, self.head_dim)
101
+ rot = apply_rotary_emb(q[..., -rd:], cos[pos:pos + 1], sin[pos:pos + 1])
102
+ q = mx.concatenate([q[..., :-rd], rot], axis=-1)
103
+ q = fake_quant_fp4(hadamard_transform(q), 32)
104
+
105
+ kv = cache.idx_kv[:, :nb]
106
+ w = self.weights_proj(x) * (self.softmax_scale * self.n_heads ** -0.5)
107
+ scores = mx.einsum("bshd,btd->bsht", q.astype(mx.float32), kv.astype(mx.float32))
108
+ scores = mx.maximum(scores, 0.0) * w[..., None].astype(mx.float32)
109
+ scores = mx.sum(scores, axis=2) # [b, 1, nb]
110
+
111
+ k = min(self.index_topk, nb)
112
+ idx = mx.argpartition(-scores, k - 1, axis=-1)[..., :k].astype(mx.int32)
113
+ return idx + offset
deepseek_v4_mlx/layers.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Core DeepSeek-V4 layers: RMSNorm, YaRN rope, and the Hadamard rotation.
2
+
3
+ Transcribed from ``inference/model.py``. Two details are easy to get wrong and are
4
+ called out where they occur:
5
+
6
+ * rope is applied to the **last** ``rope_head_dim`` channels only, pairing
7
+ *adjacent* elements as (real, imag) — not the split-halves convention used by
8
+ Llama-style models;
9
+ * attention applies the rope **inverse** to its output (``apply_rotary_emb(...,
10
+ inverse=True)``), which is why the conjugate path exists here at all.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+
17
+ import mlx.core as mx
18
+ import mlx.nn as nn
19
+
20
+
21
+ class RMSNorm(nn.Module):
22
+ """RMSNorm computed in fp32, returning the input dtype (matches reference)."""
23
+
24
+ def __init__(self, dim: int, eps: float = 1e-6):
25
+ super().__init__()
26
+ self.eps = eps
27
+ self.weight = mx.ones((dim,), dtype=mx.float32)
28
+
29
+ def __call__(self, x: mx.array) -> mx.array:
30
+ dtype = x.dtype
31
+ xf = x.astype(mx.float32)
32
+ var = mx.mean(mx.square(xf), axis=-1, keepdims=True)
33
+ xf = xf * mx.rsqrt(var + self.eps)
34
+ return (self.weight * xf).astype(dtype)
35
+
36
+
37
+ def precompute_freqs_cis(dim: int, seqlen: int, original_seq_len: int, base: float,
38
+ factor: float, beta_fast: int, beta_slow: int):
39
+ """YaRN-scaled rotary frequencies, returned as (cos, sin) of shape [seqlen, dim//2].
40
+
41
+ MLX has no complex dtype in the ops we need, so the reference's ``polar``
42
+ representation is carried as an explicit cos/sin pair.
43
+
44
+ ``original_seq_len == 0`` disables YaRN entirely, which the reference uses for
45
+ the pure sliding-window layers.
46
+ """
47
+ def find_correction_dim(num_rotations, dim, base, max_seq_len):
48
+ return dim * math.log(max_seq_len / (num_rotations * 2 * math.pi)) / (2 * math.log(base))
49
+
50
+ def find_correction_range(low_rot, high_rot, dim, base, max_seq_len):
51
+ low = math.floor(find_correction_dim(low_rot, dim, base, max_seq_len))
52
+ high = math.ceil(find_correction_dim(high_rot, dim, base, max_seq_len))
53
+ return max(low, 0), min(high, dim - 1)
54
+
55
+ def linear_ramp_factor(mn, mx_, d):
56
+ if mn == mx_:
57
+ mx_ += 0.001
58
+ lin = (mx.arange(d, dtype=mx.float32) - mn) / (mx_ - mn)
59
+ return mx.clip(lin, 0, 1)
60
+
61
+ freqs = 1.0 / (base ** (mx.arange(0, dim, 2, dtype=mx.float32) / dim))
62
+ if original_seq_len > 0:
63
+ low, high = find_correction_range(beta_fast, beta_slow, dim, base, original_seq_len)
64
+ smooth = 1 - linear_ramp_factor(low, high, dim // 2)
65
+ freqs = freqs / factor * (1 - smooth) + freqs * smooth
66
+
67
+ t = mx.arange(seqlen, dtype=mx.float32)
68
+ ang = t[:, None] * freqs[None, :] # [seqlen, dim//2]
69
+ return mx.cos(ang), mx.sin(ang)
70
+
71
+
72
+ def apply_rotary_emb(x: mx.array, cos: mx.array, sin: mx.array, inverse: bool = False) -> mx.array:
73
+ """Rotate ``x`` [..., s, (h,) d] using adjacent-pair complex multiplication.
74
+
75
+ ``cos``/``sin`` are [s, d//2]. ``inverse=True`` conjugates (de-rotates), which
76
+ attention uses on its output.
77
+ """
78
+ dtype = x.dtype
79
+ xf = x.astype(mx.float32)
80
+ shape = xf.shape
81
+ xf = xf.reshape(*shape[:-1], shape[-1] // 2, 2)
82
+ xr, xi = xf[..., 0], xf[..., 1]
83
+
84
+ # broadcast [s, d//2] against [b, s, d//2] or [b, s, h, d//2]
85
+ if xr.ndim == 4:
86
+ c = cos[None, :, None, :]
87
+ s = sin[None, :, None, :]
88
+ else:
89
+ c = cos[None, :, :]
90
+ s = sin[None, :, :]
91
+ if inverse:
92
+ s = -s
93
+
94
+ out = mx.stack([xr * c - xi * s, xr * s + xi * c], axis=-1)
95
+ return out.reshape(shape).astype(dtype)
96
+
97
+
98
+ def hadamard_transform(x: mx.array, scale: float | None = None) -> mx.array:
99
+ """Fast Walsh-Hadamard transform along the last axis (power-of-two length).
100
+
101
+ Stands in for ``fast_hadamard_transform.hadamard_transform``, which the indexer
102
+ uses to spread information across channels before FP4 quantization. The
103
+ reference always calls it with ``scale = d ** -0.5``; that is the default here.
104
+ """
105
+ d = x.shape[-1]
106
+ if d & (d - 1):
107
+ raise ValueError(f"hadamard_transform needs a power-of-two last dim, got {d}")
108
+ if scale is None:
109
+ scale = d ** -0.5
110
+
111
+ dtype = x.dtype
112
+ y = x.astype(mx.float32)
113
+ lead = y.shape[:-1]
114
+ h = 1
115
+ while h < d:
116
+ # pair up elements h apart and apply the 2x2 butterfly
117
+ y = y.reshape(*lead, d // (2 * h), 2, h)
118
+ a, b = y[..., 0, :], y[..., 1, :]
119
+ y = mx.stack([a + b, a - b], axis=-2)
120
+ y = y.reshape(*lead, d)
121
+ h *= 2
122
+ return (y * scale).astype(dtype)
123
+
124
+
125
+ def swiglu(gate: mx.array, up: mx.array, limit: float = 0.0) -> mx.array:
126
+ """SwiGLU with V4's asymmetric clamp: ``up`` two-sided, ``gate`` upper-only."""
127
+ g = gate.astype(mx.float32)
128
+ u = up.astype(mx.float32)
129
+ if limit > 0:
130
+ u = mx.clip(u, -limit, limit)
131
+ g = mx.minimum(g, limit)
132
+ return (g * mx.sigmoid(g)) * u
deepseek_v4_mlx/load.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Load a converted DeepSeek-V4 MLX build."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import glob
6
+ import json
7
+ import os
8
+
9
+ import mlx.core as mx
10
+ import mlx.nn as nn
11
+ from mlx.utils import tree_flatten
12
+
13
+ from .config import ModelArgs
14
+ from .convert import bits_for, is_quant_target
15
+ from .model import Model
16
+
17
+
18
+ def quant_predicate(group_size: int, bits: int, expert_bits: int | None):
19
+ """Quantize exactly what the converter did, at the same per-tensor bit width.
20
+
21
+ Returning a dict rather than True lets a mixed build load: MLX applies the
22
+ per-module ``{"group_size", "bits"}`` it returns. Answering plain ``True`` would
23
+ quantize every module at the top-level ``bits`` and silently mis-read the
24
+ experts, which are stored at ``expert_bits``.
25
+ """
26
+ def pred(path, module):
27
+ if not hasattr(module, "to_quantized"):
28
+ return False
29
+ w = getattr(module, "weight", None)
30
+ if w is None:
31
+ return False
32
+ name = path + ".weight"
33
+ if not is_quant_target(name, w.shape[-1], group_size):
34
+ return False
35
+ return {"group_size": group_size, "bits": bits_for(name, bits, expert_bits)}
36
+ return pred
37
+
38
+
39
+ def load(path: str, lazy: bool = False):
40
+ cfg = json.load(open(os.path.join(path, "config.json")))
41
+ args = ModelArgs.from_dict(cfg)
42
+ model = Model(args)
43
+
44
+ q = cfg.get("quantization")
45
+ if q:
46
+ nn.quantize(model, group_size=q["group_size"], bits=q["bits"],
47
+ class_predicate=quant_predicate(q["group_size"], q["bits"],
48
+ q.get("expert_bits")))
49
+
50
+ loaded = set()
51
+ for shard in sorted(glob.glob(os.path.join(path, "*.safetensors"))):
52
+ w = mx.load(shard)
53
+ model.load_weights(list(w.items()), strict=False)
54
+ if not lazy:
55
+ # materialize this shard now (bounded graph) rather than building one
56
+ # enormous eval over every parameter at the end
57
+ mx.eval(list(w.values()))
58
+ loaded.update(w.keys())
59
+ del w
60
+
61
+ expected = {k for k, _ in tree_flatten(model.parameters())}
62
+ missing = expected - loaded
63
+ if missing:
64
+ raise ValueError(f"{len(missing)} params missing from checkpoint, "
65
+ f"e.g. {sorted(missing)[:3]}")
66
+
67
+ model.eval()
68
+ return model, args
deepseek_v4_mlx/model.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DeepSeek-V4 block and full model.
2
+
3
+ The block is where Hyper-Connections meet the sub-layers: instead of
4
+ ``x = x + attn(norm(x))`` it is
5
+
6
+ residual = x # [b, s, hc, d]
7
+ x, post, comb = hc_pre(x, ...) # 4 copies -> 1
8
+ x = attn(attn_norm(x))
9
+ x = hc_post(x, residual, post, comb) # 1 -> 4, mixing the previous 4
10
+
11
+ and the same again around the MoE. The MoE additionally takes ``input_ids``,
12
+ because hash-routed layers look their experts up by token id.
13
+
14
+ The model expands the embedding to ``hc_mult`` identical copies on the way in and
15
+ collapses them with a single gate (``hc_head``) on the way out.
16
+
17
+ DSpark and the MTP head are deliberately absent: they hang off the reference's
18
+ ``forward_spec`` path and are not used for ordinary generation.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import mlx.core as mx
24
+ import mlx.nn as nn
25
+
26
+ from .attention import Attention
27
+ from .config import ModelArgs
28
+ from .hyper_connections import hc_head, hc_post, hc_pre
29
+ from .layers import RMSNorm
30
+ from .moe import MoE
31
+
32
+
33
+ class Block(nn.Module):
34
+ def __init__(self, layer_id: int, args: ModelArgs):
35
+ super().__init__()
36
+ self.layer_id = layer_id
37
+ self.norm_eps = args.norm_eps
38
+ self.hc_mult = args.hc_mult
39
+ self.hc_iters = args.hc_sinkhorn_iters
40
+ self.hc_eps = args.hc_eps
41
+
42
+ self.attn = Attention(layer_id, args)
43
+ self.ffn = MoE(layer_id, args)
44
+ self.attn_norm = RMSNorm(args.dim, args.norm_eps)
45
+ self.ffn_norm = RMSNorm(args.dim, args.norm_eps)
46
+
47
+ mix_hc = (2 + args.hc_mult) * args.hc_mult
48
+ hc_dim = args.hc_mult * args.dim
49
+ self.hc_attn_fn = mx.zeros((mix_hc, hc_dim), dtype=mx.float32)
50
+ self.hc_ffn_fn = mx.zeros((mix_hc, hc_dim), dtype=mx.float32)
51
+ self.hc_attn_base = mx.zeros((mix_hc,), dtype=mx.float32)
52
+ self.hc_ffn_base = mx.zeros((mix_hc,), dtype=mx.float32)
53
+ self.hc_attn_scale = mx.zeros((3,), dtype=mx.float32)
54
+ self.hc_ffn_scale = mx.zeros((3,), dtype=mx.float32)
55
+
56
+ def __call__(self, x: mx.array, input_ids: mx.array | None = None,
57
+ cache=None) -> mx.array:
58
+ residual = x
59
+ h, post, comb = hc_pre(x, self.hc_attn_fn, self.hc_attn_scale, self.hc_attn_base,
60
+ self.hc_mult, self.hc_iters, self.norm_eps, self.hc_eps)
61
+ h = self.attn(self.attn_norm(h), cache)
62
+ x = hc_post(h, residual, post, comb)
63
+
64
+ residual = x
65
+ h, post, comb = hc_pre(x, self.hc_ffn_fn, self.hc_ffn_scale, self.hc_ffn_base,
66
+ self.hc_mult, self.hc_iters, self.norm_eps, self.hc_eps)
67
+ h = self.ffn(self.ffn_norm(h), input_ids)
68
+ return hc_post(h, residual, post, comb)
69
+
70
+
71
+ class Model(nn.Module):
72
+ """embed -> expand to hc copies -> blocks -> collapse -> norm -> logits."""
73
+
74
+ def __init__(self, args: ModelArgs):
75
+ super().__init__()
76
+ self.args = args
77
+ self.hc_mult = args.hc_mult
78
+ self.embed = nn.Embedding(args.vocab_size, args.dim)
79
+ self.layers = [Block(i, args) for i in range(args.n_layers)]
80
+ self.norm = RMSNorm(args.dim, args.norm_eps)
81
+ self.head = nn.Linear(args.dim, args.vocab_size, bias=False)
82
+
83
+ self.hc_head_fn = mx.zeros((args.hc_mult, args.hc_mult * args.dim), dtype=mx.float32)
84
+ self.hc_head_base = mx.zeros((args.hc_mult,), dtype=mx.float32)
85
+ self.hc_head_scale = mx.zeros((1,), dtype=mx.float32)
86
+
87
+ def __call__(self, input_ids: mx.array, last_logit_only: bool = False,
88
+ cache=None) -> mx.array:
89
+ h = self.embed(input_ids)
90
+ # the four Hyper-Connection streams start as identical copies
91
+ h = mx.broadcast_to(h[:, :, None, :], (*h.shape[:2], self.hc_mult, h.shape[-1]))
92
+
93
+ for i, layer in enumerate(self.layers):
94
+ h = layer(h, input_ids, cache[i] if cache is not None else None)
95
+
96
+ h = hc_head(h, self.hc_head_fn, self.hc_head_scale, self.hc_head_base,
97
+ self.args.norm_eps, self.args.hc_eps)
98
+ h = self.norm(h)
99
+ if last_logit_only:
100
+ h = h[:, -1:]
101
+ # the reference keeps the LM head in fp32
102
+ return self.head(h.astype(mx.float32))
deepseek_v4_mlx/moe.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DeepSeek-V4 MoE: hash-or-score routing, clamped-SwiGLU experts, one shared expert.
2
+
3
+ Two things differ from every other MoE in ``mlx-lm``:
4
+
5
+ * **The first ``n_hash_layers`` layers do not score experts at all.** They read the
6
+ six expert indices straight out of a ``tid2eid[vocab_size, n_activated]`` table
7
+ indexed by *token id*, so routing there is independent of context.
8
+ * **The selection bias does not reach the routing weights.** Scores are captured
9
+ *before* the bias is added; the bias only reorders the top-k. Applying it to the
10
+ weights too is the obvious mistake and would be invisible on a smoke test.
11
+
12
+ Routing weights are applied after ``down_proj`` rather than before it as in the
13
+ reference. ``down_proj`` is linear and the weight is one scalar per token, so
14
+ ``w2(w * h) == w * w2(h)``; doing it afterwards is what lets the batched
15
+ :class:`SwitchGLU` gather-matmul be used instead of a 256-iteration Python loop.
16
+
17
+ That rewrite sums each token's expert outputs, whereas the reference scatters them.
18
+ The two agree as long as a token's expert indices are distinct — which holds for
19
+ this checkpoint: every sampled ``tid2eid`` row contains 6 distinct experts, and
20
+ scored routing takes a top-k, which cannot repeat.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import mlx.core as mx
26
+ import mlx.nn as nn
27
+ from mlx_lm.models.switch_layers import SwitchGLU
28
+
29
+ from .config import ModelArgs
30
+
31
+
32
+ class ClampedSwiGLU(nn.Module):
33
+ """SwiGLU with V4's asymmetric clamp: ``up`` two-sided, ``gate`` upper-only.
34
+
35
+ Called as ``activation(x_up, x_gate)`` to match ``SwitchGLU``.
36
+ """
37
+
38
+ def __init__(self, limit: float = 0.0):
39
+ super().__init__()
40
+ self.limit = limit
41
+
42
+ def __call__(self, x, gate):
43
+ if self.limit > 0:
44
+ x = mx.clip(x, -self.limit, self.limit)
45
+ gate = mx.minimum(gate, self.limit)
46
+ return nn.silu(gate) * x
47
+
48
+
49
+ class Gate(nn.Module):
50
+ """Routes each token to ``n_activated_experts`` experts.
51
+
52
+ ``hash=True`` layers use the ``tid2eid`` lookup and carry no bias; scored
53
+ layers carry a per-expert selection bias.
54
+ """
55
+
56
+ def __init__(self, layer_id: int, args: ModelArgs):
57
+ super().__init__()
58
+ self.topk = args.n_activated_experts
59
+ self.score_func = args.score_func
60
+ self.route_scale = args.route_scale
61
+ self.hash = args.is_hash_layer(layer_id)
62
+ n_exp = args.n_experts_for(layer_id)
63
+ self.weight = mx.zeros((n_exp, args.dim))
64
+ if self.hash:
65
+ self.tid2eid = mx.zeros((args.vocab_size, args.n_activated_experts), dtype=mx.int32)
66
+ else:
67
+ self.bias = mx.zeros((n_exp,), dtype=mx.float32)
68
+
69
+ def __call__(self, x: mx.array, input_ids: mx.array | None = None):
70
+ scores = x.astype(mx.float32) @ self.weight.astype(mx.float32).T
71
+ if self.score_func == "softmax":
72
+ scores = mx.softmax(scores, axis=-1)
73
+ elif self.score_func == "sigmoid":
74
+ scores = mx.sigmoid(scores)
75
+ else: # sqrtsoftplus
76
+ scores = mx.sqrt(nn.softplus(scores))
77
+
78
+ original_scores = scores
79
+ if not self.hash:
80
+ # bias shifts *selection* only — weights are read from original_scores
81
+ scores = scores + self.bias
82
+ indices = mx.argpartition(-scores, self.topk - 1, axis=-1)[..., :self.topk]
83
+ # argpartition does not order within the partition; sort so the expert
84
+ # order matches topk() and the gathered weights line up.
85
+ part = mx.take_along_axis(scores, indices, axis=-1)
86
+ order = mx.argsort(-part, axis=-1)
87
+ indices = mx.take_along_axis(indices, order, axis=-1)
88
+ else:
89
+ indices = self.tid2eid[input_ids]
90
+
91
+ weights = mx.take_along_axis(original_scores, indices, axis=-1)
92
+ if self.score_func != "softmax":
93
+ weights = weights / mx.sum(weights, axis=-1, keepdims=True)
94
+ weights = weights * self.route_scale
95
+ return weights, indices
96
+
97
+
98
+ class MoE(nn.Module):
99
+ """256 routed experts (top-6) plus 1 always-on shared expert."""
100
+
101
+ def __init__(self, layer_id: int, args: ModelArgs):
102
+ super().__init__()
103
+ self.dim = args.dim
104
+ self.gate = Gate(layer_id, args)
105
+ act = ClampedSwiGLU(args.swiglu_limit)
106
+ self.experts = SwitchGLU(args.dim, args.moe_inter_dim,
107
+ args.n_experts_for(layer_id), activation=act)
108
+ self.shared_experts = SharedExpert(args.dim, args.moe_inter_dim, args.swiglu_limit)
109
+
110
+ def __call__(self, x: mx.array, input_ids: mx.array | None = None) -> mx.array:
111
+ shape = x.shape
112
+ xf = x.reshape(-1, self.dim)
113
+ ids = input_ids.reshape(-1) if input_ids is not None else None
114
+
115
+ weights, indices = self.gate(xf, ids)
116
+ y = self.experts(xf, indices) # [tokens, topk, dim]
117
+ y = (y * weights[..., None].astype(y.dtype)).sum(axis=-2)
118
+ y = y + self.shared_experts(xf)
119
+ return y.reshape(shape).astype(x.dtype)
120
+
121
+
122
+ class SharedExpert(nn.Module):
123
+ """The always-on expert. Same clamped SwiGLU, no routing weight."""
124
+
125
+ def __init__(self, dim: int, inter_dim: int, limit: float = 0.0):
126
+ super().__init__()
127
+ self.w1 = nn.Linear(dim, inter_dim, bias=False)
128
+ self.w2 = nn.Linear(inter_dim, dim, bias=False)
129
+ self.w3 = nn.Linear(dim, inter_dim, bias=False)
130
+ self.limit = limit
131
+
132
+ def __call__(self, x: mx.array) -> mx.array:
133
+ dtype = x.dtype
134
+ gate = self.w1(x).astype(mx.float32)
135
+ up = self.w3(x).astype(mx.float32)
136
+ if self.limit > 0:
137
+ up = mx.clip(up, -self.limit, self.limit)
138
+ gate = mx.minimum(gate, self.limit)
139
+ h = nn.silu(gate) * up
140
+ return self.w2(h.astype(dtype))
deepseek_v4_mlx/sparse_attention.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gather-based sparse attention — the MLX stand-in for the TileLang kernel.
2
+
3
+ Each query position attends to an explicit list of KV indices rather than a
4
+ contiguous range. Semantics transcribed from ``sparse_attn_kernel`` in
5
+ ``inference/kernel.py``:
6
+
7
+ * ``kv`` is a **single** shared vector per position — MLA, so key and value are
8
+ the same tensor, not two projections;
9
+ * an index of ``-1`` means "not visible": its logit is ``-inf`` and it contributes
10
+ nothing;
11
+ * the learned ``attn_sink`` enters the softmax **denominator only**. It carries no
12
+ value vector, so a head whose real logits are all small attends to essentially
13
+ nothing rather than being forced to distribute weight over whatever is present.
14
+
15
+ The reference computes this with an online (FlashAttention-style) softmax for
16
+ memory reasons. A plain max-subtracted softmax is numerically equivalent; queries
17
+ are chunked here so the gathered ``[b, chunk, k, d]`` block stays bounded.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import mlx.core as mx
23
+
24
+ NEG_INF = -1e30
25
+
26
+
27
+ def _gather_kv(kv: mx.array, idx: mx.array) -> mx.array:
28
+ """``kv`` [b, n, d], ``idx`` [b, m, k] -> [b, m, k, d], row 0 for negative idx."""
29
+ b, n, d = kv.shape
30
+ flat = kv.reshape(b * n, d)
31
+ safe = mx.maximum(idx, 0).astype(mx.int32)
32
+ base = (mx.arange(b, dtype=mx.int32) * n).reshape(b, 1, 1)
33
+ return flat[(safe + base).reshape(-1)].reshape(*idx.shape, d)
34
+
35
+
36
+ def sparse_attn(q: mx.array, kv: mx.array, attn_sink: mx.array, topk_idxs: mx.array,
37
+ softmax_scale: float, chunk: int = 256) -> mx.array:
38
+ """Attend each query to its own list of KV positions.
39
+
40
+ ``q`` [b, m, h, d]
41
+ ``kv`` [b, n, d] shared key/value (MLA)
42
+ ``attn_sink`` [h] per-head null-attention logit
43
+ ``topk_idxs`` [b, m, k] int32, -1 = masked
44
+ returns [b, m, h, d]
45
+ """
46
+ b, m, h, d = q.shape
47
+ sink = attn_sink.astype(mx.float32).reshape(1, 1, h, 1)
48
+
49
+ outs = []
50
+ for start in range(0, m, chunk):
51
+ stop = min(start + chunk, m)
52
+ qc = q[:, start:stop].astype(mx.float32) # [b, c, h, d]
53
+ ic = topk_idxs[:, start:stop] # [b, c, k]
54
+ kvc = _gather_kv(kv, ic).astype(mx.float32) # [b, c, k, d]
55
+
56
+ # [b, c, h, k]
57
+ logits = mx.einsum("bchd,bckd->bchk", qc, kvc) * softmax_scale
58
+ valid = (ic >= 0)[:, :, None, :]
59
+ logits = mx.where(valid, logits, NEG_INF)
60
+
61
+ mmax = mx.max(logits, axis=-1, keepdims=True) # [b, c, h, 1]
62
+ # the sink competes with the real logits for the running max
63
+ mmax = mx.maximum(mmax, sink)
64
+ w = mx.exp(logits - mmax)
65
+ w = mx.where(valid, w, 0.0)
66
+ denom = mx.sum(w, axis=-1, keepdims=True) + mx.exp(sink - mmax)
67
+
68
+ o = mx.einsum("bchk,bckd->bchd", w, kvc) / denom
69
+ outs.append(o.astype(q.dtype))
70
+
71
+ return mx.concatenate(outs, axis=1) if len(outs) > 1 else outs[0]
encoding/README.md ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DeepSeek-V4 Encoding
2
+
3
+ This document describes the prompt encoding format used by DeepSeek-V4 series models. The encoding handles multi-turn conversations, tool calling, extended thinking (reasoning), and quick instruction tasks.
4
+
5
+ A self-contained reference implementation is provided in `encoding_dsv4.py`.
6
+
7
+ ## Quick Start
8
+
9
+ ```python
10
+ from encoding_dsv4 import encode_messages, parse_message_from_completion_text
11
+
12
+ # Encode a conversation
13
+ messages = [
14
+ {"role": "system", "content": "You are a helpful assistant."},
15
+ {"role": "user", "content": "What is 2+2?"},
16
+ ]
17
+ prompt = encode_messages(messages, thinking_mode="thinking")
18
+ # => "<|begin▁of▁sentence|>You are a helpful assistant.<|User|>What is 2+2?<|Assistant|><think>"
19
+
20
+ # Parse model output back to structured message
21
+ completion = "Simple arithmetic.</think>2 + 2 = 4.<|end▁of▁sentence|>"
22
+ parsed = parse_message_from_completion_text(completion, thinking_mode="thinking")
23
+ # => {"role": "assistant", "reasoning_content": "Simple arithmetic.", "content": "2 + 2 = 4.", "tool_calls": []}
24
+ ```
25
+
26
+ > **Note:** The `parse_message_from_completion_text` function is designed to handle well-formatted model output only. It does not attempt to correct or recover from malformed output that the model might occasionally generate. For production use, additional error handling is recommended.
27
+
28
+ ## Message Format
29
+
30
+ ### Special Tokens
31
+
32
+ | Token | Purpose |
33
+ |-------|---------|
34
+ | `<|begin▁of▁sentence|>` | Beginning of sequence (BOS) |
35
+ | `<|end▁of▁sentence|>` | End of assistant turn (EOS) |
36
+ | `<|User|>` | User turn prefix |
37
+ | `<|Assistant|>` | Assistant turn prefix |
38
+ | `<|latest_reminder|>` | Latest reminder (date, locale, etc.) |
39
+ | `<think>` / `</think>` | Reasoning block delimiters |
40
+ | `|DSML|` | DSML markup token |
41
+
42
+ ### Roles
43
+
44
+ The encoding supports the following message roles: `system`, `user`, `assistant`, `tool`, `latest_reminder`, and `developer`.
45
+
46
+ > **Note on the `developer` role:** The `developer` role is used exclusively in the internal search agent pipeline. It is not needed for general-purpose chat or tool-calling tasks, and the official API does not accept messages with this role.
47
+
48
+ ### Basic Chat
49
+
50
+ A simple multi-turn conversation is encoded as:
51
+
52
+ ```
53
+ <|begin▁of▁sentence|>{system_prompt}
54
+ <|User|>{user_message}<|Assistant|></think>{response}<|end▁of▁sentence|>
55
+ <|User|>{user_message_2}<|Assistant|></think>{response_2}<|end▁of▁sentence|>
56
+ ```
57
+
58
+ - The BOS token is prepended at the very beginning of the conversation.
59
+ - In **chat mode** (`thinking_mode="chat"`), `</think>` is placed right after `<|Assistant|>` to immediately close the thinking block, so the model generates content directly.
60
+
61
+ ### Interleaved Thinking Mode
62
+
63
+ In **thinking mode** (`thinking_mode="thinking"`), the model produces explicit reasoning inside `<think>...</think>` blocks before responding.
64
+
65
+ ```
66
+ <|begin▁of▁sentence|>{system_prompt}
67
+ <|User|>{message}<|Assistant|><think>{reasoning}</think>{response}<|end▁of▁sentence|>
68
+ ```
69
+
70
+ The `drop_thinking` parameter (default `True`) controls whether reasoning from earlier turns is preserved:
71
+
72
+ - **Without tools**: `drop_thinking` takes effect. Reasoning content from assistant turns **before** the last user message is stripped. Only the final assistant turn retains its `<think>...</think>` block.
73
+ - **With tools** (on system or developer message): `drop_thinking` is automatically disabled. All turns retain their reasoning, because tool-calling conversations require full context for the model to track multi-step reasoning across tool calls.
74
+
75
+ ### Tool Calling (DSML Format)
76
+
77
+ Tools are defined on the `system` or `developer` message via the `tools` field (OpenAI-compatible format). When tools are present, the following schema block is injected into the system/user prompt:
78
+
79
+ ```
80
+ ## Tools
81
+
82
+ You have access to a set of tools to help answer the user's question. You can invoke tools by writing a "<|DSML|tool_calls>" block like the following:
83
+
84
+ <|DSML|tool_calls>
85
+ <|DSML|invoke name="$TOOL_NAME">
86
+ <|DSML|parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</|DSML|parameter>
87
+ ...
88
+ </|DSML|invoke>
89
+ <|DSML|invoke name="$TOOL_NAME2">
90
+ ...
91
+ </|DSML|invoke>
92
+ </|DSML|tool_calls>
93
+
94
+ String parameters should be specified as is and set `string="true"`. For all other types (numbers, booleans, arrays, objects), pass the value in JSON format and set `string="false"`.
95
+
96
+ If thinking_mode is enabled (triggered by <think>), you MUST output your complete reasoning inside <think>...</think> BEFORE any tool calls or final response.
97
+
98
+ Otherwise, output directly after </think> with tool calls or final response.
99
+
100
+ ### Available Tool Schemas
101
+
102
+ {tool_definitions_json}
103
+
104
+ You MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls.
105
+ ```
106
+
107
+ An actual tool call in the assistant turn looks like:
108
+
109
+ ```xml
110
+ <|DSML|tool_calls>
111
+ <|DSML|invoke name="function_name">
112
+ <|DSML|parameter name="param" string="true">string_value</|DSML|parameter>
113
+ <|DSML|parameter name="count" string="false">5</|DSML|parameter>
114
+ </|DSML|invoke>
115
+ </|DSML|tool_calls><|end▁of▁sentence|>
116
+ ```
117
+
118
+ - `string="true"`: the parameter value is a raw string.
119
+ - `string="false"`: the parameter value is JSON (number, boolean, array, object).
120
+
121
+ Tool execution results are wrapped in `<tool_result>` tags within user messages:
122
+
123
+ ```
124
+ <|User|><tool_result>{result_json}</tool_result><|Assistant|><think>...
125
+ ```
126
+
127
+ When multiple tool results are present, they are sorted by the order of the corresponding `tool_calls` in the preceding assistant message.
128
+
129
+ ### Reasoning Effort
130
+
131
+ In thinking mode, the `reasoning_effort` parameter selects one of three levels, which control how much deliberation the model spends before answering. The level is realized purely as a text prefix prepended at the very beginning of the prompt (before the system message); the rest of the encoding is identical across levels.
132
+
133
+ | `reasoning_effort` | Prompt prefix |
134
+ |:---|:---|
135
+ | `"low"` (default) | none |
136
+ | `"high"` | `Reasoning Effort: Absolute maximum ...` |
137
+ | `"max"` | `Reasoning Effort: Beyond maximum ...` |
138
+
139
+ `reasoning_effort` has no effect in chat mode (`thinking_mode="chat"`), where the model does not produce a reasoning block at all.
140
+
141
+ The full prefix text for `"high"`:
142
+
143
+ ```
144
+ Reasoning Effort: Absolute maximum with no shortcuts permitted.
145
+ You MUST be very thorough in your thinking and comprehensively decompose the problem to resolve the root cause, rigorously stress-testing your logic against all potential paths, edge cases, and adversarial scenarios.
146
+ Explicitly write out your entire deliberation process, documenting every intermediate step, considered alternative, and rejected hypothesis to ensure absolutely no assumption is left unchecked.
147
+ ```
148
+
149
+ And for `"max"`:
150
+
151
+ ```
152
+ Reasoning Effort: Beyond maximum — exhaustive, relentless, and uncompromising.
153
+ You MUST reason with the utmost depth and rigor, leaving absolutely nothing to chance: exhaustively decompose the problem into its most fundamental components, trace every causal chain to its root, and resolve the underlying cause rather than any surface symptom.
154
+ Do not stop reasoning until you have independently verified the solution from multiple angles and are certain that no assumption remains unchecked and no error remains undiscovered.
155
+ ```
156
+
157
+ ### Quick Instruction Special Tokens
158
+
159
+ Quick instruction tokens are used for auxiliary classification and generation tasks. They are appended to messages via the `"task"` field to trigger specialized model behavior for a single-token or short-form output.
160
+
161
+ | Special Token | Description | Format |
162
+ |:---|:---|:---|
163
+ | `<|action|>` | Determines whether the user prompt requires a web search or can be answered directly. | `...<|User|>{prompt}<|Assistant|><think><|action|>` |
164
+ | `<|title|>` | Generates a concise conversation title after the first assistant response. | `...<|Assistant|>{response}<|end▁of▁sentence|><|title|>` |
165
+ | `<|query|>` | Generates search queries for the user prompt. | `...<|User|>{prompt}<|query|>` |
166
+ | `<|authority|>` | Classifies the user prompt's demand for source authoritativeness. | `...<|User|>{prompt}<|authority|>` |
167
+ | `<|domain|>` | Identifies the domain of the user prompt. | `...<|User|>{prompt}<|domain|>` |
168
+ | `<|extracted_url|>` `<|read_url|>` | Determines whether each URL in the user prompt should be fetched and read. | `...<|User|>{prompt}<|extracted_url|>{url}<|read_url|>` |
169
+
170
+ Usage in message format:
171
+
172
+ - **`action`** on a user message: the `<|action|>` token is placed after the assistant prefix and thinking token, triggering a routing decision (e.g., "Search" or "Answer").
173
+ - **Other tasks** (`query`, `authority`, `domain`, `read_url`) on a user message: the task token is appended directly after the user content.
174
+ - **`title`** on an assistant message: the `<|title|>` token is appended after the assistant's EOS. The next assistant message provides the generated title.
encoding/encoding_dsv4.py ADDED
@@ -0,0 +1,760 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DeepSeek-V4 Encoding
3
+
4
+ A self-contained implementation for encoding/decoding DeepSeek-V4 chat messages
5
+ with tool calling, thinking mode, and quick instruction task support.
6
+ """
7
+
8
+ from typing import Any, Dict, List, Union, Optional, Tuple
9
+ import copy
10
+ import json
11
+ import re
12
+
13
+ # ============================================================
14
+ # Special Tokens
15
+ # ============================================================
16
+
17
+ bos_token: str = "<|begin▁of▁sentence|>"
18
+ eos_token: str = "<|end▁of▁sentence|>"
19
+ thinking_start_token: str = "<think>"
20
+ thinking_end_token: str = "</think>"
21
+ dsml_token: str = "|DSML|"
22
+
23
+ USER_SP_TOKEN = "<|User|>"
24
+ ASSISTANT_SP_TOKEN = "<|Assistant|>"
25
+ LATEST_REMINDER_SP_TOKEN = "<|latest_reminder|>"
26
+
27
+ # Task special tokens for internal classification tasks
28
+ DS_TASK_SP_TOKENS = {
29
+ "action": "<|action|>",
30
+ "query": "<|query|>",
31
+ "authority": "<|authority|>",
32
+ "domain": "<|domain|>",
33
+ "title": "<|title|>",
34
+ "read_url": "<|read_url|>",
35
+ }
36
+ VALID_TASKS = set(DS_TASK_SP_TOKENS.keys())
37
+
38
+ # ============================================================
39
+ # Templates
40
+ # ============================================================
41
+
42
+ system_msg_template: str = "{content}"
43
+ user_msg_template: str = "{content}"
44
+ latest_reminder_msg_template: str = "{content}"
45
+ assistant_msg_template: str = "{reasoning}{content}{tool_calls}" + eos_token
46
+ assistant_msg_wo_eos_template: str = "{reasoning}{content}{tool_calls}"
47
+ thinking_template: str = "{reasoning_content}"
48
+
49
+ response_format_template: str = (
50
+ "## Response Format:\n\nYou MUST strictly adhere to the following schema to reply:\n{schema}"
51
+ )
52
+ tool_call_template: str = (
53
+ "<{dsml_token}invoke name=\"{name}\">\n{arguments}\n</{dsml_token}invoke>"
54
+ )
55
+ tool_calls_template = (
56
+ "<{dsml_token}{tc_block_name}>\n{tool_calls}\n</{dsml_token}{tc_block_name}>"
57
+ )
58
+ tool_calls_block_name: str = "tool_calls"
59
+
60
+ tool_output_template: str = (
61
+ "<tool_result>{content}</tool_result>"
62
+ )
63
+
64
+ # Reasoning effort levels. In thinking mode, the prompt for the selected level is
65
+ # prepended at the very beginning of the conversation. `low` is the default and
66
+ # adds nothing.
67
+ REASONING_EFFORT_PROMPTS: Dict[str, str] = {
68
+ "low": "",
69
+ "high": (
70
+ "Reasoning Effort: Absolute maximum with no shortcuts permitted.\n"
71
+ "You MUST be very thorough in your thinking and comprehensively decompose the problem to resolve the root cause, rigorously stress-testing your logic against all potential paths, edge cases, and adversarial scenarios.\n"
72
+ "Explicitly write out your entire deliberation process, documenting every intermediate step, considered alternative, and rejected hypothesis to ensure absolutely no assumption is left unchecked.\n\n"
73
+ ),
74
+ "max": (
75
+ "Reasoning Effort: Beyond maximum — exhaustive, relentless, and uncompromising.\n"
76
+ "You MUST reason with the utmost depth and rigor, leaving absolutely nothing to chance: exhaustively decompose the problem into its most fundamental components, trace every causal chain to its root, and resolve the underlying cause rather than any surface symptom.\n"
77
+ "Do not stop reasoning until you have independently verified the solution from multiple angles and are certain that no assumption remains unchecked and no error remains undiscovered.\n\n"
78
+ ),
79
+ }
80
+ DEFAULT_REASONING_EFFORT = "low"
81
+
82
+ TOOLS_TEMPLATE = """## Tools
83
+
84
+ You have access to a set of tools to help answer the user's question. You can invoke tools by writing a "<{dsml_token}tool_calls>" block like the following:
85
+
86
+ <{dsml_token}tool_calls>
87
+ <{dsml_token}invoke name="$TOOL_NAME">
88
+ <{dsml_token}parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</{dsml_token}parameter>
89
+ ...
90
+ </{dsml_token}invoke>
91
+ <{dsml_token}invoke name="$TOOL_NAME2">
92
+ ...
93
+ </{dsml_token}invoke>
94
+ </{dsml_token}tool_calls>
95
+
96
+ String parameters should be specified as is and set `string="true"`. For all other types (numbers, booleans, arrays, objects), pass the value in JSON format and set `string="false"`.
97
+
98
+ If thinking_mode is enabled (triggered by {thinking_start_token}), you MUST output your complete reasoning inside {thinking_start_token}...{thinking_end_token} BEFORE any tool calls or final response.
99
+
100
+ Otherwise, output directly after {thinking_end_token} with tool calls or final response.
101
+
102
+ ### Available Tool Schemas
103
+
104
+ {tool_schemas}
105
+
106
+ You MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls.
107
+ """
108
+
109
+ # ============================================================
110
+ # Utility Functions
111
+ # ============================================================
112
+
113
+ def to_json(value: Any) -> str:
114
+ """Serialize a value to JSON string."""
115
+ try:
116
+ return json.dumps(value, ensure_ascii=False)
117
+ except:
118
+ return json.dumps(value, ensure_ascii=True)
119
+
120
+
121
+ def tools_from_openai_format(tools):
122
+ """Extract function definitions from OpenAI-format tool list."""
123
+ return [tool["function"] for tool in tools]
124
+
125
+
126
+ def tool_calls_from_openai_format(tool_calls):
127
+ """Convert OpenAI-format tool calls to internal format."""
128
+ return [
129
+ {
130
+ "name": tool_call["function"]["name"],
131
+ "arguments": tool_call["function"]["arguments"],
132
+ }
133
+ for tool_call in tool_calls
134
+ ]
135
+
136
+
137
+ def tool_calls_to_openai_format(tool_calls):
138
+ """Convert internal tool calls to OpenAI format."""
139
+ return [
140
+ {
141
+ "type": "function",
142
+ "function": {
143
+ "name": tool_call["name"],
144
+ "arguments": tool_call["arguments"],
145
+ }
146
+ }
147
+ for tool_call in tool_calls
148
+ ]
149
+
150
+
151
+ def encode_arguments_to_dsml(tool_call: Dict[str, str]) -> str:
152
+ """
153
+ Encode tool call arguments into DSML parameter format.
154
+
155
+ Args:
156
+ tool_call: Dict with "name" and "arguments" (JSON string) keys.
157
+
158
+ Returns:
159
+ DSML-formatted parameter string.
160
+ """
161
+ p_dsml_template = '<{dsml_token}parameter name="{key}" string="{is_str}">{value}</{dsml_token}parameter>'
162
+ P_dsml_strs = []
163
+
164
+ try:
165
+ arguments = json.loads(tool_call["arguments"])
166
+ except Exception as err:
167
+ arguments = {"arguments": tool_call["arguments"]}
168
+
169
+ for k, v in arguments.items():
170
+ p_dsml_str = p_dsml_template.format(
171
+ dsml_token=dsml_token,
172
+ key=k,
173
+ is_str="true" if isinstance(v, str) else "false",
174
+ value=v if isinstance(v, str) else to_json(v),
175
+ )
176
+ P_dsml_strs.append(p_dsml_str)
177
+
178
+ return "\n".join(P_dsml_strs)
179
+
180
+
181
+ def decode_dsml_to_arguments(tool_name: str, tool_args: Dict[str, Tuple[str, str]]) -> Dict[str, str]:
182
+ """
183
+ Decode DSML parameters back to a tool call dict.
184
+
185
+ Args:
186
+ tool_name: Name of the tool.
187
+ tool_args: Dict mapping param_name -> (value, is_string_flag).
188
+
189
+ Returns:
190
+ Dict with "name" and "arguments" (JSON string) keys.
191
+ """
192
+ def _decode_value(key: str, value: str, string: str):
193
+ if string == "true":
194
+ value = to_json(value)
195
+ return f"{to_json(key)}: {value}"
196
+
197
+ tool_args_json = "{" + ", ".join([_decode_value(k, v, string=is_str) for k, (v, is_str) in tool_args.items()]) + "}"
198
+ return dict(name=tool_name, arguments=tool_args_json)
199
+
200
+
201
+ def render_tools(tools: List[Dict[str, Union[str, Dict[str, Any]]]]) -> str:
202
+ """
203
+ Render tool schemas into the system prompt format.
204
+
205
+ Args:
206
+ tools: List of tool schema dicts (each with name, description, parameters).
207
+
208
+ Returns:
209
+ Formatted tools section string.
210
+ """
211
+ tools_json = [to_json(t) for t in tools]
212
+
213
+ return TOOLS_TEMPLATE.format(
214
+ tool_schemas="\n".join(tools_json),
215
+ dsml_token=dsml_token,
216
+ thinking_start_token=thinking_start_token,
217
+ thinking_end_token=thinking_end_token,
218
+ )
219
+
220
+
221
+ def find_last_user_index(messages: List[Dict[str, Any]]) -> int:
222
+ """Find the index of the last user/developer message."""
223
+ last_user_index = -1
224
+ for idx in range(len(messages) - 1, -1, -1):
225
+ if messages[idx].get("role") in ["user", "developer"]:
226
+ last_user_index = idx
227
+ break
228
+ return last_user_index
229
+
230
+
231
+ # ============================================================
232
+ # Message Rendering
233
+ # ============================================================
234
+
235
+ def render_message(index: int, messages: List[Dict[str, Any]], thinking_mode: str, drop_thinking: bool = True, reasoning_effort: Optional[str] = None) -> str:
236
+ """
237
+ Render a single message at the given index into its encoded string form.
238
+
239
+ This is the core function that converts each message in the conversation
240
+ into the DeepSeek-V4 format.
241
+
242
+ Args:
243
+ index: Index of the message to render.
244
+ messages: Full list of messages in the conversation.
245
+ thinking_mode: Either "chat" or "thinking".
246
+ drop_thinking: Whether to drop reasoning content from earlier turns.
247
+ reasoning_effort: Reasoning effort level, one of "low", "high", "max".
248
+ None is treated as "low".
249
+
250
+ Returns:
251
+ Encoded string for this message.
252
+ """
253
+ assert 0 <= index < len(messages)
254
+ assert thinking_mode in ["chat", "thinking"], f"Invalid thinking_mode `{thinking_mode}`"
255
+
256
+ prompt = ""
257
+ msg = messages[index]
258
+ last_user_idx = find_last_user_index(messages)
259
+
260
+ role = msg.get("role")
261
+ content = msg.get("content")
262
+ tools = msg.get("tools")
263
+ response_format = msg.get("response_format")
264
+ tool_calls = msg.get("tool_calls")
265
+ reasoning_content = msg.get("reasoning_content")
266
+ wo_eos = msg.get("wo_eos", False)
267
+
268
+ if tools:
269
+ tools = tools_from_openai_format(tools)
270
+ if tool_calls:
271
+ tool_calls = tool_calls_from_openai_format(tool_calls)
272
+
273
+ # Reasoning effort prefix (only at index 0 in thinking mode; "low" adds nothing)
274
+ reasoning_effort = reasoning_effort or DEFAULT_REASONING_EFFORT
275
+ assert reasoning_effort in REASONING_EFFORT_PROMPTS, \
276
+ f"Invalid reasoning effort: {reasoning_effort}, expected one of {list(REASONING_EFFORT_PROMPTS)}"
277
+ if index == 0 and thinking_mode == "thinking":
278
+ prompt += REASONING_EFFORT_PROMPTS[reasoning_effort]
279
+
280
+ if role == "system":
281
+ prompt += system_msg_template.format(content=content or "")
282
+ if tools:
283
+ prompt += "\n\n" + render_tools(tools)
284
+ if response_format:
285
+ prompt += "\n\n" + response_format_template.format(schema=to_json(response_format))
286
+
287
+ elif role == "developer":
288
+ assert content, f"Invalid message for role `{role}`: {msg}"
289
+
290
+ content_developer = USER_SP_TOKEN
291
+ content_developer += content
292
+
293
+ if tools:
294
+ content_developer += "\n\n" + render_tools(tools)
295
+ if response_format:
296
+ content_developer += "\n\n" + response_format_template.format(schema=to_json(response_format))
297
+
298
+ prompt += user_msg_template.format(content=content_developer)
299
+
300
+ elif role == "user":
301
+ prompt += USER_SP_TOKEN
302
+
303
+ # Handle content blocks (tool results mixed with text)
304
+ content_blocks = msg.get("content_blocks")
305
+ if content_blocks:
306
+ parts = []
307
+ for block in content_blocks:
308
+ block_type = block.get("type")
309
+ if block_type == "text":
310
+ parts.append(block.get("text", ""))
311
+ elif block_type == "tool_result":
312
+ tool_content = block.get("content", "")
313
+ if isinstance(tool_content, list):
314
+ text_parts = []
315
+ for b in tool_content:
316
+ if b.get("type") == "text":
317
+ text_parts.append(b.get("text", ""))
318
+ else:
319
+ text_parts.append(f"[Unsupported {b.get('type')}]")
320
+ tool_content = "\n\n".join(text_parts)
321
+ parts.append(tool_output_template.format(content=tool_content))
322
+ else:
323
+ parts.append(f"[Unsupported {block_type}]")
324
+ prompt += "\n\n".join(parts)
325
+ else:
326
+ prompt += content or ""
327
+
328
+ elif role == "latest_reminder":
329
+ prompt += LATEST_REMINDER_SP_TOKEN + latest_reminder_msg_template.format(content=content)
330
+
331
+ elif role == "tool":
332
+ raise NotImplementedError("deepseek_v4 merges tool messages into user; please preprocess with merge_tool_messages()")
333
+
334
+ elif role == "assistant":
335
+ thinking_part = ""
336
+ tc_content = ""
337
+
338
+ if tool_calls:
339
+ tc_list = [
340
+ tool_call_template.format(
341
+ dsml_token=dsml_token,
342
+ name=tc.get("name"),
343
+ arguments=encode_arguments_to_dsml(tc)
344
+ )
345
+ for tc in tool_calls
346
+ ]
347
+ tc_content += '\n\n' + tool_calls_template.format(
348
+ dsml_token=dsml_token,
349
+ tool_calls="\n".join(tc_list),
350
+ tc_block_name=tool_calls_block_name,
351
+ )
352
+
353
+ summary_content = content or ""
354
+ rc = reasoning_content or ""
355
+
356
+ # Check if previous message has a task - if so, this is a task output (no thinking)
357
+ prev_has_task = index - 1 >= 0 and messages[index - 1].get("task") is not None
358
+
359
+ if thinking_mode == "thinking" and not prev_has_task:
360
+ if not drop_thinking or index > last_user_idx:
361
+ thinking_part = thinking_template.format(reasoning_content=rc) + thinking_end_token
362
+ else:
363
+ thinking_part = ""
364
+
365
+ if wo_eos:
366
+ prompt += assistant_msg_wo_eos_template.format(
367
+ reasoning=thinking_part,
368
+ content=summary_content,
369
+ tool_calls=tc_content,
370
+ )
371
+ else:
372
+ prompt += assistant_msg_template.format(
373
+ reasoning=thinking_part,
374
+ content=summary_content,
375
+ tool_calls=tc_content,
376
+ )
377
+ else:
378
+ raise NotImplementedError(f"Unknown role: {role}")
379
+
380
+ # Append transition tokens based on what follows
381
+ if index + 1 < len(messages) and messages[index + 1].get("role") not in ["assistant", "latest_reminder"]:
382
+ return prompt
383
+
384
+ task = messages[index].get("task")
385
+ if task is not None:
386
+ # Task special token for internal classification tasks
387
+ assert task in VALID_TASKS, f"Invalid task: '{task}'. Valid tasks are: {list(VALID_TASKS)}"
388
+ task_sp_token = DS_TASK_SP_TOKENS[task]
389
+
390
+ if task != "action":
391
+ # Non-action tasks: append task sp token directly after the message
392
+ prompt += task_sp_token
393
+ else:
394
+ # Action task: append Assistant + thinking token + action sp token
395
+ prompt += ASSISTANT_SP_TOKEN
396
+ prompt += thinking_end_token if thinking_mode != "thinking" else thinking_start_token
397
+ prompt += task_sp_token
398
+
399
+ elif messages[index].get("role") in ["user", "developer"]:
400
+ # Normal generation: append Assistant + thinking token
401
+ prompt += ASSISTANT_SP_TOKEN
402
+ if not drop_thinking and thinking_mode == "thinking":
403
+ prompt += thinking_start_token
404
+ elif drop_thinking and thinking_mode == "thinking" and index >= last_user_idx:
405
+ prompt += thinking_start_token
406
+ else:
407
+ prompt += thinking_end_token
408
+
409
+ return prompt
410
+
411
+
412
+ # ============================================================
413
+ # Preprocessing
414
+ # ============================================================
415
+
416
+ def merge_tool_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
417
+ """
418
+ Merge tool messages into the preceding user message using content_blocks format.
419
+
420
+ DeepSeek-V4 does not have a standalone "tool" role; instead, tool results
421
+ are encoded as <tool_result> blocks within user messages.
422
+
423
+ This function converts a standard OpenAI-format conversation (with separate
424
+ "tool" role messages) into V4 format where tool results are merged into
425
+ user messages.
426
+
427
+ Args:
428
+ messages: List of message dicts in OpenAI format.
429
+
430
+ Returns:
431
+ Processed message list with tool messages merged into user messages.
432
+ """
433
+ merged: List[Dict[str, Any]] = []
434
+
435
+ for msg in messages:
436
+ msg = copy.deepcopy(msg)
437
+ role = msg.get("role")
438
+
439
+ if role == "tool":
440
+ # Convert tool message to a user message with tool_result block
441
+ tool_block = {
442
+ "type": "tool_result",
443
+ "tool_use_id": msg.get("tool_call_id", ""),
444
+ "content": msg.get("content", ""),
445
+ }
446
+ # Merge into previous message if it's already a user (merged tool)
447
+ if merged and merged[-1].get("role") == "user" and "content_blocks" in merged[-1]:
448
+ merged[-1]["content_blocks"].append(tool_block)
449
+ else:
450
+ merged.append({
451
+ "role": "user",
452
+ "content_blocks": [tool_block],
453
+ })
454
+ elif role == "user":
455
+ text_block = {"type": "text", "text": msg.get("content", "")}
456
+ if merged and merged[-1].get("role") == "user" and "content_blocks" in merged[-1] and merged[-1].get("task") is None:
457
+ merged[-1]["content_blocks"].append(text_block)
458
+ else:
459
+ new_msg = {
460
+ "role": "user",
461
+ "content": msg.get("content", ""),
462
+ "content_blocks": [text_block],
463
+ }
464
+ # Preserve extra fields (task, wo_eos, mask, etc.)
465
+ for key in ("task", "wo_eos", "mask"):
466
+ if key in msg:
467
+ new_msg[key] = msg[key]
468
+ merged.append(new_msg)
469
+ else:
470
+ merged.append(msg)
471
+
472
+ return merged
473
+
474
+
475
+ def sort_tool_results_by_call_order(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
476
+ """
477
+ Sort tool_result blocks within user messages by the order of tool_calls
478
+ in the preceding assistant message.
479
+
480
+ Args:
481
+ messages: Preprocessed message list (after merge_tool_messages).
482
+
483
+ Returns:
484
+ Message list with sorted tool result blocks.
485
+ """
486
+ last_tool_call_order: Dict[str, int] = {}
487
+
488
+ for msg in messages:
489
+ role = msg.get("role")
490
+ if role == "assistant" and msg.get("tool_calls"):
491
+ last_tool_call_order = {}
492
+ for idx, tc in enumerate(msg["tool_calls"]):
493
+ tc_id = tc.get("id") or tc.get("function", {}).get("id", "")
494
+ if tc_id:
495
+ last_tool_call_order[tc_id] = idx
496
+
497
+ elif role == "user" and msg.get("content_blocks"):
498
+ tool_blocks = [b for b in msg["content_blocks"] if b.get("type") == "tool_result"]
499
+ if len(tool_blocks) > 1 and last_tool_call_order:
500
+ sorted_blocks = sorted(
501
+ tool_blocks,
502
+ key=lambda b: last_tool_call_order.get(b.get("tool_use_id", ""), 0)
503
+ )
504
+ sorted_idx = 0
505
+ new_blocks = []
506
+ for block in msg["content_blocks"]:
507
+ if block.get("type") == "tool_result":
508
+ new_blocks.append(sorted_blocks[sorted_idx])
509
+ sorted_idx += 1
510
+ else:
511
+ new_blocks.append(block)
512
+ msg["content_blocks"] = new_blocks
513
+
514
+ return messages
515
+
516
+
517
+ # ============================================================
518
+ # Main Encoding Function
519
+ # ============================================================
520
+
521
+ def encode_messages(
522
+ messages: List[Dict[str, Any]],
523
+ thinking_mode: str,
524
+ context: Optional[List[Dict[str, Any]]] = None,
525
+ drop_thinking: bool = True,
526
+ add_default_bos_token: bool = True,
527
+ reasoning_effort: Optional[str] = None,
528
+ ) -> str:
529
+ """
530
+ Encode a list of messages into the DeepSeek-V4 prompt format.
531
+
532
+ This is the main entry point for encoding conversations. It handles:
533
+ - BOS token insertion
534
+ - Thinking mode with optional reasoning content dropping
535
+ - Tool message merging into user messages
536
+ - Multi-turn conversation context
537
+
538
+ Args:
539
+ messages: List of message dicts to encode.
540
+ thinking_mode: Either "chat" or "thinking".
541
+ context: Optional preceding context messages (already encoded prefix).
542
+ drop_thinking: If True, drop reasoning_content from earlier assistant turns
543
+ (only keep reasoning for messages after the last user message).
544
+ add_default_bos_token: Whether to prepend BOS token at conversation start.
545
+ reasoning_effort: Reasoning effort level, one of "low", "high", "max".
546
+ Only takes effect in thinking mode. None is treated as "low".
547
+
548
+ Returns:
549
+ The encoded prompt string.
550
+ """
551
+ context = context if context else []
552
+
553
+ # Preprocess: merge tool messages and sort tool results
554
+ messages = merge_tool_messages(messages)
555
+ messages = sort_tool_results_by_call_order(context + messages)[len(context):]
556
+ if context:
557
+ context = merge_tool_messages(context)
558
+ context = sort_tool_results_by_call_order(context)
559
+
560
+ full_messages = context + messages
561
+
562
+ prompt = bos_token if add_default_bos_token and len(context) == 0 else ""
563
+
564
+ # Resolve drop_thinking: if any message has tools defined, don't drop thinking
565
+ effective_drop_thinking = drop_thinking
566
+ if any(m.get("tools") for m in full_messages):
567
+ effective_drop_thinking = False
568
+
569
+ if thinking_mode == "thinking" and effective_drop_thinking:
570
+ full_messages = _drop_thinking_messages(full_messages)
571
+ # After dropping, recalculate how many messages to render
572
+ # (context may have shrunk too)
573
+ num_to_render = len(full_messages) - len(_drop_thinking_messages(context))
574
+ context_len = len(full_messages) - num_to_render
575
+ else:
576
+ num_to_render = len(messages)
577
+ context_len = len(context)
578
+
579
+ for idx in range(num_to_render):
580
+ prompt += render_message(
581
+ idx + context_len,
582
+ full_messages,
583
+ thinking_mode=thinking_mode,
584
+ drop_thinking=effective_drop_thinking,
585
+ reasoning_effort=reasoning_effort,
586
+ )
587
+
588
+ return prompt
589
+
590
+
591
+ def _drop_thinking_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
592
+ """
593
+ Drop reasoning_content and non-essential messages before the last user message.
594
+
595
+ Behavior:
596
+ - Messages with role in ["user", "system", "tool", "latest_reminder"] are always kept.
597
+ - Messages at or after the last user index are always kept.
598
+ - Assistant messages before the last user get reasoning_content removed.
599
+ - Developer messages before the last user are dropped entirely.
600
+ """
601
+ last_user_idx = find_last_user_index(messages)
602
+ result = []
603
+ keep_roles = {"user", "system", "tool", "latest_reminder", "direct_search_results"}
604
+
605
+ for idx, msg in enumerate(messages):
606
+ role = msg.get("role")
607
+ if role in keep_roles or idx >= last_user_idx:
608
+ result.append(msg)
609
+ elif role == "assistant":
610
+ msg = copy.copy(msg)
611
+ msg.pop("reasoning_content", None)
612
+ result.append(msg)
613
+ # developer and other roles before last_user_idx are dropped
614
+
615
+ return result
616
+
617
+
618
+ # ============================================================
619
+ # Parsing (Decoding model output)
620
+ # ============================================================
621
+
622
+ def _read_until_stop(index: int, text: str, stop: List[str]) -> Tuple[int, str, Optional[str]]:
623
+ """
624
+ Read text from index until one of the stop strings is found.
625
+
626
+ Returns:
627
+ Tuple of (new_index, content_before_stop, matched_stop_string_or_None).
628
+ """
629
+ min_pos = len(text)
630
+ matched_stop = None
631
+
632
+ for s in stop:
633
+ pos = text.find(s, index)
634
+ if pos != -1 and pos < min_pos:
635
+ min_pos = pos
636
+ matched_stop = s
637
+
638
+ if matched_stop:
639
+ content = text[index:min_pos]
640
+ return min_pos + len(matched_stop), content, matched_stop
641
+ else:
642
+ content = text[index:]
643
+ return len(text), content, None
644
+
645
+
646
+ def parse_tool_calls(index: int, text: str) -> Tuple[int, Optional[str], List[Dict[str, str]]]:
647
+ """
648
+ Parse DSML tool calls from text starting at the given index.
649
+
650
+ Args:
651
+ index: Starting position in text.
652
+ text: The full text to parse.
653
+
654
+ Returns:
655
+ Tuple of (new_index, last_stop_token, list_of_tool_call_dicts).
656
+ Each tool call dict has "name" and "arguments" keys.
657
+ """
658
+ tool_calls: List[Dict[str, Any]] = []
659
+ stop_token = None
660
+ tool_calls_end_token = f"</{dsml_token}{tool_calls_block_name}>"
661
+
662
+ while index < len(text):
663
+ index, _, stop_token = _read_until_stop(index, text, [f"<{dsml_token}invoke", tool_calls_end_token])
664
+ if _ != ">\n":
665
+ raise ValueError(f"Tool call format error: expected '>\\n' but got '{_}'")
666
+
667
+ if stop_token == tool_calls_end_token:
668
+ break
669
+
670
+ if stop_token is None:
671
+ raise ValueError("Missing special token in tool calls")
672
+
673
+ index, tool_name_content, stop_token = _read_until_stop(index, text, [f"<{dsml_token}parameter", f"</{dsml_token}invoke"])
674
+
675
+ p_tool_name = re.findall(r'^\s*name="(.*?)">\n$', tool_name_content, flags=re.DOTALL)
676
+ if len(p_tool_name) != 1:
677
+ raise ValueError(f"Tool name format error: '{tool_name_content}'")
678
+ tool_name = p_tool_name[0]
679
+
680
+ tool_args: Dict[str, Tuple[str, str]] = {}
681
+ while stop_token == f"<{dsml_token}parameter":
682
+ index, param_content, stop_token = _read_until_stop(index, text, [f"/{dsml_token}parameter"])
683
+
684
+ param_kv = re.findall(r'^ name="(.*?)" string="(true|false)">(.*?)<$', param_content, flags=re.DOTALL)
685
+ if len(param_kv) != 1:
686
+ raise ValueError(f"Parameter format error: '{param_content}'")
687
+ param_name, string, param_value = param_kv[0]
688
+
689
+ if param_name in tool_args:
690
+ raise ValueError(f"Duplicate parameter name: '{param_name}'")
691
+ tool_args[param_name] = (param_value, string)
692
+
693
+ index, content, stop_token = _read_until_stop(index, text, [f"<{dsml_token}parameter", f"</{dsml_token}invoke"])
694
+ if content != ">\n":
695
+ raise ValueError(f"Parameter format error: expected '>\\n' but got '{content}'")
696
+
697
+ tool_call = decode_dsml_to_arguments(tool_name=tool_name, tool_args=tool_args)
698
+ tool_calls.append(tool_call)
699
+
700
+ return index, stop_token, tool_calls
701
+
702
+
703
+ def parse_message_from_completion_text(text: str, thinking_mode: str) -> Dict[str, Any]:
704
+ """
705
+ Parse a model completion text into a structured assistant message.
706
+
707
+ This function takes the raw text output from the model (a single assistant turn)
708
+ and extracts:
709
+ - reasoning_content (thinking block)
710
+ - content (summary/response)
711
+ - tool_calls (if any)
712
+
713
+ NOTE: This function is designed to parse only correctly formatted strings and
714
+ will raise ValueError for malformed output.
715
+
716
+ Args:
717
+ text: The raw completion text (including EOS token).
718
+ thinking_mode: Either "chat" or "thinking".
719
+
720
+ Returns:
721
+ Dict with keys: "role", "content", "reasoning_content", "tool_calls".
722
+ tool_calls are in OpenAI format.
723
+ """
724
+ summary_content, reasoning_content, tool_calls = "", "", []
725
+ index, stop_token = 0, None
726
+ tool_calls_start_token = f"\n\n<{dsml_token}{tool_calls_block_name}"
727
+
728
+ is_thinking = thinking_mode == "thinking"
729
+ is_tool_calling = False
730
+
731
+ if is_thinking:
732
+ index, content_delta, stop_token = _read_until_stop(index, text, [thinking_end_token, tool_calls_start_token])
733
+ reasoning_content = content_delta
734
+ assert stop_token == thinking_end_token, "Invalid thinking format: missing </think>"
735
+
736
+ index, content_delta, stop_token = _read_until_stop(index, text, [eos_token, tool_calls_start_token])
737
+ summary_content = content_delta
738
+ if stop_token == tool_calls_start_token:
739
+ is_tool_calling = True
740
+ else:
741
+ assert stop_token == eos_token, "Invalid format: missing EOS token"
742
+
743
+ if is_tool_calling:
744
+ index, stop_token, tool_calls = parse_tool_calls(index, text)
745
+
746
+ index, tool_ends_text, stop_token = _read_until_stop(index, text, [eos_token])
747
+ assert not tool_ends_text, "Unexpected content after tool calls"
748
+
749
+ assert len(text) == index and stop_token in [eos_token, None], "Unexpected content at end"
750
+
751
+ for sp_token in [bos_token, eos_token, thinking_start_token, thinking_end_token, dsml_token]:
752
+ assert sp_token not in summary_content and sp_token not in reasoning_content, \
753
+ f"Unexpected special token '{sp_token}' in content"
754
+
755
+ return {
756
+ "role": "assistant",
757
+ "content": summary_content,
758
+ "reasoning_content": reasoning_content,
759
+ "tool_calls": tool_calls_to_openai_format(tool_calls)
760
+ }
encoding/test_encoding_dsv4.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test suite for DeepSeek-V4 Encoding.
3
+
4
+ Run: python test_encoding_dsv4.py
5
+ """
6
+
7
+ import json
8
+ import os
9
+
10
+ from encoding_dsv4 import encode_messages, parse_message_from_completion_text
11
+
12
+ TESTS_DIR = os.path.join(os.path.dirname(__file__), "tests")
13
+
14
+
15
+ def test_case_1():
16
+ """Thinking mode with tool calls (multi-turn, tool results merged into user)."""
17
+ with open(os.path.join(TESTS_DIR, "test_input_1.json")) as f:
18
+ td = json.load(f)
19
+ messages = td["messages"]
20
+ messages[0]["tools"] = td["tools"]
21
+ gold = open(os.path.join(TESTS_DIR, "test_output_1.txt")).read()
22
+ prompt = encode_messages(messages, thinking_mode="thinking")
23
+ assert prompt == gold
24
+
25
+ # Parse: assistant turn with tool call
26
+ marker = "<|Assistant|><think>"
27
+ first_start = prompt.find(marker) + len(marker)
28
+ first_end = prompt.find("<|User|>", first_start)
29
+ parsed_tc = parse_message_from_completion_text(prompt[first_start:first_end], thinking_mode="thinking")
30
+ assert parsed_tc["reasoning_content"] == "The user wants to know the weather in Beijing. I should use the get_weather tool."
31
+ assert parsed_tc["content"] == ""
32
+ assert len(parsed_tc["tool_calls"]) == 1
33
+ assert parsed_tc["tool_calls"][0]["function"]["name"] == "get_weather"
34
+ assert json.loads(parsed_tc["tool_calls"][0]["function"]["arguments"]) == {"location": "Beijing", "unit": "celsius"}
35
+
36
+ # Parse: final assistant turn with content
37
+ last_start = prompt.rfind(marker) + len(marker)
38
+ parsed_final = parse_message_from_completion_text(prompt[last_start:], thinking_mode="thinking")
39
+ assert parsed_final["reasoning_content"] == "Got the weather data. Let me format a nice response."
40
+ assert "22°C" in parsed_final["content"]
41
+ assert parsed_final["tool_calls"] == []
42
+
43
+ print(" [PASS] case 1: thinking with tools (encode + parse)")
44
+
45
+
46
+ def test_case_2():
47
+ """Thinking mode without tools (drop_thinking removes earlier reasoning)."""
48
+ messages = json.load(open(os.path.join(TESTS_DIR, "test_input_2.json")))
49
+ gold = open(os.path.join(TESTS_DIR, "test_output_2.txt")).read()
50
+ prompt = encode_messages(messages, thinking_mode="thinking")
51
+ assert prompt == gold
52
+
53
+ # Parse: last assistant turn
54
+ marker = "<|Assistant|><think>"
55
+ last_start = prompt.rfind(marker) + len(marker)
56
+ parsed = parse_message_from_completion_text(prompt[last_start:], thinking_mode="thinking")
57
+ assert parsed["reasoning_content"] == "The user asks about the capital of France. It is Paris."
58
+ assert parsed["content"] == "The capital of France is Paris."
59
+ assert parsed["tool_calls"] == []
60
+
61
+ # Verify drop_thinking: first assistant's reasoning should be absent
62
+ assert "The user said hello" not in prompt
63
+
64
+ print(" [PASS] case 2: thinking without tools (encode + parse)")
65
+
66
+
67
+ def test_case_3():
68
+ """Interleaved thinking + search (developer with tools, latest_reminder)."""
69
+ messages = json.load(open(os.path.join(TESTS_DIR, "test_input_3.json")))
70
+ gold = open(os.path.join(TESTS_DIR, "test_output_3.txt")).read()
71
+ assert encode_messages(messages, thinking_mode="thinking") == gold
72
+ print(" [PASS] case 3: interleaved thinking + search")
73
+
74
+
75
+ def test_case_4():
76
+ """Quick instruction task with latest_reminder (chat mode, action task)."""
77
+ messages = json.load(open(os.path.join(TESTS_DIR, "test_input_4.json")))
78
+ gold = open(os.path.join(TESTS_DIR, "test_output_4.txt")).read()
79
+ assert encode_messages(messages, thinking_mode="chat") == gold
80
+ print(" [PASS] case 4: quick instruction task")
81
+
82
+
83
+ if __name__ == "__main__":
84
+ print("Running DeepSeek-V4 Encoding Tests...\n")
85
+ test_case_1()
86
+ test_case_2()
87
+ test_case_3()
88
+ test_case_4()
89
+ print("\nAll 4 tests passed!")
encoding/tests/test_input_1.json ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "tools": [
3
+ {
4
+ "type": "function",
5
+ "function": {
6
+ "name": "get_weather",
7
+ "description": "Get the weather for a specific location",
8
+ "parameters": {
9
+ "type": "object",
10
+ "properties": {
11
+ "location": {
12
+ "type": "string",
13
+ "description": "The city name"
14
+ },
15
+ "unit": {
16
+ "type": "string",
17
+ "enum": ["celsius", "fahrenheit"],
18
+ "description": "Temperature unit"
19
+ }
20
+ },
21
+ "required": ["location"]
22
+ }
23
+ }
24
+ },
25
+ {
26
+ "type": "function",
27
+ "function": {
28
+ "name": "search",
29
+ "description": "Search the web for information",
30
+ "parameters": {
31
+ "type": "object",
32
+ "properties": {
33
+ "query": {
34
+ "type": "string",
35
+ "description": "Search query"
36
+ },
37
+ "num_results": {
38
+ "type": "integer",
39
+ "description": "Number of results to return"
40
+ }
41
+ },
42
+ "required": ["query"]
43
+ }
44
+ }
45
+ }
46
+ ],
47
+ "messages": [
48
+ {
49
+ "role": "system",
50
+ "content": "You are a helpful assistant."
51
+ },
52
+ {
53
+ "role": "user",
54
+ "content": "What's the weather in Beijing?"
55
+ },
56
+ {
57
+ "role": "assistant",
58
+ "reasoning_content": "The user wants to know the weather in Beijing. I should use the get_weather tool.",
59
+ "tool_calls": [
60
+ {
61
+ "id": "call_001",
62
+ "type": "function",
63
+ "function": {
64
+ "name": "get_weather",
65
+ "arguments": "{\"location\": \"Beijing\", \"unit\": \"celsius\"}"
66
+ }
67
+ }
68
+ ]
69
+ },
70
+ {
71
+ "role": "tool",
72
+ "tool_call_id": "call_001",
73
+ "content": "{\"temperature\": 22, \"condition\": \"sunny\", \"humidity\": 45}"
74
+ },
75
+ {
76
+ "role": "assistant",
77
+ "reasoning_content": "Got the weather data. Let me format a nice response.",
78
+ "content": "The weather in Beijing is currently sunny with a temperature of 22°C and 45% humidity."
79
+ }
80
+ ]
81
+ }
encoding/tests/test_input_2.json ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "role": "system",
4
+ "content": "You are a helpful assistant."
5
+ },
6
+ {
7
+ "role": "user",
8
+ "content": "Hello"
9
+ },
10
+ {
11
+ "role": "assistant",
12
+ "reasoning_content": "The user said hello, I should greet back.",
13
+ "content": "Hi there! How can I help you?"
14
+ },
15
+ {
16
+ "role": "user",
17
+ "content": "What is the capital of France?"
18
+ },
19
+ {
20
+ "role": "assistant",
21
+ "reasoning_content": "The user asks about the capital of France. It is Paris.",
22
+ "content": "The capital of France is Paris."
23
+ }
24
+ ]
encoding/tests/test_input_3.json ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "role": "system",
4
+ "content": "该助手为DeepSeek,由深度求索公司创造。"
5
+ },
6
+ {
7
+ "role": "latest_reminder",
8
+ "content": "2026-02-21,星期六,广州,App,中文"
9
+ },
10
+ {
11
+ "role": "developer",
12
+ "content": "小柴胡冲剂和布洛芬能一起吃吗?\n\nCITATION FORMAT: 【{cursor_id}†L{start_line_id}(-L{end_line_id})?】",
13
+ "tools": [
14
+ {
15
+ "type": "function",
16
+ "function": {
17
+ "name": "search",
18
+ "description": "Web search. Split multiple queries with '||'.",
19
+ "parameters": {
20
+ "type": "object",
21
+ "properties": {
22
+ "queries": {
23
+ "type": "string",
24
+ "description": "query1||query2"
25
+ }
26
+ },
27
+ "required": [
28
+ "queries"
29
+ ],
30
+ "additionalProperties": false,
31
+ "$schema": "http://json-schema.org/draft-07/schema#"
32
+ }
33
+ }
34
+ },
35
+ {
36
+ "type": "function",
37
+ "function": {
38
+ "name": "open",
39
+ "description": "Batch open IDs (format 【{id}†...】) or URLs.",
40
+ "parameters": {
41
+ "type": "object",
42
+ "properties": {
43
+ "open_list": {
44
+ "type": "array",
45
+ "items": {
46
+ "type": "object",
47
+ "properties": {
48
+ "id": {
49
+ "description": "ID or URL",
50
+ "anyOf": [
51
+ {
52
+ "type": "integer"
53
+ },
54
+ {
55
+ "type": "string"
56
+ }
57
+ ],
58
+ "default": -1
59
+ },
60
+ "cursor": {
61
+ "type": "integer",
62
+ "description": "",
63
+ "default": -1
64
+ },
65
+ "loc": {
66
+ "type": "integer",
67
+ "description": "Start line",
68
+ "default": -1
69
+ },
70
+ "num_lines": {
71
+ "type": "integer",
72
+ "description": "",
73
+ "default": -1
74
+ },
75
+ "view_source": {
76
+ "type": "boolean",
77
+ "description": "",
78
+ "default": false
79
+ }
80
+ },
81
+ "additionalProperties": false
82
+ },
83
+ "description": ""
84
+ }
85
+ },
86
+ "required": [
87
+ "open_list"
88
+ ],
89
+ "additionalProperties": false,
90
+ "$schema": "http://json-schema.org/draft-07/schema#"
91
+ }
92
+ }
93
+ },
94
+ {
95
+ "type": "function",
96
+ "function": {
97
+ "name": "find",
98
+ "description": "Find exact text pattern in pages.",
99
+ "parameters": {
100
+ "type": "object",
101
+ "properties": {
102
+ "find_list": {
103
+ "type": "array",
104
+ "items": {
105
+ "type": "object",
106
+ "properties": {
107
+ "pattern": {
108
+ "type": "string",
109
+ "description": ""
110
+ },
111
+ "cursor": {
112
+ "type": "integer",
113
+ "description": "",
114
+ "default": -1
115
+ }
116
+ },
117
+ "required": [
118
+ "pattern"
119
+ ],
120
+ "additionalProperties": false
121
+ },
122
+ "description": ""
123
+ }
124
+ },
125
+ "required": [
126
+ "find_list"
127
+ ],
128
+ "additionalProperties": false,
129
+ "$schema": "http://json-schema.org/draft-07/schema#"
130
+ }
131
+ }
132
+ }
133
+ ]
134
+ },
135
+ {
136
+ "role": "assistant",
137
+ "content": "",
138
+ "reasoning_content": "用户想知道小柴胡冲剂和布洛芬能否一起服用。",
139
+ "tool_calls": [
140
+ {
141
+ "type": "function",
142
+ "function": {
143
+ "name": "search",
144
+ "arguments": "{\"queries\": \"小柴胡冲剂 布洛芬 相互作用 一起吃\"}"
145
+ }
146
+ }
147
+ ]
148
+ },
149
+ {
150
+ "role": "tool",
151
+ "content": "[0]"
152
+ },
153
+ {
154
+ "role": "assistant",
155
+ "content": "请及时就医。",
156
+ "reasoning_content": "现在开始组织回答。",
157
+ "tool_calls": []
158
+ }
159
+ ]
encoding/tests/test_input_4.json ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "role": "system",
4
+ "content": "该助手为DeepSeek-V3,由深度求索公司创造。\n今天是2025年10月17日,星期五。"
5
+ },
6
+ {
7
+ "role": "latest_reminder",
8
+ "content": "2024-11-15,上海市,App,中文"
9
+ },
10
+ {
11
+ "role": "user",
12
+ "content": "热海大滚锅是世界著名温泉吗"
13
+ },
14
+ {
15
+ "role": "assistant",
16
+ "content": "关于热海大滚锅是否是世界著名温泉,可以这样总结:它在**中国乃至全球的地热奇观中占有重要地位**,但“世界著名”的称号更侧重于它作为**独特的地质现象和旅游景点**,而非普遍的温泉疗养体验。\n\n为了让你快速了解,我整理了一个简要的表格:\n\n| 维度 | 热海大滚锅的地位与特点 |\n| :--- | :--- |\n| **地质奇观** | **世界罕见**的高温地热系统,泉眼多、水温高、形态多样。 |\n| **国内声誉** | **中国三大地热区之一**,**国家5A级旅游景区**,自古闻名(徐霞客曾游历并记载)。 |\n| **国际知名度** | 在一些旅游平台被提及为“世界六大温泉”之一,但此说法流传不广,其国际声誉更多建立在地质独特性上。 |\n| **核心体验** | **观赏地热奇观**(如97℃沸腾的“大滚锅”)、**体验温泉煮鸡蛋**。 |\n\n### 💡 游玩攻略与温馨提示\n\n如果你计划前往热海大滚锅,这里有一些实用信息供你参考:\n\n- **门票与开放时间**:\n - **门票**:景区门票约为**50元/人**。如果选择包含温泉沐浴的套餐,价格会更高,例如约**288元**。\n - **开放时间**:景区一般**08:00-18:00**开放,但具体时间可能变动,建议提前核实。\n\n- **特色体验**:\n - **温泉煮鸡蛋**:这几乎是必试项目。可以在景区门口购买用草绳串起的生鸡蛋(约5-8元/串),然后到“大滚锅”旁的指定区域蒸煮,几分钟便可熟食,趣味十足。\n - **金汤足浴**:可以直接用从“大滚锅”流出的温泉水泡脚,缓解旅途疲劳。\n\n- **注意事项**:\n - **安全第一**:“大滚锅”水温极高,务必遵守游览规则,在指定区域内观赏,切勿随意触碰泉水。\n - **规划行程**:建议为热海景区预留**3-4小时**的游览时间。景区内步道不走回头路,出入口有观光车接送。\n\n希望这些信息能帮助你更好地了解热海大滚锅。如果你对腾冲的其他景点或者行程规划有更多疑问,我很乐意提供进一步的信息。",
17
+ "mask": 1
18
+ },
19
+ {
20
+ "role": "user",
21
+ "content": "世界著名温泉有哪些",
22
+ "task": "action"
23
+ },
24
+ {
25
+ "role": "assistant",
26
+ "content": "Search"
27
+ }
28
+ ]
encoding/tests/test_output_1.txt ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <|begin▁of▁sentence|>You are a helpful assistant.
2
+
3
+ ## Tools
4
+
5
+ You have access to a set of tools to help answer the user's question. You can invoke tools by writing a "<|DSML|tool_calls>" block like the following:
6
+
7
+ <|DSML|tool_calls>
8
+ <|DSML|invoke name="$TOOL_NAME">
9
+ <|DSML|parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</|DSML|parameter>
10
+ ...
11
+ </|DSML|invoke>
12
+ <|DSML|invoke name="$TOOL_NAME2">
13
+ ...
14
+ </|DSML|invoke>
15
+ </|DSML|tool_calls>
16
+
17
+ String parameters should be specified as is and set `string="true"`. For all other types (numbers, booleans, arrays, objects), pass the value in JSON format and set `string="false"`.
18
+
19
+ If thinking_mode is enabled (triggered by <think>), you MUST output your complete reasoning inside <think>...</think> BEFORE any tool calls or final response.
20
+
21
+ Otherwise, output directly after </think> with tool calls or final response.
22
+
23
+ ### Available Tool Schemas
24
+
25
+ {"name": "get_weather", "description": "Get the weather for a specific location", "parameters": {"type": "object", "properties": {"location": {"type": "string", "description": "The city name"}, "unit": {"type": "string", "enum": ["celsius", "fahrenheit"], "description": "Temperature unit"}}, "required": ["location"]}}
26
+ {"name": "search", "description": "Search the web for information", "parameters": {"type": "object", "properties": {"query": {"type": "string", "description": "Search query"}, "num_results": {"type": "integer", "description": "Number of results to return"}}, "required": ["query"]}}
27
+
28
+ You MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls.
29
+ <|User|>What's the weather in Beijing?<|Assistant|><think>The user wants to know the weather in Beijing. I should use the get_weather tool.</think>
30
+
31
+ <|DSML|tool_calls>
32
+ <|DSML|invoke name="get_weather">
33
+ <|DSML|parameter name="location" string="true">Beijing</|DSML|parameter>
34
+ <|DSML|parameter name="unit" string="true">celsius</|DSML|parameter>
35
+ </|DSML|invoke>
36
+ </|DSML|tool_calls><|end▁of▁sentence|><|User|><tool_result>{"temperature": 22, "condition": "sunny", "humidity": 45}</tool_result><|Assistant|><think>Got the weather data. Let me format a nice response.</think>The weather in Beijing is currently sunny with a temperature of 22°C and 45% humidity.<|end▁of▁sentence|>
encoding/tests/test_output_2.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ <|begin▁of▁sentence|>You are a helpful assistant.<|User|>Hello<|Assistant|></think>Hi there! How can I help you?<|end▁of▁sentence|><|User|>What is the capital of France?<|Assistant|><think>The user asks about the capital of France. It is Paris.</think>The capital of France is Paris.<|end▁of▁sentence|>
encoding/tests/test_output_3.txt ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <|begin▁of▁sentence|>该助手为DeepSeek,由深度求索公司创造。<|latest_reminder|>2026-02-21,星期六,广州,App,中文<|User|>小柴胡冲剂和布洛芬能一起吃吗?
2
+
3
+ CITATION FORMAT: 【{cursor_id}†L{start_line_id}(-L{end_line_id})?】
4
+
5
+ ## Tools
6
+
7
+ You have access to a set of tools to help answer the user's question. You can invoke tools by writing a "<|DSML|tool_calls>" block like the following:
8
+
9
+ <|DSML|tool_calls>
10
+ <|DSML|invoke name="$TOOL_NAME">
11
+ <|DSML|parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</|DSML|parameter>
12
+ ...
13
+ </|DSML|invoke>
14
+ <|DSML|invoke name="$TOOL_NAME2">
15
+ ...
16
+ </|DSML|invoke>
17
+ </|DSML|tool_calls>
18
+
19
+ String parameters should be specified as is and set `string="true"`. For all other types (numbers, booleans, arrays, objects), pass the value in JSON format and set `string="false"`.
20
+
21
+ If thinking_mode is enabled (triggered by <think>), you MUST output your complete reasoning inside <think>...</think> BEFORE any tool calls or final response.
22
+
23
+ Otherwise, output directly after </think> with tool calls or final response.
24
+
25
+ ### Available Tool Schemas
26
+
27
+ {"name": "search", "description": "Web search. Split multiple queries with '||'.", "parameters": {"type": "object", "properties": {"queries": {"type": "string", "description": "query1||query2"}}, "required": ["queries"], "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#"}}
28
+ {"name": "open", "description": "Batch open IDs (format 【{id}†...】) or URLs.", "parameters": {"type": "object", "properties": {"open_list": {"type": "array", "items": {"type": "object", "properties": {"id": {"description": "ID or URL", "anyOf": [{"type": "integer"}, {"type": "string"}], "default": -1}, "cursor": {"type": "integer", "description": "", "default": -1}, "loc": {"type": "integer", "description": "Start line", "default": -1}, "num_lines": {"type": "integer", "description": "", "default": -1}, "view_source": {"type": "boolean", "description": "", "default": false}}, "additionalProperties": false}, "description": ""}}, "required": ["open_list"], "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#"}}
29
+ {"name": "find", "description": "Find exact text pattern in pages.", "parameters": {"type": "object", "properties": {"find_list": {"type": "array", "items": {"type": "object", "properties": {"pattern": {"type": "string", "description": ""}, "cursor": {"type": "integer", "description": "", "default": -1}}, "required": ["pattern"], "additionalProperties": false}, "description": ""}}, "required": ["find_list"], "additionalProperties": false, "$schema": "http://json-schema.org/draft-07/schema#"}}
30
+
31
+ You MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls.
32
+ <|Assistant|><think>用户想知道小柴胡冲剂和布洛芬能否一起服用。</think>
33
+
34
+ <|DSML|tool_calls>
35
+ <|DSML|invoke name="search">
36
+ <|DSML|parameter name="queries" string="true">小柴胡冲剂 布洛芬 相互作用 一起吃</|DSML|parameter>
37
+ </|DSML|invoke>
38
+ </|DSML|tool_calls><|end▁of▁sentence|><|User|><tool_result>[0]</tool_result><|Assistant|><think>现在开始组织回答。</think>请及时就医。<|end▁of▁sentence|>
encoding/tests/test_output_4.txt ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <|begin▁of▁sentence|>该助手为DeepSeek-V3,由深度求索公司创造。
2
+ 今天是2025年10月17日,星期五。<|latest_reminder|>2024-11-15,上海市,App,中文<|User|>热海大滚锅是世界著名温泉吗<|Assistant|></think>关于热海大滚锅是否是世界著名温泉,可以这样总结:它在**中国乃至全球的地热奇观中占有重要地位**,但“世界著名”的称号更侧重于它作为**独特的地质现象和旅游景点**,而非普遍的温泉疗养体验。
3
+
4
+ 为了让你快速了解,我整理了一个简要的表格:
5
+
6
+ | 维度 | 热海大滚锅的地位与特点 |
7
+ | :--- | :--- |
8
+ | **地质奇观** | **世界罕见**的高温地热系统,泉眼多、水温高、形态多样。 |
9
+ | **国内声誉** | **中国三大地热区之一**,**国家5A级旅游景区**,自古闻名(徐霞客曾游历并记载)。 |
10
+ | **国际知名度** | 在一些旅游平台被提及为“世界六大温泉”之一,但此说法流传不广,其国际声誉更多建立在地质独特性上。 |
11
+ | **核心体验** | **观赏地热奇观**(如97℃沸腾的“大滚锅”)、**体验温泉煮鸡蛋**。 |
12
+
13
+ ### 💡 游玩攻略与温馨提示
14
+
15
+ 如果你计划前往热海大滚锅,这里有一些实用信息供你参考:
16
+
17
+ - **门票与开放时间**:
18
+ - **门票**:景区门票约为**50元/人**。如果选择包含温泉沐浴的套餐,价格会更高,例如约**288元**。
19
+ - **开放时间**:景区一般**08:00-18:00**开放,但具体时间可能变动,建议提前核实。
20
+
21
+ - **特色体验**:
22
+ - **温泉煮鸡蛋**:这几乎是必试项目。可以在景区门口购买用草绳串起的生鸡蛋(约5-8元/串),然后到“大滚锅”旁的指定区域蒸煮,几分钟便可熟食,趣味十足。
23
+ - **金汤足浴**:可以直接用从“大滚锅”流出的温泉水泡脚,缓解旅途疲劳。
24
+
25
+ - **注意事项**:
26
+ - **安全第一**:“大滚锅”水温极高,务必遵守游览规则,在指定区域内观赏,切勿随意触碰泉水。
27
+ - **规划行程**:建议为热海景区预留**3-4小时**的游览时间。景区内步道不走回头路,出入口有观光车接送。
28
+
29
+ 希望这些信息能帮助你更好地了解热海大滚锅。如果你对腾冲的其他景点或者行程规划有更多疑问,我很乐意提供进一步的信息。<|end▁of▁sentence|><|User|>世界著名温泉有哪些<|Assistant|></think><|action|>Search<|end▁of▁sentence|>
generation_config.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_from_model_config": true,
3
+ "bos_token_id": 0,
4
+ "eos_token_id": 1,
5
+ "do_sample": true,
6
+ "temperature": 1.0,
7
+ "top_p": 1.0,
8
+ "transformers_version": "4.46.3"
9
+ }
model-00001-of-00033.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5b666121207ac6ec09e2e1c8e51dbc0b95c14848110b61992ef22211a1fca0e3
3
+ size 5754875518
model-00004-of-00033.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:77707e9b637a064371216eca651bbed3c2af55e529b7fff42224d8edc014ff6c
3
+ size 5218248749
model-00005-of-00033.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2bc224b6915cbd8eae08f5b617d4f1ce1ce3d791469bdd330c5555dafe09923d
3
+ size 5034459549
model-00011-of-00033.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:223de759a15abf1a90eae79508ddb7248111764553f0ba54c0dee6806855121a
3
+ size 5034459628
model-00012-of-00033.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:aaad41645f5796ece978cfbb99ef29c51ccd0b05b794b93afbbdcf2d14143872
3
+ size 5015628875
model-00013-of-00033.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c923719ddf1e525ca79a162863ea0730ddb649dacb3c233e338bf7dd7ebd8354
3
+ size 5218248781
model-00014-of-00033.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fafcfb4a24e33e47cbf1b18da8887c31c86aeabffb5cfeb8fcbc154b84f81933
3
+ size 5034459620
model-00016-of-00033.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ff0b109dd632a834df972e54e7e062381bdd11311e4ed8de66bb5530a5d4fd76
3
+ size 5218248787
model-00017-of-00033.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f9f5767e72a14f638d058a3fd54259b7a8e5bd20b8bf4648e73b22b4e7f37848
3
+ size 5034459622
model-00018-of-00033.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a13e84e80fa5061d4f29dc623b4987f6b128ba9641531647738db966c418a795
3
+ size 5015628873
model-00019-of-00033.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1fa79c548c4bfb3f967dc480805d81f7d623416ce6a01408c8c07ab5712ae9bd
3
+ size 5218248843
model-00020-of-00033.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c9e1eecb7cdeddb858d7c3dae924c34e9aed2179ad81197b84eb97eb20c49381
3
+ size 5034459620
model-00021-of-00033.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2095b2a1bef0e06fe83b85dd54769f7c7bf508fc655ff8fa1285c57fd8a38ad7
3
+ size 5015628885
model-00024-of-00033.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:75195a4c89b7d7a11a1a5d236d47b5f039d13c53a36a891badb80f6412197d33
3
+ size 5015628883
model-00025-of-00033.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a276e3fadfba9697307a481aafab5c416d59952d788a11e7eb0906d51b8787bf
3
+ size 5218248799
model-00026-of-00033.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a8d6acd4f275078462e691ddd01c49ff3631e3b37ec0ab25c1def28b54d5d000
3
+ size 5034459616
model-00032-of-00033.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b0888a88bea8985f40397f710eb21a529b266b3aa4f68ad1ae4cad050191b45e
3
+ size 5034459626
model-00033-of-00033.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:34dd8fd29e43b61453f1c4258df2c02fe149cb3086c6fcf1089e6efaf809ed86
3
+ size 1770857432
model.safetensors.index.json ADDED
The diff for this file is too large to render. See raw diff
 
tokenizer.json ADDED
The diff for this file is too large to render. See raw diff
 
tokenizer_config.json ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "add_bos_token": false,
3
+ "add_eos_token": false,
4
+ "bos_token": {
5
+ "__type": "AddedToken",
6
+ "content": "<|begin▁of▁sentence|>",
7
+ "lstrip": false,
8
+ "normalized": true,
9
+ "rstrip": false,
10
+ "single_word": false
11
+ },
12
+ "clean_up_tokenization_spaces": false,
13
+ "eos_token": {
14
+ "__type": "AddedToken",
15
+ "content": "<|end▁of▁sentence|>",
16
+ "lstrip": false,
17
+ "normalized": true,
18
+ "rstrip": false,
19
+ "single_word": false
20
+ },
21
+ "legacy": true,
22
+ "model_max_length": 1048576,
23
+ "pad_token": {
24
+ "__type": "AddedToken",
25
+ "content": "<|end▁of▁sentence|>",
26
+ "lstrip": false,
27
+ "normalized": true,
28
+ "rstrip": false,
29
+ "single_word": false
30
+ },
31
+ "sp_model_kwargs": {},
32
+ "unk_token": null,
33
+ "tokenizer_class": "PreTrainedTokenizerFast"
34
+ }