mlx-bun 0.0.11 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +43 -13
  2. package/THIRD_PARTY_LICENSES.md +9 -0
  3. package/docs/design/colibri-glm52-port.md +1479 -0
  4. package/docs/design/pre-colibri-stabilization.md +329 -0
  5. package/docs/investigations/colibri-oracle-pin.md +1284 -0
  6. package/docs/investigations/indexshare-performance-spike.md +357 -0
  7. package/docs/planning/release-notes-v0.0.12.md +80 -0
  8. package/docs/reference/cli.md +28 -2
  9. package/docs/reference/distribution.md +25 -1
  10. package/docs/reference/embedding.md +5 -2
  11. package/docs/reference/features-matrix.md +4 -2
  12. package/docs/reference/library-api.md +41 -13
  13. package/docs/reference/models.md +49 -2
  14. package/docs/reference/server-api.md +90 -9
  15. package/docs/reference/server-config.md +21 -14
  16. package/docs/reference/training.md +10 -1
  17. package/package.json +1 -1
  18. package/src/anthropic.ts +54 -44
  19. package/src/chat-template.ts +155 -10
  20. package/src/cli.ts +127 -8
  21. package/src/config.ts +71 -26
  22. package/src/diffusion/diffusion-generate.ts +14 -8
  23. package/src/download.ts +103 -12
  24. package/src/eval/ifeval.ts +63 -120
  25. package/src/eval/runner.ts +14 -5
  26. package/src/eval/tasks/ifeval.ts +167 -31
  27. package/src/expert-io.ts +391 -0
  28. package/src/expert-residency.ts +1134 -0
  29. package/src/expert-usage.ts +415 -0
  30. package/src/generate.ts +114 -17
  31. package/src/hf-push.ts +1 -1
  32. package/src/index.ts +14 -2
  33. package/src/jobs/runner.ts +37 -18
  34. package/src/kv-store.ts +147 -8
  35. package/src/lora.ts +77 -14
  36. package/src/media-fetch.ts +198 -56
  37. package/src/mlx/array.ts +43 -0
  38. package/src/mlx/autograd.ts +51 -14
  39. package/src/mlx/checkpoint.ts +46 -13
  40. package/src/mlx/custom-vjp.ts +83 -35
  41. package/src/model/factory.ts +96 -5
  42. package/src/model/gemma4-base.ts +32 -0
  43. package/src/model/glm52-atlas.ts +511 -0
  44. package/src/model/glm52-cache.ts +706 -0
  45. package/src/model/glm52-config.ts +262 -0
  46. package/src/model/glm52-container.ts +305 -0
  47. package/src/model/glm52-coupling.ts +388 -0
  48. package/src/model/glm52-dsa.ts +602 -0
  49. package/src/model/glm52-expert-layout.ts +253 -0
  50. package/src/model/glm52-layout.ts +152 -0
  51. package/src/model/glm52-memory.ts +473 -0
  52. package/src/model/glm52-mla.ts +786 -0
  53. package/src/model/glm52-moe.ts +223 -0
  54. package/src/model/glm52-pilot.ts +537 -0
  55. package/src/model/glm52-reference.ts +445 -0
  56. package/src/model/glm52-residency.ts +557 -0
  57. package/src/model/glm52-resident-weights.ts +135 -0
  58. package/src/model/glm52-streamed-experts.ts +453 -0
  59. package/src/model/glm52-streamed-metal.ts +610 -0
  60. package/src/model/glm52-weights.ts +503 -0
  61. package/src/model/glm52.ts +1153 -0
  62. package/src/model/support.ts +8 -1
  63. package/src/native/expert_io.c +523 -0
  64. package/src/native-pack.ts +6 -4
  65. package/src/responses.ts +62 -49
  66. package/src/serve/batch-scheduler.ts +95 -16
  67. package/src/serve/generation-gateway.ts +41 -16
  68. package/src/server.ts +362 -81
  69. package/src/spec/glm52-mtp-source.ts +392 -0
  70. package/src/spec/serve-loop.ts +99 -18
  71. package/src/spec/source.ts +31 -6
  72. package/src/ssd-cache.ts +6 -2
  73. package/src/tool-call.ts +30 -2
  74. package/src/train/job.ts +0 -10
  75. package/src/train/lora-params.ts +17 -6
  76. package/src/train/trainer.ts +59 -26
  77. package/src/web/app.html +13 -2
  78. package/src/web/app.js +30 -3
  79. package/src/web/src/status.ts +51 -3
  80. package/src/web-tools.ts +54 -2
  81. package/src/weights.ts +82 -19
@@ -16,9 +16,7 @@ surface); direct library callers must do the same.
16
16
  ```ts
17
17
  import {
18
18
  ensureNativeRuntime,
19
- loadModelConfig,
20
- Weights,
21
- createModel, // dispatches to Gemma4Model / MiniCPM5Model / Qwen35Model
19
+ openModel, // artifact-aware dispatch, including bounded GLM-5.2
22
20
  loadTokenizer,
23
21
  ChatTemplate,
24
22
  generate,
@@ -26,13 +24,15 @@ import {
26
24
 
27
25
  await ensureNativeRuntime();
28
26
  const dir = "/path/to/hf-snapshot"; // mlx-bun ls prints these
29
- const config = await loadModelConfig(dir);
30
- const model = createModel(await Weights.open(dir), config); // returns RuntimeModel
27
+ const model = await openModel(dir, {
28
+ // Used only by the direct Colibri GLM-5.2 artifact; other models ignore it.
29
+ contextTokens: 4096, maxGenerationTokens: 256, enableMtp: false,
30
+ });
31
31
  const tok = await loadTokenizer(dir);
32
32
  const template = await ChatTemplate.load(dir);
33
33
  // Gemma4Model and MiniCPM5Model can still be imported directly from "mlx-bun"
34
34
  // if you need the concrete type. Qwen35Model is NOT exported from the public
35
- // package — use createModel/RuntimeModel (preferred dispatch), or import from
35
+ // package — use openModel/RuntimeModel (preferred artifact-aware dispatch), or import from
36
36
  // "./src/model/qwen3_5" in-repo.
37
37
 
38
38
  const ids = tok.encode(template.render([{ role: "user", content: "hi" }]));
@@ -45,13 +45,15 @@ console.log(tok.decode(out, true));
45
45
  console.log(gen.stats); // set once iteration ends
46
46
  ```
