mlx-bun 0.0.4

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 (210) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +362 -0
  3. package/THIRD_PARTY_LICENSES.md +41 -0
  4. package/bin/mlx-bun.mjs +32 -0
  5. package/docs/curve-designer.html +303 -0
  6. package/docs/design/adapters-end-to-end.md +201 -0
  7. package/docs/design/compat-cli-surface-design.md +227 -0
  8. package/docs/design/docs-reorg-plan.md +152 -0
  9. package/docs/design/hlg-sampling.md +340 -0
  10. package/docs/design/memory-system.md +339 -0
  11. package/docs/design/optimization_plan.md +344 -0
  12. package/docs/design/parallel-slots.md +515 -0
  13. package/docs/design/runtime-isolation.md +247 -0
  14. package/docs/design/segmented-backward-training.md +508 -0
  15. package/docs/investigations/chunk-finetune-experiment.md +84 -0
  16. package/docs/investigations/curve-bisector-routing.md +123 -0
  17. package/docs/investigations/curve-runs/bisector-route.json +98 -0
  18. package/docs/investigations/curve-runs/bisector-trace.json +1393 -0
  19. package/docs/investigations/curve-runs/curve-terrain.json +1179 -0
  20. package/docs/investigations/curve-runs/curve-terrain.svg +42 -0
  21. package/docs/investigations/curve-terrain.html +105 -0
  22. package/docs/investigations/expert-offload-single-user-moe.md +292 -0
  23. package/docs/investigations/hlg-ab.html +34 -0
  24. package/docs/investigations/hlg-explorer.html +238 -0
  25. package/docs/investigations/hlg-figs/cliff.svg +1 -0
  26. package/docs/investigations/hlg-figs/cross.svg +1 -0
  27. package/docs/investigations/hlg-figs/edges.svg +1 -0
  28. package/docs/investigations/hlg-figs/k20-grids.svg +1 -0
  29. package/docs/investigations/hlg-figs/k20-peaks.svg +1 -0
  30. package/docs/investigations/hlg-figs/lw-curve.svg +1 -0
  31. package/docs/investigations/hlg-figs/terrain.svg +1 -0
  32. package/docs/investigations/hlg-report.html +262 -0
  33. package/docs/investigations/hlg-runs/hlg-ab.json +149 -0
  34. package/docs/investigations/hlg-runs/hlg-blind-prompts.md +87 -0
  35. package/docs/investigations/hlg-runs/hlg-blind.json +107 -0
  36. package/docs/investigations/hlg-runs/hlg-cliff.json +197 -0
  37. package/docs/investigations/hlg-runs/hlg-cross.json +505 -0
  38. package/docs/investigations/hlg-runs/hlg-edge.json +388 -0
  39. package/docs/investigations/hlg-runs/hlg-eval1.json +25 -0
  40. package/docs/investigations/hlg-runs/hlg-frontier.json +86 -0
  41. package/docs/investigations/hlg-runs/hlg-grid.json +242 -0
  42. package/docs/investigations/hlg-runs/hlg-grid2.json +343 -0
  43. package/docs/investigations/hlg-runs/hlg-lw-coarse.json +110 -0
  44. package/docs/investigations/hlg-runs/hlg-map.json +383 -0
  45. package/docs/investigations/hlg-runs/run0-original.log +159 -0
  46. package/docs/investigations/hlg-runs/runA-recommended.log +131 -0
  47. package/docs/investigations/hlg-runs/runAA-cliff-cpm5.log +26 -0
  48. package/docs/investigations/hlg-runs/runAB-grid2-e4b.log +18 -0
  49. package/docs/investigations/hlg-runs/runAC-peak-e4b-partial.log +25 -0
  50. package/docs/investigations/hlg-runs/runAD-ab-samples.log +8 -0
  51. package/docs/investigations/hlg-runs/runAE-edge-e4b.log +51 -0
  52. package/docs/investigations/hlg-runs/runAF-logit-calibration.log +10 -0
  53. package/docs/investigations/hlg-runs/runAG-eval-config2.log +16 -0
  54. package/docs/investigations/hlg-runs/runB-gain-variants.log +192 -0
  55. package/docs/investigations/hlg-runs/runC-pivots.log +162 -0
  56. package/docs/investigations/hlg-runs/runD-oetf.log +132 -0
  57. package/docs/investigations/hlg-runs/runE-eotf.log +162 -0
  58. package/docs/investigations/hlg-runs/runF-shaper-working.log +86 -0
  59. package/docs/investigations/hlg-runs/runG-shaper-sweep.log +101 -0
  60. package/docs/investigations/hlg-runs/runH-wsweep.log +176 -0
  61. package/docs/investigations/hlg-runs/runI-asweep.log +176 -0
  62. package/docs/investigations/hlg-runs/runJ-knobmap.log +49 -0
  63. package/docs/investigations/hlg-runs/runK-frontier.log +19 -0
  64. package/docs/investigations/hlg-runs/runL-widemap.log +65 -0
  65. package/docs/investigations/hlg-runs/runM-frontier-emb.log +15 -0
  66. package/docs/investigations/hlg-runs/runN-verify.log +116 -0
  67. package/docs/investigations/hlg-runs/runO-frontier-seed5000.log +15 -0
  68. package/docs/investigations/hlg-runs/runP-grid-AxSm.log +15 -0
  69. package/docs/investigations/hlg-runs/runQ-grid-tgW.log +15 -0
  70. package/docs/investigations/hlg-runs/runR-blind-transcript.log +79 -0
  71. package/docs/investigations/hlg-runs/runS-cross-coherence.log +33 -0
  72. package/docs/investigations/hlg-runs/runT-cross-cpm5.log +33 -0
  73. package/docs/investigations/hlg-runs/runU-cross-12b.log +33 -0
  74. package/docs/investigations/hlg-runs/runV-lw-e4b.log +22 -0
  75. package/docs/investigations/hlg-runs/runW-lw-12b.log +22 -0
  76. package/docs/investigations/hlg-runs/runX-lw-cpm5.log +22 -0
  77. package/docs/investigations/hlg-runs/runY-cliff-e4b.log +26 -0
  78. package/docs/investigations/hlg-runs/runZ-cliff-12b.log +26 -0
  79. package/docs/investigations/hlg-sampling-investigation.md +970 -0
  80. package/docs/investigations/lab-build-journal.md +157 -0
  81. package/docs/investigations/pi-builtin-investigation.md +308 -0
  82. package/docs/investigations/starter-model-port-handoff.md +323 -0
  83. package/docs/planning/IDEAS.md +26 -0
  84. package/docs/planning/PRODUCT_ROADMAP.md +426 -0
  85. package/docs/planning/ResearchTopics.md +119 -0
  86. package/docs/planning/chunk-task-roadmap.md +77 -0
  87. package/docs/planning/journal.md +155 -0
  88. package/docs/reference/distribution.md +121 -0
  89. package/docs/reference/embedding.md +168 -0
  90. package/docs/reference/library-api.md +153 -0
  91. package/docs/reference/server-api.md +224 -0
  92. package/docs/reference/server-config.md +243 -0
  93. package/docs/segmented-backward-handoff.md +148 -0
  94. package/package.json +68 -0
  95. package/src/anthropic.ts +491 -0
  96. package/src/chat-template.ts +186 -0
  97. package/src/cli.ts +1075 -0
  98. package/src/config.ts +229 -0
  99. package/src/curve-sampler.ts +122 -0
  100. package/src/dataset/generators.ts +1087 -0
  101. package/src/dataset/index.ts +12 -0
  102. package/src/dataset/job.ts +81 -0
  103. package/src/dataset/llm.ts +71 -0
  104. package/src/dataset/registry.ts +528 -0
  105. package/src/download.ts +283 -0
  106. package/src/eval/README.md +86 -0
  107. package/src/eval/kl-prompts.ts +29 -0
  108. package/src/eval/kl.ts +294 -0
  109. package/src/eval/quality-db.ts +92 -0
  110. package/src/eval/runner.ts +102 -0
  111. package/src/eval/score.ts +49 -0
  112. package/src/eval/tasks/bfcl.ts +500 -0
  113. package/src/eval/tasks/gsm8k.ts +85 -0
  114. package/src/eval/tasks/hashhop.ts +109 -0
  115. package/src/eval/tasks/humaneval.ts +252 -0
  116. package/src/eval/tasks/ifeval.ts +354 -0
  117. package/src/eval/tasks/mmlu.ts +145 -0
  118. package/src/evaldb.ts +199 -0
  119. package/src/expert-offload-build.ts +77 -0
  120. package/src/expert-offload.ts +61 -0
  121. package/src/expert-trace.ts +67 -0
  122. package/src/fit.ts +254 -0
  123. package/src/generate.ts +451 -0
  124. package/src/harness-pi.ts +177 -0
  125. package/src/hf-push.ts +628 -0
  126. package/src/index.ts +22 -0
  127. package/src/jobs/db.ts +139 -0
  128. package/src/jobs/index.ts +29 -0
  129. package/src/jobs/job-entry.ts +106 -0
  130. package/src/jobs/runner.ts +304 -0
  131. package/src/jobs/sse.ts +114 -0
  132. package/src/jobs/types.ts +58 -0
  133. package/src/kv-store.ts +185 -0
  134. package/src/lora.ts +314 -0
  135. package/src/md-modules.d.ts +7 -0
  136. package/src/mlx/array.ts +237 -0
  137. package/src/mlx/autograd.ts +267 -0
  138. package/src/mlx/checkpoint.ts +116 -0
  139. package/src/mlx/compile.ts +158 -0
  140. package/src/mlx/custom-vjp.ts +140 -0
  141. package/src/mlx/ffi.ts +433 -0
  142. package/src/mlx/metal-kernel.ts +130 -0
  143. package/src/mlx/ops.ts +707 -0
  144. package/src/mmap.ts +90 -0
  145. package/src/model/batched-mask.ts +204 -0
  146. package/src/model/batched-rotating.ts +300 -0
  147. package/src/model/compiled-decode.ts +681 -0
  148. package/src/model/factory.ts +27 -0
  149. package/src/model/fingerprint.ts +33 -0
  150. package/src/model/flash-attention.ts +674 -0
  151. package/src/model/fused-decode-kernel.ts +305 -0
  152. package/src/model/fused-geglu-kernel.ts +87 -0
  153. package/src/model/gemma4-base.ts +1616 -0
  154. package/src/model/gemma4.ts +1012 -0
  155. package/src/model/generated/gemma4-12b.ts +526 -0
  156. package/src/model/generated/gemma4-26b.ts +500 -0
  157. package/src/model/generated/gemma4-e4b.ts +757 -0
  158. package/src/model/generated/index.ts +18 -0
  159. package/src/model/minicpm5.ts +278 -0
  160. package/src/model/qwen3-delta.ts +224 -0
  161. package/src/model/qwen3_5.ts +429 -0
  162. package/src/model/support.ts +46 -0
  163. package/src/native-pack.ts +152 -0
  164. package/src/pi-provider.ts +95 -0
  165. package/src/pi-terminal.ts +264 -0
  166. package/src/pi-web.ts +924 -0
  167. package/src/preflight.ts +123 -0
  168. package/src/prompt-cache.ts +113 -0
  169. package/src/quantize/allocator.ts +627 -0
  170. package/src/quantize/calibration.ts +180 -0
  171. package/src/quantize/config-writer.ts +132 -0
  172. package/src/quantize/index.ts +57 -0
  173. package/src/quantize/job.ts +136 -0
  174. package/src/quantize/quantizer.ts +462 -0
  175. package/src/quantize/safetensors-writer.ts +171 -0
  176. package/src/quantize/sensitivity.ts +234 -0
  177. package/src/registry.ts +336 -0
  178. package/src/responses.ts +774 -0
  179. package/src/safetensors.ts +158 -0
  180. package/src/sampler.ts +517 -0
  181. package/src/serve/batch-scheduler.ts +362 -0
  182. package/src/serve/generation-gateway.ts +160 -0
  183. package/src/server.ts +1891 -0
  184. package/src/spec/drafter.ts +261 -0
  185. package/src/spec/generate.ts +221 -0
  186. package/src/status-page.html +497 -0
  187. package/src/tokenizer.ts +38 -0
  188. package/src/tool-call.ts +242 -0
  189. package/src/train/dataset.ts +461 -0
  190. package/src/train/export.ts +32 -0
  191. package/src/train/forward.ts +229 -0
  192. package/src/train/index.ts +43 -0
  193. package/src/train/job.ts +148 -0
  194. package/src/train/lora-params.ts +194 -0
  195. package/src/train/loss.ts +342 -0
  196. package/src/train/merge.ts +195 -0
  197. package/src/train/optimizer.ts +176 -0
  198. package/src/train/rank.ts +101 -0
  199. package/src/train/segmented.ts +520 -0
  200. package/src/train/trainer.ts +600 -0
  201. package/src/tui.ts +161 -0
  202. package/src/vision/embedder.ts +168 -0
  203. package/src/vision/preprocess.ts +247 -0
  204. package/src/vision/prompt.ts +158 -0
  205. package/src/vision/siglip.ts +599 -0
  206. package/src/web/app.html +2471 -0
  207. package/src/web/skills/web-research/SKILL.md +44 -0
  208. package/src/web/skills.ts +59 -0
  209. package/src/web-tools.ts +426 -0
  210. package/src/weights.ts +91 -0