47
47
 
48
- Weight loading is lazy (mmap + mlx native loader): construction is
49
- milliseconds; weights materialize on first forward.
48
+ Ordinary safetensors loading is lazy (mmap + mlx native loader). GLM-5.2 first
49
+ runs its header-only exact process equation, then opens the bounded Colibri
50
+ resident/expert tiers; impossible plans fail before either tier is committed.
50
51
 
51
52
  > **First run:** library consumers bypass the CLI's first-run step, so
52
53
  > call `await ensureNativeRuntime()` once before constructing a model on
53
54
  > a machine that may not have the MLX native runtime yet — it downloads
54
- > the sha256-verified native pack to `~/Library/Caches/mlx-bun/` and is
55
+ > the sha256-verified native pack (MLX plus the direct-Colibri expert-I/O
56
+ > helper) to `~/Library/Caches/mlx-bun/` and is
55
57
  > a no-op when the runtime is already present (beside the executable,
56
58
  > already cached, or installed via homebrew). `nativeRuntimeDir()`
57
59
  > returns the resolved directory, or `null` on a fresh machine.
@@ -109,6 +111,24 @@ budget (pre-GPU, mmap-only check). The server exposes OpenAI chat
109
111
  completions, Anthropic `/v1/messages`, OpenAI Responses, embeddings,
110
112
  adapters, and `/stats` — [server-api.md](./server-api.md).
111
113
 
114
+ For GLM-5.2, pass its load-only resource controls under `glm`:
115
+
116
+ ```ts
117
+ const ctx = await loadContext(dir, "glm-5.2", {
118
+ glm: {
119
+ contextTokens: 4096,
120
+ maxGenerationTokens: 128,
121
+ batchSize: 8,
122
+ enableMtp: true, // default; native MTP routes requests serial+spec
123
+ },
124
+ });
125
+ ```
126
+
127
+ `openModel()` and `loadContext()` both select the streamed Colibri runtime;
128
+ callers do not use `Weights.open()` or `createModel()` for this artifact.
129
+ Direct `generate()` supports the full sampling/grammar/logprobs contract but
130
+ does not itself mount a drafter; native MTP is a serving-context feature.
131
+
112
132
  ## Text embeddings
113
133
 
114
134
  ```ts
@@ -154,16 +174,24 @@ under one adapter must not seed another's prefill).
154
174
  ```ts
155
175
  import { saveKvCache, loadKvCache } from "./src/kv-store";
156
176
  saveKvCache("/tmp/prefix.kv", tokens, caches); // page-aligned file
157
- const { tokens, caches, mmap } = loadKvCache("/tmp/prefix.kv", model);
158
- // reload is a zero-copy MAP_PRIVATE mmap straight to the GPU (~1 ms);
159
- // keep `mmap` referenced as long as the caches live
177
+ const { tokens, caches } = loadKvCache("/tmp/prefix.kv", model, {
178
+ modelId, configFingerprint, tokenizerHash, verify: true,
179
+ });
180
+ // Restore streams one tensor at a time from a read-only mmap into MLX-owned
181
+ // storage, drops each clean file-page range, and unmaps before returning.
160
182
  // Note: loadKvCache accepts anything with makeCache(): Cache[] — it validates
161
183
  // the entry count against the cache list makeCache() returns (not
162
184
  // model.layers.length, which is wrong for KV-shared models like e4b, whose
163
185
  // makeCache() returns donors only). RuntimeModel qualifies directly, no cast.
164
186
  ```
165
187
 
166
- Quantized caches are not persistable yet (documented gap).
188
+ Format v3 supports plain, rotating, quantized, TurboQuant, SSM, and GLM-5.2
189
+ checkpoint-native caches. GLM rows use `mla`, `mla-dsa`, and `mtp-mla` kinds and
190
+ store only compressed latent/RoPE/DSA state—never reconstructed full K/V.
191
+ `Glm52NativeMtpSource.clonePersistentCache()` returns an owned MTP snapshot;
192
+ `restorePersistentCache()` adopts a validated restored `mtp-mla` row. Writes
193
+ are page-aligned, hashed, atomic (`.tmp` + fsync + rename), and available in
194
+ both synchronous and event-loop-yielding `saveKvCacheAsync` forms.
167
195
 
168
196
  ## Registry + fit (model discovery and memory math)
169
197
 
@@ -35,6 +35,11 @@ mlx-bun get <substring> # no "/" = registry query, re-gets the match
35
35
  - Plain HTTPS resolve/CDN (no Xet), sequential files, `Range`-resume of
36
36
  partial blobs (`<blob>.incomplete`). Auth: `HF_TOKEN` env, then the
37
37
  token `hf auth login` writes. 401/403 answers get a "gated repo" hint.
38
+ - Before the first payload request, mlx-bun totals the bytes still absent,
39
+ credits complete shared blobs and valid `.incomplete` prefixes, and checks
40
+ the target volume for that remainder plus a fixed 1 GiB safety reserve. An
41
+ impossible acquisition fails immediately with required/available GiB and a
42
+ rerun/resume instruction rather than filling the disk halfway through.
38
43
  - A substring argument (no `/`) resolves against already-downloaded
39
44
  repos — `mlx-bun get 12B` refreshes the 12B to upstream's latest. An
40
45
  unknown substring errors with a pointer to `mlx-bun ls <q>`.
@@ -51,14 +56,56 @@ mlx-bun get <substring> # no "/" = registry query, re-gets the match
51
56
  Every blob is checksummed **while streaming** (a resume re-hashes the
52
57
  existing prefix in chunks — no whole-file allocation):
53
58
 
54
- - **LFS files** (weights): sha256 must equal the API's `lfs.oid` — which
55
- is also the blob's filename.
59
+ - **LFS files** (weights): sha256 must equal the API's current `lfs.sha256`
60
+ field (the older `lfs.oid` spelling is also accepted) — which is also the
61
+ blob's filename.
56
62
  - **Small files** (configs, tokenizer): git blob identity,
57
63
  `sha1("blob <size>\0" + content)`, must equal the API's `blobId`.
58
64
 
59
65
  A mismatch deletes the partial (never resume corrupt bytes); a short
60
66
  read keeps the `.incomplete` for resume.
61
67
 
68
+ ## GLM-5.2 on a 32 GB Mac
69
+
70
+ The validated direct-container artifact is
71
+ `mateogrgic/GLM-5.2-colibri-int4-with-int8-mtp` at revision
72
+ `3cc8db99b1b13fc79325d987ba3c1c430766b3b8`. It is approximately 384 GB
73
+ decimal / 357 GiB on disk, so choose the Hugging Face cache volume before the
74
+ download. Fast internal NVMe is strongly preferred; an external volume also
75
+ needs to sustain the expert-streaming workload after acquisition.
76
+
77
+ ```sh
78
+ # Optional: choose a large fast volume before downloading.
79
+ export HF_HUB_CACHE=/Volumes/FastSSD/huggingface/hub
80
+
81
+ mlx-bun get mateogrgic/GLM-5.2-colibri-int4-with-int8-mtp \
82
+ --revision 3cc8db99b1b13fc79325d987ba3c1c430766b3b8
83
+ mlx-bun scan
84
+ mlx-bun serve GLM-5.2-colibri \
85
+ --memory-budget 26.8435456 \
86
+ --context-length 4096 \
87
+ --max-tokens 128 \
88
+ --mtp on
89
+ ```
90
+
91
+ `26.8435456` decimal GB is exactly 25 GiB. The validated plan reserves about
92
+ 19.9 GiB for the process and leaves the rest of the 32 GiB machine to macOS.
93
+ Startup runs the exact header-only resource equation before opening resident
94
+ weights or expert slabs. The server reports the resulting plan at
95
+ `GET /stats` under `glm52`.
96
+
97
+ If acquisition is interrupted, rerun the identical `get` command: mlx-bun
98
+ rehashes each existing prefix in bounded chunks, sends a Range request for the
99
+ remainder, and verifies the complete blob before publishing it. If the disk
100
+ preflight refuses, free space or point `HF_HUB_CACHE` at another volume and
101
+ rerun. No Python environment and no converted copy inside the mlx-bun checkout
102
+ are involved.
103
+
104
+ The artifact is an external MIT-licensed derivative of
105
+ `zai-org/GLM-5.2-FP8`, converted with Colibri and extended with int8 MTP
106
+ weights. Model weights are not bundled with mlx-bun; review the artifact model
107
+ card before redistribution.
108
+
62
109
  ## scan + the registry
63
110
 
64
111
  `mlx-bun scan` walks `models--*/snapshots/*` reading only `config.json`
@@ -50,7 +50,7 @@ Request body (OpenAI chat schema; unknown fields ignored):
50
50
  "tools": [ /* OpenAI function tools */ ],
51
51
  "tool_choice": "auto", // "none" disables tools
52
52
  "chat_template_kwargs": { // forwarded to the chat template
53
- "enable_thinking": false // MiniCPM5 / Qwen3.5: <think> channel on/off
53
+ "enable_thinking": false // MiniCPM5 / Qwen3.5 / GLM-5.2: think channel
54
54
  },
55
55
  "reasoning_effort": "medium", // "none"|"minimal"|"low"|"medium"|"high"
56
56
  // gates enable_thinking on Qwen3.5/MiniCPM5:
@@ -78,6 +78,11 @@ Sampling defaults follow the model author's `generation_config.json`
78
78
  when a field is omitted (optiq serve's gen_config behavior); explicit
79
79
  request values always win. MiniCPM5 defaults to the no-think direct
80
80
  answer mode unless `chat_template_kwargs.enable_thinking` is `true`.
81
+ GLM-5.2 also defaults to no-think: its generation primer is
82
+ `<think></think>`. Enabling thinking renders `Reasoning Effort: Max` in the
83
+ system turn and leaves an open `<think>` primer for generation. The same
84
+ precedence applies through Chat Completions, Anthropic Messages, and Responses
85
+ because all three normalize into this one chat request path.
81
86
 
82
87
  `stop` sequences are matched on **decoded text**, not token ids, so a
83
88
  sequence that spans token boundaries still fires. Generation halts at
@@ -303,13 +308,16 @@ with `finish_reason: "stop"`. Works on both lanes — serial and
303
308
  `--batch N` (per-row matchers).
304
309
 
305
310
  Degrade path — a grammar that fails to compile is never rejected (no
306
- 400/500): chat prepends a system message instructing valid JSON output
307
- (schema included when one was given) and the response carries a
308
- `Warning` header (`grammar not enforced: …`); `/v1/completions` has no
309
- chat template to inject into, so it emits the `Warning` header only.
310
- `MLX_BUN_GRAMMAR=0` disables the feature entirely (the fields are
311
- ignored: no enforcement, no injection, no Warning). Design and fidelity
312
- notes: [structured-output.md](../design/structured-output.md).
311
+ 400/500): chat prepends a string-valued system instruction for the
312
+ requested JSON schema, raw grammar, regex, or choice constraint, and the
313
+ response carries a `Warning` header (`grammar not enforced: …`);
314
+ `/v1/completions` has no chat template to inject into, so it emits the
315
+ `Warning` header only.
316
+ `MLX_BUN_GRAMMAR=0` disables mask compilation but does not silently
317
+ ignore a requested constraint: it uses this same degrade path (chat
318
+ prompt injection plus `Warning`; raw completions `Warning` only).
319
+ Design and fidelity notes:
320
+ [structured-output.md](../design/structured-output.md).
313
321
 
314
322
  ### Errors
315
323
 
@@ -486,6 +494,23 @@ The served model is FIRST, with extra capability fields:
486
494
  // lazily loadable)
487
495
  "audio": false, // input_audio content parts accepted
488
496
  // (tower loaded or lazily loadable)