package/src/cli.ts ADDED
@@ -0,0 +1,1075 @@
1
+ #!/usr/bin/env bun
2
+ // mlx-bun CLI: the appliance interface.
3
+ //
4
+ // mlx-bun get <org/repo> [--revision main] resumable verified download
5
+ // mlx-bun scan index the HF cache
6
+ // mlx-bun ls [--vision] [--max-size 10GB] [query]
7
+ // mlx-bun fit <query> [--ctx 32768] [--skus]
8
+ // mlx-bun serve [query] [--port 8090] [--memory-budget GB]
9
+ // mlx-bun evals recent benchmark runs
10
+ // mlx-bun harness pi [--base-url <url>] [--remove] connect your own pi to the local server
11
+
12
+ import { Registry } from "./registry";
13
+ import { loadModelConfig } from "./config";
14
+ import { fit, skuMatrix, thisMachine } from "./fit";
15
+ import { EvalDB } from "./evaldb";
16
+ import pkg from "../package.json" with { type: "json" };
17
+ import { renderHelp } from "./tui";
18
+ import { isSupportedModelRecord } from "./model/support";
19
+ import { perfKernelEnabled } from "./model/fused-decode-kernel";
20
+
21
+ const argv = process.argv.slice(2);
22
+ // The appliance path: naked `mlx-bun` (or only options, e.g.
23
+ // `mlx-bun --port 9000`) runs `serve` — first run downloads a model and
24
+ // opens the chat UI. `--help`/`--version` and explicit subcommands still win.
25
+ const HELP_VERSION_FLAGS = ["--help", "-h", "--version", "-v"];
26
+ const noSubcommand =
27
+ !argv[0] || (argv[0].startsWith("-") && !HELP_VERSION_FLAGS.includes(argv[0]));
28
+ const cmd = noSubcommand ? "serve" : argv[0];
29
+
30
+ const OVERVIEW = `mlx-bun ${pkg.version} — local AI on Apple silicon. One binary, no Python.
31
+
32
+ Usage: mlx-bun [command] [options] (no command = serve)
33
+
34
+ Commands:
35
+ pi Launch a pi coding-agent session on a local model (the appliance path)
36
+ serve Start the OpenAI/Anthropic-compatible server + status page
37
+ get Download a model from Hugging Face (resumable, verified)
38
+ ls List downloaded models
39
+ fit Will a model fit this machine? Memory + speed assessment
40
+ scan Re-index the Hugging Face cache
41
+ harness Connect your own pi install to the local mlx-bun server
42
+ benchmark Measure decode/prefill speed of OUR stack on this machine
43
+ evals Show recent benchmark runs (all stacks)
44
+ help Show help for a command (also: mlx-bun <command> --help)
45
+
46
+ Options:
47
+ -h, --help Show help
48
+ -v, --version Show version
49
+
50
+ Examples:
51
+ mlx-bun # download (first run) + serve + open the chat UI
52
+ mlx-bun pi # start (download if needed) and chat with an agent
53
+ mlx-bun serve 12B # serve the 12B; status page at http://localhost:8090/
54
+ mlx-bun get mlx-community/gemma-4-12B-it-OptiQ-4bit`;
55
+
56
+ const SERVER_FLAGS = `Server options:
57
+ --host <addr> Interface to bind [default: all interfaces;
58
+ use 127.0.0.1 for loopback-only]
59
+ --port <n> Listen port [default: 8090]
60
+ --memory-budget <GB> Admission-control memory budget; requests that
61
+ cannot fit are rejected instead of crashing the
62
+ GPU [default: machine RAM × 0.75, check-only]
63
+ --prompt-cache <GB> Prompt (KV) cache byte cap [default: 2 GB]
64
+ --batch <n> Max concurrent requests batched through the
65
+ mlx-lm-parity engine [default: 1 = serial].
66
+ >1 opts the whole server into bf16 continuous
67
+ batching (= mlx-lm B=N); see --kv-quant.
68
+ --no-open Don't open the chat UI in your browser on start.
69
+ By default an interactive terminal opens
70
+ http://<host>:<port>/#/chat once the server is up.
71
+
72
+ Model & quality:
73
+ --kv-quant <mode> KV cache quantization: config (per-layer
74
+ kv_config.json when the model ships one), off
75
+ (bf16), or 4 / 8 (uniform bits)
76
+ [default: config; bf16 under --batch N — the
77
+ batched engine is bf16-only, so an explicit
78
+ --kv-quant routes those requests to the serial path]
79
+ --thinking <true|false> Default for the chat template's enable_thinking
80
+ variable (CPM and other hybrid-reasoning models);
81
+ a request's chat_template_kwargs overrides it
82
+ [default: the model's own, false for CPM]
83
+ --temperature <n> Server-wide sampling defaults; a per-request field
84
+ --top-p <n> still overrides them, and the browser chat (which
85
+ --top-k <n> sends none) inherits them
86
+ [default: the model's generation_config.json]
87
+ --hlg-sampling on|off Piecewise tone-curve sampling (HLG): rolls off the
88
+ top, boosts the mids, gentles the tail. Gain folds
89
+ from --temperature. [default: off]
90
+ See docs/design/hlg-sampling.md
91
+ --hlg-width <nats> HLG mid-region half-width [default: 4]
92
+ --hlg-shoulder <nats> HLG highlight rolloff scale [default: 4]
93
+ --hlg-toe <nats> HLG shadow rolloff scale [default: 6]
94
+ --hlg-pivot-offset <nats> HLG pivot: nats below the top token [default: 6]
95
+
96
+ Performance levers (A/B levers; defaults are the measured winners):
97
+ --compiled-decode on|off Compiled decode graphs [default: on]
98
+ --perf-kernel on|off Fused quantized-KV decode-SDPA Metal kernel
99
+ (perf side of the compat A/B) [default: on]
100
+ --fused-decode on|off Fused-decode experiment lever [default: off]
101
+ --fused-sdpa on|off Fused SDPA path [default: on]
102
+ --force-wire Wire weights into memory at load
103
+ --expert-offload MoE only: serve experts from a page-aligned file
104
+ mmap (built on first use) — keeps the model out of
105
+ memory pressure (phys_footprint ≈ active params),
106
+ bit-exact with the resident path`;
107
+
108
+ const HELP: Record<string, string> = {
109
+ pi: `mlx-bun pi — drop into a coding-agent session on a local model
110
+
111
+ Usage: mlx-bun pi [options] [message...]
112
+
113
+ Runs the pi coding agent's own terminal UI in-process against the local
114
+ model — nothing to install, pi is built in. Reuses a healthy local
115
+ server when one is running; otherwise serves e4b by default (override
116
+ with --query) and starts a server for the session (the server ends with
117
+ the session). Fresh install: downloads a small starter model first so
118
+ you're chatting in minutes, then streams e4b in the background — it
119
+ becomes the default on the next run.
120
+
121
+ Model selection:
122
+ -q, --query <q> Model to serve when starting a server (registry query)
123
+
124
+ ${SERVER_FLAGS}
125
+
126
+ Run modes:
127
+ message... Pre-fill the first turn (interactive)
128
+ -p, --print [msg] One-shot: print the reply and exit (also reads piped
129
+ stdin); add --json for the full event stream
130
+ --mode json|rpc Structured print (json) or JSONL RPC over stdio
131
+
132
+ Already use pi? Connect your own pi to this local model instead:
133
+ mlx-bun harness pi`,
134
+
135
+ serve: `mlx-bun serve — OpenAI/Anthropic-compatible server for a local model
136
+
137
+ Usage: mlx-bun serve [query] [options]
138
+
139
+ [query] Registry query (e.g. "12B", "e4b", a repo substring).
140
+ Omitted: serves e4b (the default), else the largest
141
+ downloaded model that fits. Fresh install: downloads a
142
+ small starter model first, then e4b in the background
143
+ (default on next run).
144
+
145
+ ${SERVER_FLAGS}
146
+
147
+ Endpoints: /v1/chat/completions, /v1/messages, /v1/responses, /v1/models,
148
+ /v1/adapters, /stats, /fit, /library, /downloads — status page at /,
149
+ browser chat at /chat`,
150
+
151
+ get: `mlx-bun get — download a model from Hugging Face
152
+
153
+ Usage: mlx-bun get <org/repo> [options]
154
+
155
+ Options:
156
+ --revision <rev> Git revision [default: main]
157
+
158
+ Resumable (Range requests against partial blobs) and verified (sha256
159
+ for LFS blobs). Re-running after an interruption continues where it
160
+ stopped. Uses HF_TOKEN / hf auth login credentials when present.`,
161
+
162
+ ls: `mlx-bun ls — list downloaded models
163
+
164
+ Usage: mlx-bun ls [query] [options]
165
+
166
+ Options:
167
+ --vision Only models with a vision sidecar
168
+ --max-size <size> Filter by weight size (e.g. 10GB, 800MB)`,
169
+
170
+ fit: `mlx-bun fit — will this model fit on this machine?
171
+
172
+ Usage: mlx-bun fit <query> [options]
173
+
174
+ Options:
175
+ --ctx <tokens> Context size to assess [default: 32768]
176
+ --skus Also print the Apple silicon SKU matrix
177
+
178
+ Solves weights + KV growth + prefill transient against wired memory and
179
+ predicts decode speed from memory bandwidth.`,
180
+
181
+ scan: `mlx-bun scan — re-index the Hugging Face cache
182
+
183
+ Usage: mlx-bun scan
184
+
185
+ Reads config.json + safetensors headers (never tensor bytes) for every
186
+ snapshot in ~/.cache/huggingface/hub and refreshes the model registry.`,
187
+
188
+ harness: `mlx-bun harness pi — connect your own pi to the local model
189
+
190
+ Usage: mlx-bun harness pi [options]
191
+
192
+ For people who already use pi: this points your existing pi install at
193
+ the local mlx-bun server so you can run pi the way you already do, on a
194
+ local model. (To just chat now with no setup, use \`mlx-bun pi\` — the
195
+ built-in agent.)
196
+
197
+ Options:
198
+ --base-url <url> Server base URL [default: http://localhost:8090/v1]
199
+ --remove Disconnect (delete the extension)
200
+
201
+ Installs a small discovery extension into ~/.pi/agent/extensions that
202
+ registers mlx-bun as a pi provider (models discovered live from
203
+ /v1/models at pi startup). One file, reversible with --remove; never
204
+ touches your existing pi config. Then: pi --provider mlx-bun`,
205
+
206
+ benchmark: `mlx-bun benchmark — measure OUR stack on this machine
207
+
208
+ Usage: mlx-bun benchmark [query] [options]
209
+
210
+ Runs mlx-bun by itself (no other systems required) and records the
211
+ result to the eval DB. For quotable, cross-stack numbers use
212
+ ./benchmark.sh from the repo — it preflight-gates on an idle machine
213
+ and runs the mlx-lm/optiq comparison legs.
214
+
215
+ [query] Model to benchmark [default: auto-pick]
216
+
217
+ Options:
218
+ --tokens <n> Tokens to decode per run [default: 256]
219
+ --runs <n> Runs (median reported) [default: 3]
220
+ --prompt-tokens <n> Pad the prompt to ~n tokens (long-context decode)
221
+ --kv-quant <mode> config | off | 4 | 8 [default: off, the
222
+ historical baseline]
223
+
224
+ Performance levers (--compiled-decode, --perf-kernel, --fused-decode,
225
+ --fused-sdpa) apply to the run — A/B by running twice.`,
226
+
227
+ evals: `mlx-bun evals — recent benchmark runs
228
+
229
+ Usage: mlx-bun evals [options]
230
+
231
+ Options:
232
+ --limit <n> Rows to show [default: 20]
233
+ --raw Full records as JSON lines (for scripts/jq)
234
+ --clear Archive the eval DB (timestamped backup) and start fresh
235
+
236
+ Table view: when, model, bench kind, KV mode, decode tok/s, TTFT,
237
+ peak memory, commit. Runs are written by ./benchmark.sh.`,
238
+ };
239
+
240
+ HELP.bench = HELP.benchmark!;
241
+
242
+ function printHelp(topic?: string): never {
243
+ if (topic && HELP[topic]) console.log(renderHelp(HELP[topic]));
244
+ else if (topic) {
245
+ console.error(`unknown command: ${topic}\n`);
246
+ console.log(renderHelp(OVERVIEW));
247
+ process.exit(1);
248
+ } else console.log(renderHelp(OVERVIEW));
249
+ process.exit(0);
250
+ }
251
+
252
+ if (cmd === "--help" || cmd === "-h") printHelp();
253
+ if (cmd === "--version" || cmd === "-v" || cmd === "version") {
254
+ console.log(`mlx-bun ${pkg.version}`);
255
+ process.exit(0);
256
+ }
257
+ if (cmd === "help") printHelp(argv[1]);
258
+ // Per-command --help/-h — except `pi`, whose extra args pass through
259
+ // (use `mlx-bun help pi` for our help, `pi --help` for pi's).
260
+ if (cmd !== "pi" && (argv.includes("--help") || argv.includes("-h"))) printHelp(cmd);
261
+ if (cmd === "pi" && (argv[1] === "--help" || argv[1] === "-h") && argv.length === 2) printHelp("pi");
262
+
263
+ const flag = (name: string): boolean => argv.includes(`--${name}`);
264
+ const opt = (name: string, dflt: string | null = null): string | null => {
265
+ const i = argv.indexOf(`--${name}`);
266
+ return i > -1 && argv[i + 1] ? argv[i + 1]! : dflt;
267
+ };
268
+ /** Single-dash value flag (e.g. -q), the short-alias counterpart to opt(). */
269
+ const optShort = (short: string, dflt: string | null = null): string | null => {
270
+ const i = argv.indexOf(`-${short}`);
271
+ return i > -1 && argv[i + 1] ? argv[i + 1]! : dflt;
272
+ };
273
+ const positional = (n: number): string | undefined =>
274
+ argv.filter((a, i) => !a.startsWith("--") && (i === 0 || !argv[i - 1]!.startsWith("--"))).slice(1)[n];
275
+
276
+ const gb = (bytes: number) => `${(bytes / 2 ** 30).toFixed(2)} GB`;
277
+
278
+ /** First-run: fetch the MLX native runtime pack if no copy is resolvable
279
+ * (env / beside the binary / cache / homebrew). ~52 MB, resumable,
280
+ * sha256-verified. Must run BEFORE importing anything that dlopens
281
+ * (./server, ./generate — they load libmlxc at module scope). */
282
+ async function ensureNative(s?: import("./tui").Step): Promise<void> {
283
+ const { nativeRuntimeDir, ensureNativeRuntime, NATIVE_PACK_VERSION } = await import("./native-pack");
284
+ if (nativeRuntimeDir()) return;
285
+ s?.update(`downloading the MLX native runtime (v${NATIVE_PACK_VERSION}, ~52 MB)`);
286
+ await ensureNativeRuntime({
287
+ onProgress: (received, total) => {
288
+ const pct = total ? Math.floor((received / total) * 100) : 0;
289
+ const line = `MLX native runtime ${gb(received)} / ${gb(total)} (${pct}%)`;
290
+ if (s) s.update(line); else process.stdout.write(`\r ${line} `);
291
+ },
292
+ });
293
+ if (!s) process.stdout.write("\n");
294
+ }
295
+
296
+ /** AppleScript that focuses the first tab whose URL contains `match` in a
297
+ * literal-named browser, returning "ok"/"miss". The app name MUST be a literal
298
+ * (not a variable): AppleScript loads an app's scripting dictionary by literal
299
+ * name at COMPILE time, so `tell application someVar` can't resolve app-specific
300
+ * terms like `active tab index` (fails with -2740). Referencing an uninstalled
301
+ * app by literal name is also a compile error a `try` can't catch — hence we
302
+ * only ever run a browser's script when pgrep says it's running (= installed). */
303
+ function focusTabScript(app: string, kind: "chromium" | "safari", match: string): string {
304
+ const m = JSON.stringify(match); // safe AppleScript string literal
305
+ if (kind === "safari") {
306
+ return `tell application "${app}"
307
+ repeat with w in windows
308
+ repeat with t in tabs of w
309
+ if URL of t contains ${m} then
310
+ set current tab of w to t
311
+ activate
312
+ return "ok"
313
+ end if
314
+ end repeat
315
+ end repeat
316
+ end tell
317
+ return "miss"`;
318
+ }
319
+ return `tell application "${app}"
320
+ repeat with w in windows
321
+ set k to 0
322
+ repeat with t in tabs of w
323
+ set k to k + 1
324
+ if URL of t contains ${m} then
325
+ set active tab index of w to k
326
+ set index of w to 1
327
+ activate
328
+ return "ok"
329
+ end if
330
+ end repeat
331
+ end repeat
332
+ end tell
333
+ return "miss"`;
334
+ }
335
+
336
+ /** Open the chat UI, reusing an already-open tab on this host:port instead of
337
+ * spawning a duplicate. Each browser is scripted via its own literal-named
338
+ * AppleScript, run ONLY when pgrep confirms it's running (so the literal `tell`
339
+ * always compiles). Falls back to a plain `open` (new tab) when no running
340
+ * browser has the tab, for browsers we don't script (e.g. Firefox), or if
341
+ * AppleScript is blocked. Best-effort and fire-and-forget: any failure just
342
+ * means a fresh tab, so it never regresses the plain-open path. The first focus
343
+ * may trigger a one-time macOS "control your browser" permission prompt;
344
+ * declining it just falls back to opening a new tab. */
345
+ function openChatUi(url: string, hostPort: string): void {
346
+ const browsers: Array<{ proc: string; kind: "chromium" | "safari" }> = [
347
+ { proc: "Google Chrome", kind: "chromium" },
348
+ { proc: "Arc", kind: "chromium" },
349
+ { proc: "Brave Browser", kind: "chromium" },
350
+ { proc: "Microsoft Edge", kind: "chromium" },
351
+ { proc: "Safari", kind: "safari" },
352
+ ];
353
+ void (async () => {
354
+ for (const b of browsers) {
355
+ try {
356
+ const running = Bun.spawn(["pgrep", "-x", b.proc], { stdout: "ignore", stderr: "ignore" });
357
+ if ((await running.exited) !== 0) continue; // not running → skip (and don't compile its tell)
358
+ const script = focusTabScript(b.proc, b.kind, hostPort);
359
+ const p = Bun.spawn(["osascript", "-e", script], { stdout: "pipe", stderr: "ignore", timeout: 8000 });
360
+ const out = await new Response(p.stdout).text();
361
+ if ((await p.exited) === 0 && out.trim() === "ok") return; // focused an existing tab
362
+ } catch { /* try the next browser */ }
363
+ }
364
+ try { Bun.spawn(["open", url], { stdout: "ignore", stderr: "ignore" }); } catch { /* best-effort */ }
365
+ })();
366
+ }
367
+
368
+ /** Server/runtime flags shared by every mode that loads a model
369
+ * (serve, pi). Env levers are set here so they're in place before the
370
+ * generate/compiled-decode modules read them. */
371
+ function serverRuntimeFlags(): { port: number; serverOptions: import("./server").ServerOptions } {
372
+ const onOff = (name: string): boolean | null => {
373
+ const v = opt(name);
374
+ if (v === null) return null;
375
+ if (v === "on" || v === "1" || v === "true") return true;
376
+ if (v === "off" || v === "0" || v === "false") return false;
377
+ console.error(`--${name} expects on|off (got "${v}")`);
378
+ process.exit(1);
379
+ };
380
+ const cd = onOff("compiled-decode");
381
+ if (cd !== null) process.env.MLX_BUN_COMPILED_DECODE = cd ? "1" : "0";
382
+ const pk = onOff("perf-kernel");
383
+ if (pk !== null) process.env.MLX_BUN_PERF_KERNEL = pk ? "1" : "0";
384
+ const fd = onOff("fused-decode");
385
+ if (fd !== null) process.env.MLX_BUN_FUSED_DECODE = fd ? "1" : "0";
386
+ const fs = onOff("fused-sdpa");
387
+ if (fs !== null) process.env.MLX_BUN_NO_FUSED_SDPA = fs ? "0" : "1"; // inverted env
388
+ if (flag("force-wire")) process.env.MLX_BUN_FORCE_WIRE = "1";
389
+
390
+ const serverOptions: import("./server").ServerOptions = {};
391
+ const budgetGB = Number(opt("memory-budget", "0"));
392
+ if (budgetGB > 0) serverOptions.memoryBudgetBytes = budgetGB * 1e9;
393
+ const pcGB = Number(opt("prompt-cache", "0"));
394
+ if (pcGB > 0) serverOptions.promptCacheBytes = pcGB * 2 ** 30;
395
+ // --batch N: max concurrent requests batched through the mlx-lm-parity
396
+ // engine (N=1 = today's serial path). --decode-concurrency is accepted as
397
+ // an mlx_lm.server-compatible alias (drop-in).
398
+ const batchRaw = opt("batch") ?? opt("decode-concurrency");
399
+ if (batchRaw !== null) {
400
+ const n = Number(batchRaw);
401
+ if (!Number.isInteger(n) || n < 1) {
402
+ console.error(`--batch expects an integer >= 1 (got "${batchRaw}")`);
403
+ process.exit(1);
404
+ }
405
+ serverOptions.batch = n;
406
+ }
407
+ const kv = opt("kv-quant");
408
+ if (kv === "off") serverOptions.kvQuant = "off";
409
+ else if (kv === "config") serverOptions.kvQuant = "config";
410
+ else if (kv) {
411
+ const bits = Number(kv);
412
+ if (![4, 8].includes(bits)) { console.error(`--kv-quant expects config|off|4|8 (got "${kv}")`); process.exit(1); }
413
+ serverOptions.kvQuant = bits;
414
+ }
415
+ const host = opt("host");
416
+ if (host) serverOptions.hostname = host;
417
+ // Server-wide default for the chat template's enable_thinking variable
418
+ // (CPM/MiniCPM5 and other hybrid-reasoning models). Unset ⇒ the model's
419
+ // own default (false for MiniCPM5). onOff accepts true|false|on|off|1|0.
420
+ const thinking = onOff("thinking");
421
+ if (thinking !== null) serverOptions.defaultThinking = thinking;
422
+ // Server-wide sampling defaults. Unset ⇒ the model's generation_config.json
423
+ // (MiniCPM5 ships 0.9 / 0.95 — its think recipe). A per-request field still
424
+ // overrides these; the browser chat sends none, so this is its only lever.
425
+ const numFlag = (name: string, lo: number, hi: number): number | null => {
426
+ const v = opt(name);
427
+ if (v === null) return null;
428
+ const n = Number(v);
429
+ if (!Number.isFinite(n) || n < lo || n > hi) {
430
+ console.error(`--${name} expects a number in [${lo}, ${hi}] (got "${v}")`);
431
+ process.exit(1);
432
+ }
433
+ return n;
434
+ };
435
+ const temp = numFlag("temperature", 0, 5);
436
+ if (temp !== null) serverOptions.defaultTemperature = temp;
437
+ const topP = numFlag("top-p", 0, 1);
438
+ if (topP !== null) serverOptions.defaultTopP = topP;
439
+ const topK = numFlag("top-k", 0, 1_000_000);
440
+ if (topK !== null) serverOptions.defaultTopK = topK;
441
+ // HLG sampling — piecewise tone curve on the logprobs (default off). Knobs in
442
+ // nats; the mid gain folds from --temperature. docs/design/hlg-sampling.md.
443
+ if (onOff("hlg-sampling") === true) {
444
+ serverOptions.hlg = {
445
+ enabled: true,
446
+ width: numFlag("hlg-width", 0, 100) ?? 4,
447
+ shoulder: numFlag("hlg-shoulder", 0, 100) ?? 4,
448
+ toe: numFlag("hlg-toe", 0, 100) ?? 6,
449
+ pivotOffset: numFlag("hlg-pivot-offset", 0, 100) ?? 6,
450
+ pivot: "top",
451
+ };
452
+ }
453
+ return { port: Number(opt("port", "8090")), serverOptions };
454
+ }
455
+
456
+ /** One-line summary of the active runtime levers for the ready card. */
457
+ function runtimeSummary(o: import("./server").ServerOptions): string {
458
+ const kv = o.kvQuant === "off" ? "off" : typeof o.kvQuant === "number" ? `kv${o.kvQuant}` : "config";
459
+ const lever = (env: string, dflt: string) => process.env[env] ?? dflt;
460
+ return `kv-quant ${kv} · compiled-decode ${lever("MLX_BUN_COMPILED_DECODE", "1") === "1" ? "on" : "off"}` +
461
+ ` · perf-kernel ${perfKernelEnabled() ? "on" : "off"}` +
462
+ (lever("MLX_BUN_FUSED_DECODE", "0") === "1" ? " · fused-decode on" : "") +
463
+ (o.batch && o.batch > 1 ? ` · batch ${o.batch}` : "") +
464
+ (o.defaultThinking !== undefined ? ` · thinking ${o.defaultThinking ? "on" : "off"}` : "") +
465
+ (o.defaultTemperature !== undefined ? ` · temp ${o.defaultTemperature}` : "") +
466
+ (o.defaultTopP !== undefined ? ` · top-p ${o.defaultTopP}` : "") +
467
+ (o.defaultTopK !== undefined ? ` · top-k ${o.defaultTopK}` : "");
468
+ }
469
+
470
+ /** Shared model resolution: explicit query wins; otherwise e4b (the default
471
+ * model everywhere) if it's downloaded, else the largest supported model that
472
+ * fits this machine. Downloads the recommended model first on a fresh install. */
473
+ async function resolveModelAuto(query: string | null): Promise<{ m: import("./registry").ModelRecord; picked: boolean }> {
474
+ const reg = new Registry();
475
+ if (reg.list().length === 0) await reg.scan();
476
+ if (query) return { m: reg.resolve(query), picked: false };
477
+ const { recommendedRepoId } = await import("./fit");
478
+ let candidates = reg.list().filter((r) => isSupportedModelRecord(r.modelType, r.repoId));
479
+ if (candidates.length === 0) {
480
+ // First run: starter-model flow (PRODUCT_ROADMAP / PLAN Phase 16).
481
+ // Download the sub-GB starter first so the user is chatting quickly,
482
+ // then stream the recommended Gemma for this Mac in the background —
483
+ // it becomes the auto-pick next run.
484
+ const starterRepo = "mlx-community/MiniCPM5-1B-OptiQ-4bit";
485
+ const recRepo = recommendedRepoId();
486
+ const { step, style } = await import("./tui");
487
+ const { downloadModel } = await import("./download");
488
+ const s = step(`downloading starter model ${starterRepo}`);
489
+ await downloadModel(starterRepo, {
490
+ onProgress: (file, received, total) => {
491
+ const pct = total ? Math.floor((received / total) * 100) : 0;
492
+ s.update(`starter model ${starterRepo} — ${file} ${gb(received)} / ${gb(total)} (${pct}%)`);
493
+ },
494
+ });
495
+ s.done(`starter model ready ${style.dim(`· ${starterRepo}`)}`);
496
+ await reg.scan();
497
+ if (recRepo !== starterRepo) {
498
+ console.log(` ${style.dim(`downloading ${recRepo} (recommended for this Mac) in the background —`)}`);
499
+ console.log(` ${style.dim("it becomes the default next run · progress on the status page (/downloads) · resumable")}`);
500
+ downloadModel(recRepo)
501
+ .then(async () => {
502
+ await new Registry().scan();
503
+ console.log(` ${style.dim(`background download complete: ${recRepo} (used on next run)`)}`);
504
+ })
505
+ .catch(() => { /* resumable — the next run continues it */ });
506
+ }
507
+ candidates = reg.list().filter((r) => isSupportedModelRecord(r.modelType, r.repoId));
508
+ }
509
+ // No query: e4b by default; otherwise the largest model that still leaves
510
+ // the machine usable for other apps. Never auto-grab the 26B "dedicate the
511
+ // machine" model on a small-RAM Mac — that's an explicit --query choice —
512
+ // but allow it as a last resort if it's all the user has downloaded.
513
+ const { DEFAULT_REPO_ID, COEXIST_FRACTION, chooseAutoModel, thisMachine } = await import("./fit");
514
+ const machine = thisMachine();
515
+ const fitsFull = new Map<string, boolean>();
516
+ const fitsCoexist = new Map<string, boolean>();
517
+ for (const r of candidates) {
518
+ const config = await loadModelConfig(r.path);
519
+ fitsFull.set(r.repoId, fit(config, r.sizeBytes, 8192, machine, undefined, r.expertsBytes).fits);
520
+ fitsCoexist.set(
521
+ r.repoId,
522
+ fit(config, r.sizeBytes, 8192, machine, undefined, r.expertsBytes, machine.ramBytes * COEXIST_FRACTION).fits,
523
+ );
524
+ }
525
+ const chosen = chooseAutoModel(
526
+ candidates,
527
+ DEFAULT_REPO_ID,
528
+ (c) => fitsFull.get(c.repoId) ?? false,
529
+ (c) => fitsCoexist.get(c.repoId) ?? false,
530
+ );
531
+ if (chosen) return { m: chosen, picked: true };
532
+ console.error("no downloaded supported model fits this machine — pick one explicitly (mlx-bun ls)");
533
+ process.exit(1);
534
+ }
535
+
536
+ function parseSize(s: string): number {
537
+ const m = /^([\d.]+)\s*(GB|MB|G|M)?$/i.exec(s.trim());
538
+ if (!m) throw new Error(`bad size: ${s}`);
539
+ const mult = /^m/i.test(m[2] ?? "G") ? 2 ** 20 : 2 ** 30;
540
+ return Number(m[1]) * mult;
541
+ }
542
+
543
+ switch (cmd) {
544
+ case "get": {
545
+ const repoId = positional(0);
546
+ if (!repoId || !repoId.includes("/")) {
547
+ console.error("usage: mlx-bun get <org/repo> [--revision main]");
548
+ process.exit(1);
549
+ }
550
+ const { downloadModel } = await import("./download");
551
+ const { step, style } = await import("./tui");
552
+ const s = step(`downloading ${repoId}`);
553
+ const snap = await downloadModel(repoId, {
554
+ revision: opt("revision", "main")!,
555
+ onProgress: (file, received, total) => {
556
+ const pct = total ? Math.floor((received / total) * 100) : 0;
557
+ s.update(`${style.bold(repoId)} ${style.dim(`· ${file} · ${gb(received)} / ${gb(total)} (${pct}%)`)}`);
558
+ },
559
+ });
560
+ s.done(`${style.bold(repoId)} ${style.dim("downloaded · verified")}`);
561
+ const sScan = step("updating registry");
562
+ const reg = new Registry();
563
+ await reg.scan();
564
+ sScan.done(`registry updated ${style.dim(`· ${snap}`)}`);
565
+ break;
566
+ }
567
+
568
+ case "scan": {
569
+ const { step } = await import("./tui");
570
+ const s = step("scanning the Hugging Face cache");
571
+ const reg = new Registry();
572
+ const n = await reg.scan();
573
+ s.done(`indexed ${n} model snapshot(s)`);
574
+ break;
575
+ }
576
+
577
+ case "ls": {
578
+ const reg = new Registry();
579
+ if ((reg.list().length ?? 0) === 0) await reg.scan();
580
+ const maxSize = opt("max-size");
581
+ const models = reg.list({
582
+ vision: flag("vision") ? true : undefined,
583
+ maxBytes: maxSize ? parseSize(maxSize) : undefined,
584
+ query: positional(0),
585
+ });
586
+ if (models.length === 0) {
587
+ console.log("no models match (try `mlx-bun scan`)");
588
+ break;
589
+ }
590
+ const { table, style, h1 } = await import("./tui");
591
+ h1("library");
592
+ console.log();
593
+ table(
594
+ [
595
+ { header: "model", paint: (c) => style.bold(c) },
596
+ { header: "size", align: "right" },
597
+ { header: "params", align: "right" },
598
+ { header: "quant" },
599
+ { header: "license", paint: (c) => style.dim(c) },
600
+ { header: "capabilities", paint: (c) => style.dim(c) },
601
+ ],
602
+ models.map((m) => [
603
+ m.repoId,
604
+ gb(m.sizeBytes),
605
+ m.paramCount ? `${(m.paramCount / 1e9).toFixed(1)}B` : "?",
606
+ m.quantBits ? `${m.quantBits}-bit g${m.quantGroupSize}` : "full",
607
+ m.license ?? "?",
608
+ [
609
+ isSupportedModelRecord(m.modelType, m.repoId) ? "supported" : `unsupported (${m.modelType})`,
610
+ m.hasVisionSidecar ? "vision" : null,
611
+ m.hasToolTemplate ? "tools" : null,
612
+ m.hasKvConfig ? "kv-quant" : null,
613
+ ].filter(Boolean).join(" · "),
614
+ ]),
615
+ );
616
+ console.log();
617
+ console.log(style.dim(` ${models.length} model(s) · mlx-bun fit <query> for a memory assessment`));
618
+ break;
619
+ }
620
+
621
+ case "fit": {
622
+ const query = positional(0);
623
+ if (!query) { console.error("usage: mlx-bun fit <query> [--ctx N] [--skus]"); process.exit(1); }
624
+ const reg = new Registry();
625
+ if (reg.list().length === 0) await reg.scan();
626
+ const m = reg.resolve(query);
627
+ const config = await loadModelConfig(m.path);
628
+ const ctx = Number(opt("ctx", "8192"));
629
+ const r = fit(config, m.sizeBytes, ctx, thisMachine(), undefined, m.expertsBytes);
630
+ const { box, table, style, h1, gradient } = await import("./tui");
631
+ h1("will it fit?");
632
+ console.log(` ${style.bold(m.repoId)} ${style.dim(`@ ${ctx.toLocaleString()} context · this machine`)}`);
633
+ console.log();
634
+ const expertsNote = config.text.enableMoeBlock && m.expertsBytes > 0
635
+ ? style.dim(` (experts ${gb(m.expertsBytes)}; top ${config.text.topKExperts}/${config.text.numExperts} read per token)`)
636
+ : "";
637
+ box([
638
+ `weights ${gb(r.weightsBytes).padStart(9)}${expertsNote}`,
639
+ ...(m.sidecarBytes > 0
640
+ ? [style.dim(` + vision sidecar ${gb(m.sidecarBytes)} (bf16, loads only for vision)`)] : []),
641
+ `kv cache ${gb(r.kvBytes).padStart(9)}`,
642
+ `transient ${gb(r.transientBytes).padStart(9)}`,
643
+ `total ${gb(r.totalBytes).padStart(9)} ${style.dim(`of ${gb(r.usableBytes)} usable`)} ${r.fits ? style.green(style.bold("FITS")) : style.bold("DOES NOT FIT")}`,
644
+ "",
645
+ `max safe context ${style.bold(r.maxSafeContext.toLocaleString())} tokens`,
646
+ `predicted decode ${gradient(`${r.predictedDecodeTps.toFixed(1)} tok/s`)}`,
647
+ ]);
648
+ if (flag("skus")) {
649
+ h1("apple silicon matrix");
650
+ console.log();
651
+ table(
652
+ [
653
+ { header: "chip" }, { header: "ram", align: "right" },
654
+ { header: "fits", paint: (c) => (c.includes("fits") ? style.green(c) : style.dim(c)) },
655
+ { header: "max context", align: "right" }, { header: "decode", align: "right" },
656
+ ],
657
+ skuMatrix(config, m.sizeBytes, ctx, m.expertsBytes).map((row) => [
658
+ row.sku, `${row.ramGB} GB`, row.fits ? "fits" : "—",
659
+ row.fits ? row.maxContext.toLocaleString() : "—",
660
+ row.fits ? `~${row.decodeTps.toFixed(0)} tok/s` : "—",
661
+ ]),
662
+ );
663
+ }
664
+ console.log();
665
+ break;
666
+ }
667
+
668
+ case "serve": {
669
+ const { banner, step, box, style } = await import("./tui");
670
+ banner(pkg.version);
671
+ if (noSubcommand)
672
+ console.log(style.dim(" no command given — starting the server · mlx-bun --help for all commands"));
673
+ const rt = serverRuntimeFlags();
674
+ // Friendly collision check before loading gigabytes of weights.
675
+ {
676
+ const { probeServer } = await import("./harness-pi");
677
+ const running = await probeServer(`http://localhost:${rt.port}/v1`);
678
+ if (running) {
679
+ console.error(`port ${rt.port} is already serving ${running.map((m) => m.id).join(", ")}.`);
680
+ console.error("reuse it (mlx-bun pi attaches automatically), stop it, or pick --port <other>.");
681
+ console.error("NOTE: a second server is a second model in memory — check `mlx-bun fit` first.");
682
+ process.exit(1);
683
+ }
684
+ }
685
+ const { m, picked } = await resolveModelAuto(positional(0) ?? opt("query"));
686
+ const sFit = step("assessing fit");
687
+ const config = await loadModelConfig(m.path);
688
+ const report = fit(config, m.sizeBytes, 8192, undefined, undefined, m.expertsBytes);
689
+ sFit.done(`${style.bold(m.repoId)} ${style.dim(`· ${gb(m.sizeBytes)} · ~${report.predictedDecodeTps.toFixed(0)} tok/s predicted`)}${picked ? style.dim(" · auto-picked (override: mlx-bun serve <query>)") : ""}`);
690
+ const sNative = step("native runtime");
691
+ await ensureNative(sNative);
692
+ sNative.done("native runtime ready");
693
+ const { createServer, loadContext } = await import("./server");
694
+ if (flag("expert-offload")) {
695
+ if (m.expertsBytes === 0) {
696
+ console.error(style.dim("--expert-offload ignored: this model has no experts (dense)"));
697
+ } else {
698
+ const sOff = step("expert offload");
699
+ const { ensureOffloadFile } = await import("./expert-offload-build");
700
+ const { activateExpertOffload } = await import("./expert-offload");
701
+ // builds <model>/.mlx-bun-offload on first use (~one-time), then reuses
702
+ const dir = await ensureOffloadFile(m.path, (msg) => sOff.update(msg));
703
+ activateExpertOffload(dir);
704
+ sOff.done(`experts mmap'd ${style.dim(dir)} ${style.dim("· phys_footprint ≈ core")}`);
705
+ }
706
+ }
707
+ const sLoad = step("loading weights");
708
+ const t0 = performance.now();
709
+ const ctx = await loadContext(m.path, m.repoId, { memoryBudgetBytes: rt.serverOptions.memoryBudgetBytes });
710
+ const server = createServer(ctx, rt.port, { ...rt.serverOptions, owner: "serve" });
711
+ sLoad.done(`weights loaded ${style.dim(`in ${(performance.now() - t0).toFixed(0)} ms`)}`);
712
+ const shownHost = rt.serverOptions.hostname ?? "localhost";
713
+ console.log();
714
+ box([
715
+ `${style.green("●")} ${style.bold("serving")} ${m.repoId}`,
716
+ "",
717
+ `API ${style.url(`http://${shownHost}:${server.port}/v1`)} ${style.dim("(OpenAI · Anthropic · Responses)")}`,
718
+ `App ${style.url(`http://${shownHost}:${server.port}/`)} ${style.dim("(chat · quantize · finetune · dataset · status)")}`,
719
+ ...(rt.serverOptions.memoryBudgetBytes
720
+ ? [`Mem ${gb(rt.serverOptions.memoryBudgetBytes)} budget ${style.dim("(admission control on)")}`] : []),
721
+ `Perf ${style.dim(runtimeSummary(rt.serverOptions))}`,
722
+ "",
723
+ style.dim("agent session: mlx-bun pi stop: Ctrl+C"),
724
+ ]);
725
+ // Open the chat UI for interactive runs (--no-open, or a non-TTY such as
726
+ // a piped/headless/benchmark run, skips it). 0.0.0.0/:: bind → localhost
727
+ // for the browser. Reuses an existing tab on this host:port when possible
728
+ // (see openChatUi); the URL is printed above either way.
729
+ if (!flag("no-open") && process.stdout.isTTY) {
730
+ const browserHost = shownHost === "0.0.0.0" || shownHost === "::" ? "localhost" : shownHost;
731
+ openChatUi(`http://${browserHost}:${server.port}/#/chat`, `${browserHost}:${server.port}`);
732
+ }
733
+ break;
734
+ }
735
+
736
+ case "bench":
737
+ case "benchmark": {
738
+ const { banner, step, box, style } = await import("./tui");
739
+ banner(pkg.version);
740
+ serverRuntimeFlags(); // env levers (--compiled-decode, --perf-kernel, ...) apply to the run
741
+ const { m, picked } = await resolveModelAuto(positional(0) ?? opt("query"));
742
+ const tokens = Number(opt("tokens", "256"));
743
+ const runs = Number(opt("runs", "3"));
744
+ const promptTokens = Number(opt("prompt-tokens", "0"));
745
+ const kvMode = opt("kv-quant", "off")!;
746
+ console.log(` ${style.dim("measures THIS machine, OUR stack only — no other systems needed.")}`);
747
+ console.log(` ${style.dim("numbers on a loaded machine are not quotable; ./benchmark.sh is the preflight-gated harness.")}`);
748
+ const sNative = step("native runtime");
749
+ await ensureNative(sNative);
750
+ sNative.done("native runtime ready");
751
+ const sLoad = step(`loading ${m.repoId}${picked ? " (auto-picked)" : ""}`);
752
+ const { Weights } = await import("./weights");
753
+ const { createModel } = await import("./model/factory");
754
+ const { generate } = await import("./generate");
755
+ const { ChatTemplate } = await import("./chat-template");
756
+ const { loadTokenizer } = await import("./tokenizer");
757
+ const { peakMemory, resetPeakMemory } = await import("./mlx/ffi");
758
+ const config = await loadModelConfig(m.path);
759
+ const weights = await Weights.open(m.path);
760
+ const model = createModel(weights, config);
761
+ const tok = await loadTokenizer(m.path);
762
+ const template = await ChatTemplate.load(m.path);
763
+ sLoad.done(`${style.bold(m.repoId)} ${style.dim(`· ${gb(m.sizeBytes)}`)}`);
764
+
765
+ let userMsg = "Write a detailed essay about the history of computing, starting with mechanical calculators.";
766
+ if (promptTokens > 0) {
767
+ const filler = "Background context: the history of computation spans mechanical " +
768
+ "calculators, electromechanical relays, vacuum tubes, transistors, " +
769
+ "integrated circuits, and modern accelerators. ";
770
+ while (tok.encode(userMsg).length < promptTokens - 24) userMsg = filler + userMsg;
771
+ }
772
+ const rendered = template.render([{ role: "user", content: userMsg }]);
773
+ const ids = tok.encode(rendered);
774
+ const promptIds = ids[0] === ids[1] && ids[0] === tok.bosTokenId ? ids.slice(1) : ids;
775
+ const kvOptions = kvMode === "config"
776
+ ? (() => {
777
+ if (!config.kvQuant?.length) { console.error("model has no kv_config.json (--kv-quant config)"); process.exit(1); }
778
+ return { kvConfig: config.kvQuant };
779
+ })()
780
+ : kvMode !== "off" ? { kvBits: Number(kvMode), quantizedKvStart: 0 } : {};
781
+
782
+ // Warmup materializes weights so prefill timing measures prefill,
783
+ // not lazy page-in (same protocol as scripts/bench.ts).
784
+ const sWarm = step("warmup");
785
+ {
786
+ const wCache = model.makeCache();
787
+ const wGen = generate(model, promptIds.slice(0, Math.min(8, promptIds.length - 1)), {
788
+ maxTokens: 1, temperature: 0, cache: wCache,
789
+ });
790
+ for await (const _ of wGen) { /* discard */ }
791
+ for (const c of wCache) c.dispose();
792
+ }
793
+ resetPeakMemory();
794
+ sWarm.done("warmup complete");
795
+
796
+ const decodes: number[] = [];
797
+ const prefills: number[] = [];
798
+ let lastStats: { promptTokens: number; generatedTokens: number } = { promptTokens: 0, generatedTokens: 0 };
799
+ for (let i = 0; i < runs; i++) {
800
+ const sRun = step(`run ${i + 1}/${runs} · ${tokens} tokens`);
801
+ const gen = generate(model, promptIds, { maxTokens: tokens, temperature: 0, ...kvOptions });
802
+ for await (const _ of gen) { /* timing only */ }
803
+ const s = gen.stats!;
804
+ decodes.push(s.decodeTps);
805
+ prefills.push(s.prefillTps);
806
+ lastStats = s;
807
+ sRun.done(`run ${i + 1}/${runs} ${style.dim("·")} ${style.green(style.bold(`${s.decodeTps.toFixed(1)} tok/s`))} ${style.dim(`decode · prefill ${s.prefillTps.toFixed(0)} tok/s`)}`);
808
+ }
809
+ const median = (a: number[]) => [...a].sort((x, y) => x - y)[Math.floor(a.length / 2)]!;
810
+ const peak = peakMemory();
811
+
812
+ const { EvalDB: DB, gitCommit } = await import("./evaldb");
813
+ new DB().record({
814
+ modelPath: m.path,
815
+ commitSha: gitCommit() ?? undefined,
816
+ promptTokens: lastStats.promptTokens, cachedTokens: 0,
817
+ generatedTokens: lastStats.generatedTokens,
818
+ prefillTps: median(prefills), decodeTps: median(decodes),
819
+ peakBytes: peak,
820
+ notes: `cli-bench median-of-${runs} ${tokens}tok kv=${kvMode}${promptTokens ? ` ctx=${lastStats.promptTokens}` : ""} decode[${decodes.map((d) => d.toFixed(1)).join(",")}]`,
821
+ });
822
+ console.log();
823
+ box([
824
+ `${style.green("●")} ${style.bold("benchmark complete")} ${style.dim(`· ${m.repoId}`)}`,
825
+ "",
826
+ `decode ${style.green(style.bold(`${median(decodes).toFixed(1)} tok/s`))} ${style.dim(`median of ${runs} · [${decodes.map((d) => d.toFixed(1)).join(", ")}]`)}`,
827
+ `prefill ${style.bold(`${median(prefills).toFixed(0)} tok/s`)} ${style.dim(`· ${lastStats.promptTokens} prompt tokens`)}`,
828
+ `peak mem ${style.bold(gb(peak))} ${style.dim("(generation only)")}`,
829
+ `levers ${style.dim(runtimeSummary({ kvQuant: kvMode === "off" ? "off" : kvMode === "config" ? undefined : Number(kvMode) }))}`,
830
+ "",
831
+ style.dim("recorded to the eval DB — see mlx-bun evals"),
832
+ ]);
833
+ break;
834
+ }
835
+
836
+ case "evals": {
837
+ if (flag("clear")) {
838
+ // Eval rows are real data — archive, never destroy.
839
+ const { DEFAULT_EVAL_DB } = await import("./evaldb");
840
+ const { existsSync, renameSync } = await import("node:fs");
841
+ if (!existsSync(DEFAULT_EVAL_DB)) { console.log("no eval DB to clear"); break; }
842
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
843
+ const backup = DEFAULT_EVAL_DB.replace(/\.sqlite$/, `-${stamp}.sqlite`);
844
+ renameSync(DEFAULT_EVAL_DB, backup);
845
+ console.log(`eval DB archived to ${backup}`);
846
+ console.log("fresh DB starts with the next mlx-bun benchmark run");
847
+ break;
848
+ }
849
+ const db = new EvalDB();
850
+ const limit = Number(opt("limit", "20"));
851
+ const rows = db.recent(limit);
852
+ if (flag("raw")) {
853
+ for (const r of rows) console.log(JSON.stringify(r));
854
+ break;
855
+ }
856
+ const { style } = await import("./tui");
857
+ if (rows.length === 0) {
858
+ console.log("no eval runs recorded yet — ./benchmark.sh writes them");
859
+ break;
860
+ }
861
+ const ago = (ts: number) => {
862
+ const s = (Date.now() - ts) / 1000;
863
+ if (s < 90) return `${Math.round(s)}s ago`;
864
+ if (s < 5400) return `${Math.round(s / 60)}m ago`;
865
+ if (s < 129600) return `${Math.round(s / 3600)}h ago`;
866
+ return `${Math.round(s / 86400)}d ago`;
867
+ };
868
+ const modelName = (p: string) =>
869
+ /models--[^/]+--([^/]+)/.exec(p)?.[1] ?? p.split("/").at(-1) ?? p;
870
+ const note = (r: Record<string, unknown>, key: string) =>
871
+ new RegExp(`${key}=([\\w.]+)`).exec((r.notes as string) ?? "")?.[1] ?? "";
872
+ const BENCH_LABELS: Record<string, string> = {
873
+ "h2h-server": "server e2e",
874
+ "h2h-direct": "direct decode",
875
+ "bench.ts": "decode script",
876
+ "cli-bench": "cli benchmark",
877
+ };
878
+ const table = rows.map((r) => ({
879
+ when: ago(r.ts as number),
880
+ model: modelName(r.model_path as string).replace(/-OptiQ-4bit$/, "").replace(/^gemma-4-/, "g4-"),
881
+ bench: BENCH_LABELS[((r.notes as string) ?? "").split(" ")[0] ?? ""] ?? ((r.notes as string) ?? "").split(" ")[0] ?? "",
882
+ kv: note(r, "kv"),
883
+ stack: (r.stack as string) ?? "mlx-bun",
884
+ prefill: (r.prefill_tps as number) > 0 ? `${(r.prefill_tps as number).toFixed(0)}` : "",
885
+ decode: (r.decode_tps as number).toFixed(1),
886
+ ttft: note(r, "ttft_ms") ? `${note(r, "ttft_ms")}ms` : "",
887
+ peak: gb(r.peak_bytes as number),
888
+ commit: (r.commit_sha as string) ?? "",
889
+ }));
890
+ type Row = (typeof table)[number];
891
+ const cols: Array<[string, keyof Row, "left" | "right"]> = [
892
+ ["WHEN", "when", "right"], ["STACK", "stack", "left"], ["MODEL", "model", "left"],
893
+ ["BENCH", "bench", "left"], ["KV", "kv", "left"],
894
+ ["PREFILL", "prefill", "right"], ["DECODE", "decode", "right"], ["TTFT", "ttft", "right"],
895
+ ["PEAK", "peak", "right"], ["COMMIT", "commit", "left"],
896
+ ];
897
+ const widths = cols.map(([h, k]) => Math.max(h.length, ...table.map((t) => t[k].length)));
898
+ console.log();
899
+ console.log(" " + cols.map(([h], i) => style.dim(h.padEnd(widths[i]!))).join(" "));
900
+ for (const t of table) {
901
+ console.log(" " + cols.map(([, k, align], i) => {
902
+ const cell = align === "right" ? t[k].padStart(widths[i]!) : t[k].padEnd(widths[i]!);
903
+ if (k === "decode") return style.green(style.bold(cell));
904
+ if (k === "model") return style.bold(cell);
905
+ if (k === "stack") return cell.trimEnd() === "mlx-bun" ? style.accent(cell) : style.dim(cell);
906
+ if (k === "commit" || k === "when") return style.dim(cell);
907
+ return cell;
908
+ }).join(" "));
909
+ }
910
+ console.log();
911
+ console.log(style.dim(" STACK engine that produced the number (mlx-bun = us; mlx-lm/optiq = Python references)"));
912
+ console.log(style.dim(" BENCH server e2e = full HTTP round-trip · direct decode = engine only, no server"));
913
+ console.log(style.dim(" decode script / cli benchmark = single-stack decode measurement"));
914
+ console.log(style.dim(" KV KV-cache quantization (off = bf16, config = per-layer kv_config.json)"));
915
+ console.log(style.dim(" PREFILL prompt ingestion tok/s (in-process benches) · DECODE generation tok/s"));
916
+ console.log(style.dim(" TTFT request → first token, incl. server overhead (server benches)"));
917
+ console.log(style.dim(" PEAK max memory during generation"));
918
+ console.log();
919
+ console.log(style.dim(` ${rows.length} run(s) · --limit <n> for more · --raw for full records · mlx-bun benchmark to add a run`));
920
+ break;
921
+ }
922
+
923
+ case "pi": {
924
+ // mlx-bun consumes its own flags; the rest describe how the built-in
925
+ // agent runs. `mlx-bun pi` always drives pi's own TUI in-process
926
+ // (src/pi-terminal.ts) — pi is bundled, nothing to install. Users who
927
+ // already run their own pi connect it with `mlx-bun harness pi`.
928
+ const OURS_VAL = new Set([
929
+ "--query", "-q", "--port", "--host", "--memory-budget", "--prompt-cache", "--kv-quant",
930
+ "--compiled-decode", "--perf-kernel", "--fused-decode", "--fused-sdpa", "--thinking",
931
+ "--temperature", "--top-p", "--top-k",
932
+ ]);
933
+ const OURS_BOOL = new Set(["--force-wire", "--expert-offload"]);
934
+ const passthrough: string[] = [];
935
+ for (let i = 1; i < argv.length; i++) {
936
+ const a = argv[i]!;
937
+ if (OURS_VAL.has(a)) { i++; continue; }
938
+ if (OURS_BOOL.has(a)) continue;
939
+ passthrough.push(a);
940
+ }
941
+
942
+ const { parsePiArgs } = await import("./pi-terminal");
943
+ const stdinPiped = !process.stdin.isTTY;
944
+ const parsed = parsePiArgs(passthrough, stdinPiped);
945
+
946
+ // Headless machine-readable runs (`-p`, `--mode json`, `--mode rpc`) must
947
+ // keep stdout pristine for the response/JSONL stream — route our bring-up
948
+ // logging to stderr. Interactive keeps the pretty banner on stdout.
949
+ const quiet = parsed.mode !== "interactive";
950
+ const origStdoutWrite = process.stdout.write.bind(process.stdout);
951
+ if (quiet) {
952
+ (process.stdout as unknown as { write: typeof process.stderr.write }).write =
953
+ process.stderr.write.bind(process.stderr);
954
+ }
955
+ const restoreStdout = () => {
956
+ if (quiet) (process.stdout as unknown as { write: typeof origStdoutWrite }).write = origStdoutWrite;
957
+ };
958
+
959
+ const rt = serverRuntimeFlags();
960
+ const port = rt.port;
961
+ const baseUrl = `http://localhost:${port}/v1`;
962
+ const { probeServer } = await import("./harness-pi");
963
+ let models = await probeServer(baseUrl);
964
+ let startedServer = false;
965
+ if (models) {
966
+ console.log(`reusing running server at ${baseUrl} (${models.map((m) => m.id).join(", ")})`);
967
+ // Server-shaping flags can't apply to a server we didn't start —
968
+ // drop them loudly rather than silently or fatally.
969
+ const ignored = [...OURS_VAL, ...OURS_BOOL].filter((f) => f !== "--port" && argv.includes(f));
970
+ if (ignored.length > 0) {
971
+ const { style } = await import("./tui");
972
+ console.log(style.dim(` ignoring ${ignored.join(", ")} — the running server keeps its own configuration`));
973
+ console.log(style.dim(" (stop it or use --port <other> to start a fresh server with these flags)"));
974
+ }
975
+ try {
976
+ const stats = await (await fetch(`http://localhost:${port}/stats`)).json() as
977
+ { server?: { owner?: string } };
978
+ if (stats.server?.owner === "pi-session") {
979
+ console.log("note: that server belongs to another `mlx-bun pi` session and ends when it does —");
980
+ console.log(" run `mlx-bun serve` for a persistent server.");
981
+ }
982
+ } catch {}
983
+ } else {
984
+ const { banner, step, style } = await import("./tui");
985
+ banner(pkg.version);
986
+ const { m, picked } = await resolveModelAuto(opt("query") ?? optShort("q"));
987
+ if (picked)
988
+ console.log(` ${style.dim(`auto-picked ${m.repoId} (largest supported model that fits; override with --query)`)}`);
989
+ const sNative = step("native runtime");
990
+ await ensureNative(sNative);
991
+ sNative.done("native runtime ready");
992
+ const { createServer, loadContext } = await import("./server");
993
+ const sLoad = step(`loading ${m.repoId}`);
994
+ const t0 = performance.now();
995
+ const ctx = await loadContext(m.path, m.repoId, { memoryBudgetBytes: rt.serverOptions.memoryBudgetBytes });
996
+ createServer(ctx, port, { ...rt.serverOptions, owner: "pi-session" });
997
+ startedServer = true;
998
+ sLoad.done(`serving ${style.bold(m.repoId)} ${style.dim(`at ${baseUrl} · ready in ${(performance.now() - t0).toFixed(0)} ms`)}`);
999
+ models = await probeServer(baseUrl);
1000
+ if (!models) { restoreStdout(); console.error("server started but /v1/models probe failed"); process.exit(1); }
1001
+ }
1002
+ const exitNote = () => {
1003
+ // The server we started lives exactly as long as the pi session — any
1004
+ // exit tears it down. A standalone server belongs to `mlx-bun serve`
1005
+ // (which we reuse and never stop).
1006
+ if (startedServer && parsed.mode === "interactive")
1007
+ console.log("pi exited — shutting down (use `mlx-bun serve` for a persistent server)");
1008
+ };
1009
+
1010
+ // pi's own TUI embedded in-process — nothing to install.
1011
+ const { runEmbeddedPi } = await import("./pi-terminal");
1012
+ let message = parsed.message;
1013
+ if (parsed.mode === "print" && stdinPiped) {
1014
+ const piped = (await Bun.stdin.text()).trim();
1015
+ if (piped) message = message ? `${message}\n\n${piped}` : piped;
1016
+ }
1017
+ if (parsed.mode === "interactive")
1018
+ console.log(`launching mlx-bun pi (model ${models[0]!.id}) — /help for commands, double-Ctrl+C to exit`);
1019
+ restoreStdout();
1020
+ const code = await runEmbeddedPi({
1021
+ baseUrl,
1022
+ modelLabel: models[0]!.id,
1023
+ contextWindow: models[0]!.contextWindow,
1024
+ reasoning: models[0]!.reasoning,
1025
+ mode: parsed.mode,
1026
+ printFormat: parsed.printFormat,
1027
+ initialMessage: message,
1028
+ });
1029
+ exitNote();
1030
+ process.exit(code);
1031
+ }
1032
+
1033
+ case "harness": {
1034
+ const target = positional(0);
1035
+ if (target !== "pi") {
1036
+ console.error("usage: mlx-bun harness pi [--base-url <url>] [--remove]");
1037
+ process.exit(1);
1038
+ }
1039
+ const { detectPi, installPiExtension, removePiExtension, DEFAULT_BASE_URL } =
1040
+ await import("./harness-pi");
1041
+ if (flag("remove")) {
1042
+ console.log(removePiExtension() ? "mlx-bun provider extension removed" : "nothing to remove");
1043
+ break;
1044
+ }
1045
+ const pi = detectPi();
1046
+ if (!pi.found) {
1047
+ console.error("pi not found on PATH. Install it first:");
1048
+ console.error(" curl -fsSL https://pi.dev/install.sh | sh");
1049
+ console.error("then re-run: mlx-bun harness pi");
1050
+ process.exit(1);
1051
+ }
1052
+ const { step, box, style } = await import("./tui");
1053
+ const sPi = step("detecting pi");
1054
+ sPi.done(`pi ${style.dim(`${pi.binPath}${pi.version ? ` · v${pi.version}` : ""}`)}`);
1055
+ const baseUrl = opt("base-url", DEFAULT_BASE_URL)!;
1056
+ const sExt = step("installing provider extension");
1057
+ const result = await installPiExtension(baseUrl);
1058
+ sExt.done(`provider extension installed ${style.dim(`· ${result.path}`)}`);
1059
+ const sProbe = step("probing server");
1060
+ if (result.serverReachable) sProbe.done(`server live ${style.dim(`· ${result.bakedModels.join(", ")}`)}`);
1061
+ else sProbe.done(`server not running ${style.dim(`· models discovered when pi starts against ${baseUrl}`)}`);
1062
+ console.log();
1063
+ box([
1064
+ `${style.green("●")} ${style.bold("your pi is connected to mlx-bun")}`,
1065
+ "",
1066
+ `launch ${style.accent("pi --provider mlx-bun")}`,
1067
+ `select ${style.dim("/model inside pi · or scope cycling:")} ${style.accent('pi --models "mlx-bun/*"')}`,
1068
+ `undo ${style.dim("mlx-bun harness pi --remove")}`,
1069
+ ]);
1070
+ break;
1071
+ }
1072
+
1073
+ default:
1074
+ printHelp(cmd);
1075
+ }