497
+ "batch_mode": "batch", // loaded cache capability; per-request
498
+ // usage.lane is authoritative
499
+ "tools": true,
500
+ "structured_output": true,
501
+ "embeddings": false,
502
+ "adapters": false,
503
+ "training": false,
504
+ "dsa": true, // GLM-5.2 only; false elsewhere
505
+ "mtp": true, // checkpoint-native GLM MTP is mounted
506
+ "capabilities": { // explicit discoverable surface
507
+ "chat_completions": true, "text_completions": true,
508
+ "anthropic_messages": true, "responses": true,
509
+ "streaming": true, "tools": true, "structured_output": true,
510
+ "logprobs": true, "embeddings": false,
511
+ "vision": false, "audio": false,
512
+ "adapters": false, "training": false
513
+ },
489
514
  "gen_defaults": { // model-author sampling defaults
490
515
  "temperature": 0.7, // (generation_config.json, with any
491
516
  "top_p": 0.95, // --temperature/--top-p/--top-k server
@@ -518,6 +543,13 @@ matching `mlx_lm.server`.
518
543
  `input_audio` content parts (tower loaded or lazily loadable — see
519
544
  *Audio input* above for what audio requires), so clients can discover
520
545
  capabilities instead of probing for a `400`.
546
+ The served-model row also includes `batch_mode: "off" | "serial" | "batch"`:
547
+ the configured cap and the loaded model's cache capability together. Per-turn
548
+ `usage.lane` remains authoritative for a particular request; for example,
549
+ native-MTP requests route serial even when the served GLM model reports
550
+ `batch_mode: "batch"` for ordinary requests.
551
+ For GLM-5.2, the non-generative fields are deliberately false: embeddings,
552
+ vision/audio, adapters, and training are not emulated by the serving port.
521
553
 
522
554
  ## GET /health
523
555
 
@@ -544,9 +576,40 @@ capabilities instead of probing for a `400`.
544
576
  "usable_bytes": 0,
545
577
  "weights_bytes": 0
546
578
  },
579
+ // present only for the direct Colibri GLM-5.2 runtime:
580
+ "glm52": {
581
+ "preset": "g5-32gb-quality",
582
+ "planned_process_bytes": 0,
583
+ "process_limit_bytes": 0,
584
+ "context_tokens": 4096,
585
+ "max_generation_tokens": 128,
586
+ "batch_size": 1,
587
+ "dsa": true,
588
+ "mtp": true,
589
+ "mtp_draft_tokens": 3,
590
+ "resident_weight_bytes": 0,
591
+ "main_expert_slab_bytes": 0,
592
+ "mtp_expert_slab_bytes": 0,
593
+ "expert_runtime": {
594
+ "main_residency": { "resident": 0, "pinned": 0, "working": 0,
595
+ "hits": 0, "misses": 0, "evictions": 0 },
596
+ "mtp_residency": { "resident": 0, "pinned": 0, "working": 0,
597
+ "hits": 0, "misses": 0, "evictions": 0 },
598
+ "last_turn": {
599
+ "main": { "demand": { "hits": 0, "misses": 0,
600
+ "readBytes": 0 } },
601
+ "mtp": { "demand": { "hits": 0, "misses": 0,
602
+ "readBytes": 0 } },
603
+ "pilot": null
604
+ },
605
+ "last_repin": []
606
+ }
607
+ },
547
608
  "batch": {
548
609
  "configured": 1, // the --batch N value
549
- "batched": false, // batching enabled (N>1)
610
+ "mode": "off", // "off" | "serial" | "batch": configured
611
+ // and model-cache capability together
612
+ "batched": false, // true only when mode == "batch"
550
613
  "active_rows": 0, // rows currently decoding in the batch
551
614
  "pending_rows": 0, // queued + mid-prefill rows waiting
552
615
  "submitted_rows": 0, // cumulative rows routed to the batch lane since start
@@ -955,6 +1018,15 @@ banner and `mlx-bun fit`. When the eval DB has a real measured decode
955
1018
  rate for this model snapshot, it is included and takes precedence over
956
1019
  the prediction.
957
1020
 
1021
+ For the direct Colibri GLM-5.2 runtime this endpoint switches to the exact
1022
+ serve-time memory plan. The response separates the full streamed artifact on
1023
+ disk from resident weights, main/MTP expert slabs, KV, and other reserves.
1024
+ `measured_decode_tps` is the G5 warm quality-preserving measurement;
1025
+ `glm52.direct_oracle_warm_decode_tps` is the same-machine direct Colibri
1026
+ control, while `glm52.aspirational_decode_tps` is a target, not a release
1027
+ gate. The single-row SKU result describes the current machine because the
1028
+ generic all-resident SKU estimator is inapplicable.
1029
+
958
1030
  ```jsonc
959
1031
  {
960
1032
  "machine": { "chip": "M4 Pro", "ram_bytes": 0, "bandwidth_gbs": 0.0 },
@@ -973,6 +1045,15 @@ the prediction.
973
1045
  "max_safe_context": 8192,
974
1046
  "predicted_decode_tps": 0.0
975
1047
  },
1048
+ // GLM-5.2 only:
1049
+ "glm52": {
1050
+ "artifact_disk_bytes": 0, // exact registry size; null if unavailable
1051
+ "main_expert_slab_bytes": 0,
1052
+ "mtp_expert_slab_bytes": 0,
1053
+ "max_generation_tokens": 128,
1054
+ "direct_oracle_warm_decode_tps": 0.27,
1055
+ "aspirational_decode_tps": 2.0
1056
+ },
976
1057
  "sku_matrix_ctx": 32768,
977
1058
  "sku_matrix": [{
978
1059
  "sku": "M4 Pro 24 GB", "ram_gb": 24,
@@ -21,7 +21,7 @@ the design rationale is in
21
21
  | --- | --- | --- | --- |
22
22
  | `--host` | addr | `127.0.0.1` | Interface to bind. Loopback-only by default (mlx_lm.server parity); pass `--host 0.0.0.0` to expose the server on your network. |
23
23
  | `--port` | n | `8080` | Listen port. A pre-flight probe refuses to start if the port is already serving. |
24
- | `--memory-budget` | GB | machine RAM × 0.75 | Admission ceiling. Requests whose `prompt + max_tokens` exceed the budget's max safe context are **rejected with 400** (`type: memory_admission`) instead of risking an uncatchable GPU OOM. Also caps the mlx allocator (`mlx_set_memory_limit`) as defense in depth. **Decimal GB (×10⁹).** |
24
+ | `--memory-budget` | GB | machine RAM × 0.75; GLM-5.2: min(25 GiB, physical RAM) | Admission ceiling. Requests whose `prompt + max_tokens` exceed the budget's max safe context are **rejected with 400** (`type: memory_admission`) instead of risking an uncatchable GPU OOM. GLM-5.2 runs its header-only exact process equation before opening resident weights or expert slabs, and caps MLX to that plan's allocator reserve. **Decimal GB (×10⁹).** |
25
25
  | `--prompt-cache` | GB | `8` | RAM prefix-KV cache (byte-capped LRU; `0` disables). **PREFIX SHARING (2026-07-05): serves are NON-CONSUMING** — a hit hands out zero-copy clones and leaves the donor entry intact, so N agents (or a brand-new session) sharing a system prompt all reuse ONE prefill without destroying each other's conversation entries (the old consume-and-trim take cannibalized the donor). Extended entries supersede their prefix-ancestors (when trimmable), so a conversation stays one entry. Works on BOTH lanes and tiers over the SSD store (Layer 0). |
26
26
  | `--isolate` | (bool) | off | Runtime isolation ([runtime-isolation.md](../design/runtime-isolation.md)): the inference engine runs as a CHILD process on a unix socket; this process stays a pure UI/API reverse proxy — instant under any GPU load, survives engine crashes (auto-respawn, in-flight requests get a clean 502). Measured cost ≈0 (−0.4% tok/s, +2 ms TTFT, per-token SSE granularity preserved). `/ws/chat` is not proxied yet (501). `/engine` reports child pid/restarts. |
27
27
  | `--model-pool` | n | `1` | With `--isolate`: max RESIDENT model engines. Requests naming another model (**exact `/v1/models` id** — fuzzy strings keep mlx-lm's ignored-field semantics) spawn/route to that model's own engine child (SPAWN-OVERLAP: the new model loads while the old one keeps serving; nobody's stream is interrupted). Over the cap, the LRU engine drains, **demotes its prompt cache to the SSD tier**, and exits — switching back respawns it with state restored from disk. Measured (M1 Max, cpm5⇄qwen0.8b, pool 1): switch 1.5 s, switch-back 1.2 s with `cached_tokens` 103/104 — the conversation survived the eviction. The pool drives this eviction via `POST /admin/drain` — unix-socket-only (never exposed on the public TCP listener) — which quiesces the gateway and demotes the whole prompt cache to the SSD tier before eviction, responding `{ drained: true, demotions: N }`. |
@@ -38,11 +38,13 @@ the design rationale is in
38
38
  | `--draft-kind` | `two-model`\|`assistant`\|`dspark`\|`deepspec`\|`ngram` | auto | Override the draft-artifact kind detection (`dspark.json` → dspark; `Gemma4DSparkModel` architecture stamp → deepspec; `*_assistant` config → assistant; else two-model). **`ngram` is never auto-detected — it has no artifact**: MODEL-FREE prompt lookup (drafts copied from the request's own prompt+generation when its trailing k-gram occurred earlier in the stream; port of prompt-lookup decoding / vLLM's `ngram` proposer). Mount it ALONE — `--draft-kind ngram` with a `--draft-model` is refused, as is any other kind without one. Zero weights/memory, lossless by the same verify (gated: serve-loop output token-identical to non-spec greedy on e4b, tests/spec-ngram.test.ts); a no-match round degrades to one plain target step. Best on agentic/RAG/code-edit traffic that re-emits context spans. |
39
39
  | `--num-draft-tokens` | n | `3` (`ngram`: `10`) | Drafts per verify round (mlx_lm.server's default; `mlx_lm.generate`'s is 2). A DSpark draft pins this to its trained block width (`cfg.gamma`); ngram drafting is free so its default is the reference implementation's 10. |
40
40
  | `--ngram-max` / `--ngram-min` | k | `3` / `1` | `--draft-kind ngram` only: longest/shortest trailing k-gram searched for a match (the reference values — longest first, first occurrence wins). |
41
- | `--thinking` | `true`\|`false` | model's own (false for CPM) | Server-wide default for the chat template's `enable_thinking` (MiniCPM5/CPM and Qwen3.5 hybrid reasoning). Full precedence: an explicit request `chat_template_kwargs.enable_thinking` always wins; else `reasoning_effort` (`"none"` → off, any other level → on) if the request sent one; else this server default; else the model's own default. |
41
+ | `--mtp` | `on`\|`off` | on for GLM-5.2 | Mount the checkpoint-native GLM MTP row as the server's drafter. It uses the bounded auxiliary expert tier and exact serial verify loop (`usage.lane: "serial+spec"`). `off` removes the draft so ordinary GLM requests can use continuous batching; per-row batched MTP is post-release. Mutually exclusive with an explicit `--draft-model`/`--draft-kind`. |
42
+ | `--context-length` | tokens | `4096` for GLM-5.2 | Context reserved by GLM's header-only resource equation. The same value becomes the request-admission ceiling and is reported in `/stats.glm52`; an impossible plan fails before committing model memory. |
43
+ | `--thinking` | `true`\|`false` | model's own (false for CPM and GLM-5.2) | Server-wide default for the chat template's `enable_thinking` (MiniCPM5/CPM, Qwen3.5, and GLM-5.2). Full precedence: an explicit request `chat_template_kwargs.enable_thinking` always wins; else `reasoning_effort` (`"none"` → off, any other level → on) if the request sent one; else this server default; else the model's own default. GLM renders disabled thinking as `<think></think>` and enabled thinking as an open `<think>` generation primer plus `Reasoning Effort: Max`. |
42
44
  | `--temperature` | n ∈ [0,5] | `generation_config.json` | Server-wide sampling default. Per-request `temperature` still wins; the browser chat (sends none) inherits this. `--temp` is accepted as an alias (mlx_lm.server compat); explicit `--temperature` wins if both are given. **Migration note:** mlx_lm.server's `--temp` *default* is `0.0` (unset-temperature requests are greedy there); mlx-bun falls back to the model's `generation_config.json`, then `0.7` — pass `--temp 0` for mlx-lm's behavior. |
43
45
  | `--top-p` | n ∈ [0,1] | `generation_config.json` | Server-wide top-p default (per-request `top_p` wins). |
44
46
  | `--top-k` | n ∈ [0,1e6] | `generation_config.json` | Server-wide top-k default (per-request `top_k` wins). |
45
- | `--max-tokens` | n | `65536` chat / `512` raw completion | Completion cap when a request omits `max_tokens` (mlx_lm.server flag; its default there is 512 — `--max-tokens 512` reproduces mlx_lm.server exactly). |
47
+ | `--max-tokens` | n | GLM-5.2: `128`; otherwise `65536` chat / `512` raw completion | Completion cap when a request omits `max_tokens` (mlx_lm.server flag; its default there is 512 — `--max-tokens 512` reproduces mlx_lm.server exactly). For GLM this is also reserved by the pre-open resource equation and must fit inside `--context-length`. |
46
48
  | `--allow-private-media` | (bool) | off | Let `image_url`/`audio_url` content parts fetch from **private/loopback/link-local** hosts (a NAS, another LAN box). Off by default: a request's URL is attacker-controlled input, so remote media fetches (`src/media-fetch.ts`) refuse non-http(s) schemes and destinations in private/loopback/link-local/CGNAT ranges — including hosts that *resolve* there and every **redirect hop** (each hop re-passes the whole policy) — which keeps the server from being steered at cloud metadata (`169.254.169.254`) or LAN-internal services (SSRF). Independent of the flag, every remote fetch has a **10 s wall-clock timeout** and a **64 MB response cap** (streaming-enforced, not just Content-Length); violations surface as clean `400`s (`prompt build failed: …`). `data:` URLs decode locally and are never policy-checked. |
47
49
  | `--no-open` | (bool) | off | Skip the automatic browser open on start. By default an interactive terminal session opens `http://<host>:<port>/#/chat` once the server is ready; pass this flag to suppress it (e.g. headless or non-TTY environments already skip it). |
48
50
  | `--hlg-sampling` | `on`\|`off` | off | Piecewise tone-curve (HLG) sampling: rolls off the top-token region, boosts the mids, gentles the tail. The overall gain folds from `--temperature`. See [docs/design/hlg-sampling.md](../design/hlg-sampling.md). |
@@ -112,7 +114,8 @@ for paired A/B harnesses).
112
114
  | `MLX_BUN_PAGED_KV` | `--paged-kv` | off (`==="1"`) | Paged KV cache (see the flag row above — v1: serial, Gemma4, bf16; refusals + prompt-cache bypass apply identically). |
113
115
  | `MLX_BUN_ALLOW_PRIVATE_MEDIA` | `--allow-private-media` | off (`==="1"`) | Permit `image_url`/`audio_url` fetches to private/loopback/link-local hosts (the SSRF guard's LAN escape hatch — see the flag row above; timeout + size cap still apply). |
114
116
  | `MLX_BUN_PREFILL_TAIL_SPLIT` | — | on (`!=="0"`) | Oracle prefill convention (mlx-lm `generate_step` and its server's batched engine): drain the prompt only to len−1, then compute step-0 logits from a separate **L=1 forward of the last prompt token**. Both lanes. **The SPEC lane (`--draft-model`) follows its own oracle's shape under the same flag** (mlx-lm `speculative_generate_step`, re-anchored 2026-07-07): both target AND draft drain to len−1 and there is **no separate step-0 at all** — the un-drained last prompt token heads the first verify window. Gated live: token-for-token vs the oracle venv incl. a knife-edge cell (tests/spec-serve.test.ts "L1 knife-edge"). (The standalone optiq-oracled assistant loop `specGenerate` deliberately keeps FULL-prompt prefill — optiq's own convention.) `=0` restores the pre-2026-07-07 full-final-chunk convention everywhere (A/B lever + kill switch) — that convention is ulp-different in bf16 at step 0 AND in the last prompt token's stored KV, which flips near-tie greedy streams vs mlx-lm (the 2026-07-07 12B completion-probe divergence). |
115
- | `MLX_BUN_GRAMMAR_JUMP` | — | **off** (`==="1"`) | Jump-forward decoding for structured output (SGLang's technique via xgrammar's `findJumpForwardString`): when the grammar forces a unique continuation (JSON punctuation/keys; longest under `any_whitespace:false` fixed formatting), the SERIAL lane emits its retokenized ids with ONE multi-token forward instead of one masked forward per token. Lossless in STRING space and always grammar-valid, but the token stream (and, conditioning on it, content after a forced span) can legally differ from an unjumped run — no oracle, hence opt-in. Excluded automatically for `logprobs` requests; SentencePiece-family tokenizers whose raw encode can't reproduce a mid-stream span simply never jump (guard degrades to normal decode). The batch lane's per-row grammar (`#stepGrammar`) does not jump yet. Tests: tests/grammar-jump.test.ts (contract + Llama-3.2-1B e2e). |
117
+ | `MLX_BUN_GRAMMAR` | — | on (`!=="0"`) | `=0` disables structured-output mask compilation. Requested constraints still take the documented graceful-degrade route: chat prompt injection plus a `Warning` header; raw completions emit the header only. |
118
+ | `MLX_BUN_GRAMMAR_JUMP` | — | **off** (`==="1"`) | Jump-forward decoding for structured output (SGLang's technique via xgrammar's `findJumpForwardString`): when the grammar forces a unique continuation (JSON punctuation/keys; longest under `any_whitespace:false` fixed formatting), the SERIAL lane emits its retokenized ids with ONE multi-token forward instead of one masked forward per token. Lossless in STRING space and always grammar-valid, but the token stream (and, conditioning on it, content after a forced span) can legally differ from an unjumped run — no oracle, hence opt-in. Excluded automatically when either `logprobs` or `top_logprobs` is requested, because jumped tokens have no sampled distribution row; SentencePiece-family tokenizers whose raw encode can't reproduce a mid-stream span simply never jump (guard degrades to normal decode). The batch lane's per-row grammar (`#stepGrammar`) does not jump yet. Tests: tests/grammar-jump.test.ts (contract + Llama-3.2-1B e2e). |
116
119
  | `MLX_BUN_SSD_WRITEBEHIND` | — | on (`!=="0"`) | `--ssd-cache`'s debounced write-behind snapshot (restart survival). `=0` disables it entirely — the paired-A/B lever + kill switch for the 2026-07-07 idle-gating fix (eviction/demotion spills still write). The flush is **idle-gated**: every per-tensor step (blocking GPU sync + `writeSync`) waits for the engine to go idle and pauses when a request arrives mid-flush, so durability work never taxes an active decode (pre-fix: a ~16k entry's flush overlapping cached ctx repeats cost e4b ~9% decode@ctx vs mlx-lm). The snapshot timer also never holds batch admission — while rows are active it re-arms instead of grabbing the exclusive. |
117
120
  | `MLX_BUN_SSD_SPILL_QUEUE_GB` | — | 2 | Byte cap on the write-behind queue's PENDING spill clones (they pin the evicted entries' GPU memory until the idle-gated flush runs). Over cap, the oldest queued spill drops — its clones free immediately and the entry becomes a future cache miss (never a wrong result). `=0` keeps only the newest + in-flight clone pinned (the soft cap never drops the item just enqueued). `/stats.ssd_cache` reports `pending_spills` / `pending_spill_bytes` / `dropped_spills`. Note: there is deliberately **no shutdown flush** — exiting under traffic loses queued write-behind entries (best-effort tier; cost = one re-prefill). |
118
121
  | `MLX_BUN_BATCH_NO_PIPELINE` | — | off (`==="1"`) | Disables the batch lane's pipelined token readback (the A/B lever for the shipped pipelining, like its peers `MLX_BUN_BATCH_EXTEND` / `MLX_BUN_BATCH_SSM` / `MLX_BUN_BATCH_VEC_SAMPLE`). Numerically equivalent, slower steps. |
@@ -211,7 +214,8 @@ inside it per the table above).
211
214
 
212
215
  | Option | serial (`--batch 1`) | `--batch N` (N>1) |
213
216
  | --- | --- | --- |
214
- | `--kv-quant config`/`4`/`8` | ✅ applied to all requests | ⚠️ applied, but forces **all** requests to the serial lane (no batching) |
217
+ | `--kv-quant config` | ✅ applied to all requests | ✅ batches where the loaded cache capability supports the per-layer scheme; otherwise routes serial |
218
+ | `--kv-quant 4`/`8` | ✅ applied to all requests | ⚠️ uniform-threshold semantics route requests serial |
215
219
  | `--kv-quant turbo[:k<bits>v<bits>]` | ✅ applied to all requests | ⚠️ applied, but forces **all** requests to the serial lane (no batching) |
216
220
  | `--kv-quant off` | ✅ bf16 | ✅ bf16 (same as the implicit batch default) |
217
221
  | *(kv-quant unset)* | bf16 (the L1 default) | **bf16** (Option B) — incl. serial-lane fallback requests |
@@ -227,6 +231,7 @@ inside it per the table above).
227
231
  | `tools` / `stop` | ✅ | ✅ (batches) |
228
232
  | structured output (`response_format`/`guided_*`) | ✅ (mask in the decode loop) | ✅ (batches; per-row matchers) |
229
233
  | `--draft-model` | ✅ spec decode (grammar composes) | ⚠️ mounts, but routes **every** request serial — spec and batching are different modes |
234
+ | GLM `--mtp on` | ✅ native MTP spec decode | ⚠️ default-on MTP routes every request serial+spec; `--mtp off` exposes ordinary GLM batching |
230
235
  | `--compiled-decode` | ✅ (serial decode route) | ✅ at **B=1 only** (Phase 3.2): a lone request's adopted serial-class caches replay the same compiled step, same kill switch; B>1 steps run the plain graph |
231
236
  | `--fused-sdpa`/`--force-wire` | ✅ (serial decode route) | **n/a — compat mode, no perf flags by design** |
232
237
 
@@ -394,9 +399,9 @@ cells in one run).
394
399
  `--expert-offload`. Inherently the serial recipe until batched
395
400
  quantized KV lands.
396
401
 
397
- **The two exclusions to remember:** batching ⊕ kv-quant (batch is bf16 by
398
- contract; the resolver is a Lab-gated follow-up) and spec ⊕ prompt
399
- cache (v1 bypass; the §7.6 composition is the fix).
402
+ **The two exclusions to remember:** batching excludes uniform/TurboQuant KV
403
+ (per-layer `kv_config.json` does compose with batching), and spec excludes
404
+ prompt-cache reuse (v1 bypass; the §7.6 composition is the fix).
400
405
 
401
406
  ## Observability — `GET /stats`
402
407
 
@@ -405,9 +410,11 @@ lives in [server-api.md](server-api.md#get-stats) (kept in lockstep with
405
410
  the code — this section only explains how to read it); highlights:
406
411
  `prompt_cache` (8 GB default cap), `kv_quant.mode` (incl. `turbo kXvY`),
407
412
  the conditional `ssd_cache` block, and `batch`
408
- (`active_rows`/`pending_rows`/`submitted_rows`/`kv_bytes`/`kv_budget_bytes`).
409
-
410
- `batch.batched` reflects only whether `--batch N` (N>1) is configured;
411
- with an explicit `--kv-quant` it can read `true` while `active_rows`
412
- never exceeds 1 (every request routes serial). `active_rows` is the
413
- honest signal of whether anything is actually batching.
413
+ (`mode`/`active_rows`/`pending_rows`/`submitted_rows`/`kv_bytes`/
414
+ `kv_budget_bytes`).
415
+
416
+ `batch.mode` is the truthful configured/capability state: `off` for
417
+ `--batch 1`, `serial` when a larger configured cap cannot batch the loaded
418
+ model's cache layout, and `batch` when the model is admitted. `batch.batched`
419
+ is the compatibility boolean for `mode == "batch"`; `active_rows` reports the
420
+ instantaneous live rows.
@@ -177,7 +177,7 @@ are `DEFAULT_TRAIN_CONFIG` (trainer.ts:89).
177
177
  | `weight_decay` | float ≥0 | `0.01` | AdamW weight decay (β = `[0.9, 0.999]`, fixed) |
178
178
  | `lora_dropout` | float [0,1) | `0.0` | LoRA-input dropout (PEFT-style), training-only regularizer for small sets. Recompute-safe (mask keyed by step+layer, so segmented/checkpointed backward reproduces it) |
179
179
  | `rs_lora` | bool | `false` | rsLoRA — scale the update by α/√rank instead of α, so `rank_scaling` changes capacity not step size. Recorded in the adapter config; inference applies the same per-layer scale |
180
- | `lora_plus_ratio` | float ≥1 | `1.0` | LoRA+ — LR multiplier for the B leaves (A stays at base LR). >1 speeds the B-driven early learning (B is zero-init). 1 = off. Wired in the ORPO loop |
180
+ | `lora_plus_ratio` | float ≥1 | `1.0` | LoRA+ — LR multiplier for the B leaves (A stays at base LR). >1 speeds the B-driven early learning (B is zero-init). 1 = off. The same optimizer policy applies to SFT, DPO, and ORPO |
181
181
  | `grad_checkpoint` | bool | `false` | Recompute layer activations in backward (memory↔compute; bit-identical) |
182
182
  | `mlp_split` | bool | `false` | Gradient-checkpoint MLP splitting (Gemma4-only, requires `grad_checkpoint`) |
183
183
  | `segment_size` | int | `0` (off) | `>0` enables segmented backward — layers per segment (see below) |
@@ -295,6 +295,15 @@ A finished run writes a PEFT-compatible adapter directory:
295
295
  - `optiq_lora_config.json` — mlx-bun/optiq adapter metadata (per-layer ranks)
296
296
  - `adapter_config.json` — PEFT-compatible config
297
297
 
298
+ The save completes only after both config files are written, including
299
+ eval-step checkpoints, so a successful process exit always leaves an
300
+ immediately mountable directory. Serving accepts both mlx-lm tensor layout
301
+ (`[in, rank]` / `[rank, out]`) and standard PEFT layout
302
+ (`[rank, in]` / `[out, rank]`, including `base_model.model.*` names).
303
+ Standard PEFT `use_rslora` metadata is honored as
304
+ `lora_alpha / sqrt(rank)`, and saved rsLoRA adapters emit that metadata so
305
+ their training and serving scales agree.
306
+
298
307
  When `save_checkpoints: true`, each eval step also writes
299
308
  `checkpoints/step-<NNNNN>-val<loss>/` and a durable `metrics.json` (config,
300
309
  wall seconds, peak GB, final/best train+val loss, full val trajectory).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mlx-bun",
3
- "version": "0.0.11",
3
+ "version": "0.0.12",
4
4
  "description": "Native MLX inference for Bun on Apple Silicon — local LLM server (OpenAI/Anthropic-compatible) and TypeScript library. No Python.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/anthropic.ts CHANGED
@@ -469,58 +469,68 @@ export function translateOpenAiSse(
469
469
  const dec = new TextDecoder();
470
470
  let buf = "";
471
471
  let finalized = false;
472
+ let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
473
+ let cancelled = false;
472
474
  return new ReadableStream<Uint8Array>({
473
- async start(controller) {
475
+ start(controller) {
474
476
  const emit = (frames: string[]) => {
475
477
  for (const f of frames) controller.enqueue(enc.encode(f));
476
478
  };
477
- const reader = upstream.getReader();
478
- try {
479
- while (true) {
480
- const { done, value } = await reader.read();
481
- if (done) break;
482
- buf += dec.decode(value, { stream: true });
483
- let sep: number;
484
- while ((sep = buf.indexOf("\n\n")) !== -1) {
485
- const frame = buf.slice(0, sep);
486
- buf = buf.slice(sep + 2);
487
- const data = frame
488
- .split("\n")
489
- .find((l) => l.startsWith("data: "))
490
- ?.slice(6);
491
- if (data == null) continue;
492
- if (data === "[DONE]" || data === '"[DONE]"') {
493
- if (!finalized) {
494
- finalized = true;
495
- emit(translator.finalize());
479
+ reader = upstream.getReader();
480
+ void (async () => {
481
+ try {
482
+ while (!cancelled) {
483
+ const { done, value } = await reader!.read();
484
+ if (done) break;
485
+ buf += dec.decode(value, { stream: true });
486
+ let sep: number;
487
+ while ((sep = buf.indexOf("\n\n")) !== -1) {
488
+ const frame = buf.slice(0, sep);
489
+ buf = buf.slice(sep + 2);
490
+ const data = frame
491
+ .split("\n")
492
+ .find((l) => l.startsWith("data: "))
493
+ ?.slice(6);
494
+ if (data == null) continue;
495
+ if (data === "[DONE]" || data === '"[DONE]"') {
496
+ if (!finalized) {
497
+ finalized = true;
498
+ emit(translator.finalize());
499
+ }
500
+ continue;
496
501
  }
497
- continue;
498
- }
499
- const parsed = JSON.parse(data);
500
- if (parsed?.error) {
501
- emit([
502
- sse("error", {
503
- type: "error",
504
- error: { type: "api_error", message: parsed.error.message ?? "generation failed" },
505
- }),
506
- ]);
507
- continue;
502
+ const parsed = JSON.parse(data);
503
+ if (parsed?.error) {
504
+ emit([
505
+ sse("error", {
506
+ type: "error",
507
+ error: { type: "api_error", message: parsed.error.message ?? "generation failed" },
508
+ }),
509
+ ]);
510
+ continue;
511
+ }
512
+ emit(translator.addChunk(parsed));
508
513
  }
509
- emit(translator.addChunk(parsed));
510
514
  }
515
+ if (!cancelled && !finalized) emit(translator.finalize());
516
+ } catch (e) {
517
+ if (!cancelled)
518
+ emit([
519
+ sse("error", {
520
+ type: "error",
521
+ error: { type: "api_error", message: (e as Error).message },
522
+ }),
523
+ ]);
524
+ } finally {
525
+ reader?.releaseLock();
526
+ reader = null;
527
+ if (!cancelled) controller.close();
511
528
  }
512
- if (!finalized) emit(translator.finalize());
513
- } catch (e) {
514
- emit([
515
- sse("error", {
516
- type: "error",
517
- error: { type: "api_error", message: (e as Error).message },
518
- }),
519
- ]);
520
- } finally {
521
- reader.releaseLock();
522
- controller.close();
523
- }
529
+ })();
530
+ },
531
+ async cancel(reason) {
532
+ cancelled = true;
533
+ await reader?.cancel(reason);
524
534
  },
525
535
  });
526
536
  }