mlx-bun 0.0.8 → 0.0.10

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 (144) hide show
  1. package/README.md +112 -20
  2. package/docs/design/batching-perf-path.md +232 -0
  3. package/docs/design/batching-v2-plan.md +515 -0
  4. package/docs/design/bucketing-stage.md +196 -0
  5. package/docs/design/compat-cli-surface-design.md +11 -11
  6. package/docs/design/docs-reorg-plan.md +1 -1
  7. package/docs/design/dreaming-nightly-pipeline.md +291 -0
  8. package/docs/design/dspark-speculative-decoding.md +106 -0
  9. package/docs/design/generic-model-support.md +381 -0
  10. package/docs/design/memory-inference-path.md +422 -0
  11. package/docs/design/memory-synthesis.md +287 -0
  12. package/docs/design/minicpm5-decode-megakernel.md +484 -0
  13. package/docs/design/mlx-lm-tool-parity-plan.md +535 -0
  14. package/docs/design/omlx-adoption-map.md +90 -0
  15. package/docs/design/orpo-dynamic-lambda.md +41 -0
  16. package/docs/design/orpo-training.md +35 -5
  17. package/docs/design/parallel-slots.md +12 -1
  18. package/docs/design/parity-tier-dag.md +13 -5
  19. package/docs/design/segmented-backward-training.md +15 -0
  20. package/docs/design/spec-decode-larger-targets.md +229 -0
  21. package/docs/design/ssd-kv-cold-tier.md +158 -0
  22. package/docs/design/tauri-desktop-app.md +2 -2
  23. package/docs/design/the-dreaming-handoff.md +141 -0
  24. package/docs/design/the-dreaming-master-plan.md +446 -0
  25. package/docs/design/write-pipeline-entity.md +468 -0
  26. package/docs/investigations/curve-bisector-routing.md +1 -1
  27. package/docs/investigations/decode-roofline-lookagain.md +348 -0
  28. package/docs/investigations/dspark-handoff.md +58 -0
  29. package/docs/investigations/kernel-perf-review-2026-07.md +171 -0
  30. package/docs/planning/PRODUCT_ROADMAP.md +2 -2
  31. package/docs/planning/curve-sampler-research-plan.md +331 -0
  32. package/docs/planning/memory-docs-and-dag-plan.md +185 -0
  33. package/docs/planning/pi-integration-review.md +178 -0
  34. package/docs/planning/release-notes-v0.0.10.md +57 -0
  35. package/docs/planning/release-notes-v0.0.9.md +122 -0
  36. package/docs/planning/web-ui-pass-plan.md +213 -0
  37. package/docs/planning/website-readme-pass-plan.md +224 -0
  38. package/docs/reference/cli.md +255 -0
  39. package/docs/reference/embedding.md +7 -2
  40. package/docs/reference/library-api.md +22 -3
  41. package/docs/reference/memory.md +5 -5
  42. package/docs/reference/models.md +137 -0
  43. package/docs/reference/orpo-quickstart.md +6 -0
  44. package/docs/reference/server-api.md +187 -9
  45. package/docs/reference/server-config.md +43 -9
  46. package/docs/reference/training.md +31 -12
  47. package/package.json +1 -1
  48. package/src/anthropic.ts +22 -0
  49. package/src/assets/curve-designer.html +1 -1
  50. package/src/chat-template.ts +25 -7
  51. package/src/cli.ts +1077 -90
  52. package/src/config.ts +4 -1
  53. package/src/download.ts +99 -8
  54. package/src/embed.ts +77 -0
  55. package/src/eval/perplexity.ts +164 -0
  56. package/src/eval/runner.ts +15 -6
  57. package/src/eval/tasks/bfcl.ts +1 -1
  58. package/src/generate.ts +149 -18
  59. package/src/harness-pi.ts +43 -17
  60. package/src/index.ts +2 -0
  61. package/src/kv-store.ts +240 -98
  62. package/src/lora.ts +2 -2
  63. package/src/memory/article.ts +558 -0
  64. package/src/memory/chunk-validation.ts +150 -0
  65. package/src/memory/chunk.ts +305 -12
  66. package/src/memory/cluster.ts +344 -17
  67. package/src/memory/coverage.ts +134 -0
  68. package/src/memory/crosslink.ts +0 -0
  69. package/src/memory/db.ts +184 -45
  70. package/src/memory/entity.ts +203 -0
  71. package/src/memory/gate.ts +187 -0
  72. package/src/memory/history.ts +152 -0
  73. package/src/memory/ingest.ts +324 -11
  74. package/src/memory/model.ts +321 -0
  75. package/src/memory/normalize.ts +671 -0
  76. package/src/memory/parse.ts +22 -0
  77. package/src/memory/pipeline.ts +242 -30
  78. package/src/memory/prompts.ts +42 -0
  79. package/src/memory/query.ts +334 -0
  80. package/src/memory/reconcile.ts +734 -0
  81. package/src/memory/redirect.ts +108 -0
  82. package/src/memory/reindex.ts +325 -0
  83. package/src/memory/resolve.ts +307 -0
  84. package/src/memory/route.ts +272 -0
  85. package/src/memory/schedule.ts +6 -4
  86. package/src/memory/stages.ts +774 -0
  87. package/src/memory/synthesize.ts +1629 -20
  88. package/src/memory/tools.ts +446 -96
  89. package/src/memory/vault.ts +462 -35
  90. package/src/memory/wikify.ts +1057 -16
  91. package/src/mlx/array.ts +21 -0
  92. package/src/mlx/ffi.ts +3 -0
  93. package/src/mlx/ops.ts +72 -0
  94. package/src/model/compiled-decode.ts +43 -8
  95. package/src/model/factory.ts +29 -8
  96. package/src/model/flash-attention.ts +4 -2
  97. package/src/model/fused-decode-kernel.ts +14 -7
  98. package/src/model/fused-mlp-kernel.ts +169 -0
  99. package/src/model/fused-swiglu-kernel.ts +240 -0
  100. package/src/model/gemma4-base.ts +96 -7
  101. package/src/model/gemma4.ts +33 -2
  102. package/src/model/minicpm5.ts +106 -8
  103. package/src/model/qwen3-delta.ts +48 -1
  104. package/src/model/qwen3.ts +276 -0
  105. package/src/model/qwen3_5.ts +11 -2
  106. package/src/model/steel-linear-kernel.ts +96 -0
  107. package/src/model/support.ts +38 -8
  108. package/src/model/universal/archs.ts +332 -0
  109. package/src/model/universal/dense.ts +576 -0
  110. package/src/model/universal/modules.ts +163 -0
  111. package/src/model/universal/rope.ts +366 -0
  112. package/src/pi-provider.ts +1 -1
  113. package/src/pi-terminal.ts +29 -8
  114. package/src/pi-web.ts +19 -2
  115. package/src/prompt-cache.ts +55 -9
  116. package/src/quantize/config-writer.ts +1 -1
  117. package/src/registry.ts +219 -8
  118. package/src/responses.ts +23 -0
  119. package/src/sampler.ts +220 -9
  120. package/src/serve/batch-scheduler.ts +307 -83
  121. package/src/serve/generation-gateway.ts +156 -29
  122. package/src/server.ts +746 -73
  123. package/src/spec/drafter.ts +81 -49
  124. package/src/spec/dspark/data-dflash.ts +156 -0
  125. package/src/spec/dspark/data.ts +201 -0
  126. package/src/spec/dspark/generate-dflash.ts +193 -0
  127. package/src/spec/dspark/generate.ts +290 -0
  128. package/src/spec/dspark/loss.ts +213 -0
  129. package/src/spec/dspark/module-dflash.ts +361 -0
  130. package/src/spec/dspark/module.ts +540 -0
  131. package/src/spec/dspark/sample.ts +112 -0
  132. package/src/spec/generate.ts +40 -19
  133. package/src/ssd-cache.ts +213 -0
  134. package/src/status-page.html +1 -1
  135. package/src/tokenizer.ts +7 -0
  136. package/src/train/flash-cce.ts +81 -23
  137. package/src/train/fuse.ts +243 -0
  138. package/src/train/index.ts +3 -0
  139. package/src/train/job.ts +8 -0
  140. package/src/train/loss.ts +341 -40
  141. package/src/train/prefix-shared.ts +62 -11
  142. package/src/train/segmented.ts +215 -55
  143. package/src/train/trainer.ts +112 -38
  144. package/src/web/skills/memory/SKILL.md +40 -17
@@ -0,0 +1,137 @@
1
+ # Model management: get / scan / ls / gc
2
+
3
+ How mlx-bun downloads, indexes, lists, and reclaims models. The store is
4
+ the standard Hugging Face hub cache — nothing proprietary; `hf` and
5
+ mlx-lm read/write the same tree.
6
+
7
+ ## The cache layout (and why duplicates happen)
8
+
9
+ Default root: `~/.cache/huggingface/hub` (override with `HF_HUB_CACHE`,
10
+ or `HF_HOME` — the root becomes `$HF_HOME/hub`). Per repo:
11
+
12
+ ```
13
+ models--<org>--<name>/
14
+ blobs/<digest> # verified content, one file per blob
15
+ snapshots/<commit>/<file> → ../../blobs/<digest> # symlink farm
16
+ refs/<revision> # e.g. refs/main = the commit sha
17
+ ```
18
+
19
+ **Snapshot-per-commit semantics:** every revision you download gets its
20
+ own `snapshots/<commit>` directory, and nothing ever deletes old ones.
21
+ When upstream pushes a new commit and you `get` again, you gain a new
22
+ snapshot; the previous one — and any blobs only it references, i.e. the
23
+ old weights — stays on disk forever. That is why one repo can appear
24
+ several times in a raw snapshot listing, and how tens of GB of dead
25
+ blobs accumulate. `refs/main` always names the current (canonical)
26
+ snapshot; the registry and every query verb resolve through it.
27
+
28
+ ## get — download (resumable, verified)
29
+
30
+ ```
31
+ mlx-bun get <org/repo> [--revision main]
32
+ mlx-bun get <substring> # no "/" = registry query, re-gets the match
33
+ ```
34
+
35
+ - Plain HTTPS resolve/CDN (no Xet), sequential files, `Range`-resume of
36
+ partial blobs (`<blob>.incomplete`). Auth: `HF_TOKEN` env, then the
37
+ token `hf auth login` writes. 401/403 answers get a "gated repo" hint.
38
+ - A substring argument (no `/`) resolves against already-downloaded
39
+ repos — `mlx-bun get 12B` refreshes the 12B to upstream's latest. An
40
+ unknown substring errors with a pointer to `mlx-bun ls <q>`.
41
+ - Concurrent-writer safety: an `O_EXCL` lockfile (`<blob>.lock`, pid +
42
+ timestamp) serializes two processes downloading the same blob (e.g. a
43
+ foreground `get` racing the server's background auto-download). Locks
44
+ from dead pids, or older than ~1 h, are stolen; a live lock fails fast
45
+ with "another download … is in progress".
46
+ - If the download created a new snapshot (upstream pushed), `get` says
47
+ so and estimates what `mlx-bun gc` would reclaim.
48
+
49
+ ### Verification story
50
+
51
+ Every blob is checksummed **while streaming** (a resume re-hashes the
52
+ existing prefix in chunks — no whole-file allocation):
53
+
54
+ - **LFS files** (weights): sha256 must equal the API's `lfs.oid` — which
55
+ is also the blob's filename.
56
+ - **Small files** (configs, tokenizer): git blob identity,
57
+ `sha1("blob <size>\0" + content)`, must equal the API's `blobId`.
58
+
59
+ A mismatch deletes the partial (never resume corrupt bytes); a short
60
+ read keeps the `.incomplete` for resume.
61
+
62
+ ## scan + the registry
63
+
64
+ `mlx-bun scan` walks `models--*/snapshots/*` reading only `config.json`
65
+ + safetensors **headers** (never tensor bytes) into
66
+ `~/.cache/mlx-bun/registry.sqlite`. The registry is a derived cache: on
67
+ schema drift it rebuilds; rows whose snapshot dir vanished are reaped.
68
+
69
+ Schema (table `models`, one row **per snapshot**, keyed on `path`):
70
+
71
+ | column | meaning |
72
+ | --- | --- |
73
+ | `path` (PK) | `<hub>/models--…/snapshots/<commit>` |
74
+ | `repo_id`, `model_type` | identity |
75
+ | `param_count`, `num_layers`, `hidden_size`, `vocab_size` | shape |
76
+ | `size_bytes` | language-model weights (sidecar excluded) |
77
+ | `sidecar_bytes` | `optiq_vision.safetensors` (bf16 SigLIP sidecar) |
78
+ | `experts_bytes` | `.experts.` tensor bytes (MoE fit math) |
79
+ | `quant_bits`, `quant_group_size`, `quant_mode` | quantization |
80
+ | `has_vision_sidecar` | sidecar file present |
81
+ | `vision_config_type` | `config.json`'s `vision_config.model_type` when it names a vision tower (`*_vision`) |
82
+ | `has_kv_config`, `has_tool_template`, `license` | capabilities/terms |
83
+ | `scanned_at` | freshness (canonical tie-break) |
84
+
85
+ **Vision capability** is `has_vision_sidecar OR vision_config_type =
86
+ *unified_vision`: the SigLIP models (e2b/e4b/26B/31B,
87
+ `gemma4_vision`) need the bf16 sidecar; the unified 12B
88
+ (`gemma4_unified_vision`) declares its encoder-free tower in config.
89
+ Presence of a `vision_config` key alone is **not** a signal — Qwen3.5
90
+ nests a copy of its own text config there.
91
+
92
+ ## Query resolution rules (per verb)
93
+
94
+ Every verb resolves fuzzy queries the same way, with revision collapsing
95
+ so a stale snapshot never makes a repo "ambiguous":
96
+
97
+ - **`ls [query]`** — substring match on repo id / model type; shows **one
98
+ row per repo** (the canonical revision). `--all-revisions` shows the
99
+ per-snapshot truth with the canonical marked `*`. Capabilities column
100
+ labels the support tier: `supported (targeted)` (dedicated forward,
101
+ L2/L3 paths) vs `supported (generic)` (Tier-0 universal module).
102
+ - **`fit` / `serve` / `train` / … (single-model verbs)** — `resolve()`:
103
+ the query must match exactly one **repo**; multiple cached revisions of
104
+ that repo collapse to the canonical snapshot (refs/main, else most
105
+ recently scanned). Drafter companions (`*_assistant`) never count.
106
+ - **`get <substring>`** — same `resolve()`, then re-downloads that repo id.
107
+
108
+ Canonical = the snapshot `refs/main` points at; if no `refs/main`, the
109
+ most recently scanned snapshot (stable path tie-break).
110
+
111
+ ## gc — reclaim superseded snapshots + dead blobs
112
+
113
+ ```
114
+ mlx-bun gc # plan + per-repo reclaim summary, deletes nothing
115
+ mlx-bun gc --yes # actually delete (destructive)
116
+ mlx-bun gc --dry-run # never delete, even with --yes
117
+ mlx-bun gc --yes --force # also prune warned snapshots (see below)
118
+ ```
119
+
120
+ Per repo: keep every snapshot a `refs/*` file points at, delete the
121
+ rest, then delete blobs no surviving snapshot symlinks to. Prints
122
+ keep/prune/skip counts and reclaimable bytes per repo.
123
+
124
+ Safety rails:
125
+
126
+ - **Deletion requires `--yes`.** The default run is a report.
127
+ - **File-loss tripwire:** a superseded snapshot containing files the
128
+ kept snapshots *lack* is skipped with a warning naming those files —
129
+ deleting it would delete the machine's only copy (live example: a
130
+ stale gemma-4-12B snapshot holds an `optiq_vision.safetensors` that
131
+ the canonical revision dropped). `--force` overrides, deliberately.
132
+ - Repos with no usable refs (no `refs/`, or refs naming a missing
133
+ snapshot) are left untouched.
134
+ - `.incomplete` / `.lock` resume artifacts in `blobs/` are never touched.
135
+
136
+ After a real deletion, gc re-scans so the registry drops the reaped
137
+ rows immediately.
@@ -37,6 +37,11 @@ mlx-bun train e4b --data ./prefs --resume ~/.cache/mlx-bun/mlx-bun-finetunes/orp
37
37
  | `--rank` / `--scale` | orpo 16 / 2.0 | LoRA rank (by_bits scaled) / scale |
38
38
  | `--seq` | e4b 8192 · cpm 4096 | max sequence length |
39
39
  | `--lambda` | 0.1 | ORPO odds-ratio weight |
40
+ | `--sft-scope` | `full` | ORPO chosen-NLL scope (`sft_scope`): `full` = paper/TRL-faithful prompt+response CE · `response` = response-only, bit-exact to pre-2026-07 runs |
41
+ | `--grad-accum` | 1 | gradient accumulation steps (effective batch = batch × grad-accum at batch-size-1 memory) |
42
+ | `--grad-clip` | 1.0 | gradient-norm clip (0 = off) |
43
+ | `--seed` | 0 | data-shuffle / init seed |
44
+ | `--val-size` | 256 | max validation examples per eval |
40
45
  | `--seg` | 2 | layers/segment (segmented backward); `--no-segment` to disable |
41
46
  | `--save-every` | off | mountable checkpoint every N steps |
42
47
  | `--resume <dir>` | — | warm-start LoRA weights from a checkpoint/adapter |
@@ -65,6 +70,7 @@ MODEL=/path/to/snapshot DATA=/path/to/datadir bun scripts/train-orpo.ts
65
70
  | `LR` | 1e-5 | learning rate (cosine + short warmup) |
66
71
  | `RANK` / `SCALE` | 16 / 2.0 | LoRA rank (by_bits scaled) / scale |
67
72
  | `LAMBDA` | 0.1 | ORPO odds-ratio weight |
73
+ | `SFT_SCOPE` | `full` | chosen-NLL scope: `full` (paper/TRL-faithful, prompt+response) · `response` (pre-2026-07 response-only, reproduces old runs) |
68
74
  | `FLASH=0` | (on) | use the MLX fused head instead of the flash Metal head |
69
75
  | `PREFIX=0` | (on) | disable prefix-sharing (plain two-forward) |
70
76
  | `SEGOFF=1` | (off) | disable the segmented backward (more memory) |
@@ -11,7 +11,9 @@ For the full set of start flags (`--port`, `--memory-budget`,
11
11
  defaults, and the perf levers) and a compatibility matrix of which
12
12
  combinations compose, see [server-config.md](./server-config.md). This
13
13
  doc covers the request/response wire format. LoRA adapters are mounted at
14
- runtime via `POST /v1/adapters` (below), not a start flag.
14
+ runtime via `POST /v1/adapters` (below), or at startup with
15
+ `serve --adapter <dir>` (which also makes that adapter the default for
16
+ requests that don't select one).
15
17
 
16
18
  ## POST /v1/chat/completions
17
19
 
@@ -25,7 +27,25 @@ Request body (OpenAI chat schema; unknown fields ignored):
25
27
  "temperature": 0.7, // 0 = greedy
26
28
  "top_p": 0, "top_k": 0, // 0 = off
27
29
  "seed": 1234, // omit for time-derived
30
+ "min_p": 0.05, // min-p sampling (0 = off)
31
+ "xtc_probability": 0.5, // XTC sampling: chance per step of dropping
32
+ "xtc_threshold": 0.1, // every token above threshold except the
33
+ // least likely (EOS + newline exempt,
34
+ // matching mlx_lm.server)
35
+ "logit_bias": { "42": -5.0 }, // additive per-token-id logit bias
28
36
  "repetition_penalty": 1.1, // optional
37
+ "repetition_context_size": 20, // recent-token window (0 = whole history)
38
+ "presence_penalty": 0.0, // subtracted once if the token occurred
39
+ "presence_context_size": 20, // in the window (mlx-lm extension)
40
+ "frequency_penalty": 0.0, // subtracted per occurrence in the window
41
+ "frequency_context_size": 20, // (mlx-lm extension)
42
+ "logprobs": false, // BOOL (mlx_lm.server's type, even on
43
+ // /v1/completions): return each emitted
44
+ // token's logprob
45
+ "top_logprobs": -1, // int in [0, 11] or -1 = unset (mlx-lm's
46
+ // validation — its cap is 11, not
47
+ // OpenAI's 20); k > 0 returns the top-k
48
+ // (token, logprob) pairs per position
29
49
  "stop": "\n\n", // or ["###", "\n\n"] (spec: up to 4)
30
50
  "tools": [ /* OpenAI function tools */ ],
31
51
  "tool_choice": "auto", // "none" disables tools
@@ -88,6 +108,43 @@ Non-streaming response:
88
108
  }
89
109
  ```
90
110
 
111
+ ### logprobs / top_logprobs
112
+
113
+ With `"logprobs": true` and/or `"top_logprobs": k` (1–11), the
114
+ non-streaming response's `choices[0].logprobs` carries
115
+ **`mlx_lm.server`'s block, not OpenAI's** (server.py
116
+ `generate_response`): entries are keyed by token *id*, token strings
117
+ are the raw vocab pieces (`convert_ids_to_tokens` — e.g. `"▁Hello"`),
118
+ and there is no `bytes` field. The same block appears on
119
+ `/v1/completions` (mlx-lm shares the response builder; there is no
120
+ legacy `{tokens, token_logprobs}` text-completion shape).
121
+
122
+ ```jsonc
123
+ // top_logprobs: k > 0 (wins over logprobs when both are set — mlx-lm's if/elif)
124
+ "logprobs": { "content": [
125
+ { "id": 9906, "token": "▁Hello", "logprob": -0.02, // = top-1, merged from
126
+ "top_logprobs": [ // dict(top[0], top_logprobs=top)
127
+ { "id": 9906, "token": "▁Hello", "logprob": -0.02 },
128
+ { "id": 13347, "token": "▁Hi", "logprob": -4.1 } // … k entries, sorted desc
129
+ ] },
130
+ // … one entry per generated token (reasoning/tool tokens included)
131
+ ] }
132
+
133
+ // logprobs: true only
134
+ "logprobs": { "content": [ { "id": 9906, "logprob": -0.02 }, … ] }
135
+ ```
136
+
137
+ The distribution matches mlx-lm `generate_step` exactly: full-vocab
138
+ log-softmax of the logits **after** logits processors (logit_bias,
139
+ penalties), **before** the sampler's temperature/top-p/top-k/min-p/XTC
140
+ (mlx_lm/generate.py L409–422). mlx-lm's top-k order is unspecified
141
+ (argpartition); ours is sorted descending — the same set, so the entry
142
+ is deterministically the argmax. Stream chunks never carry logprobs
143
+ (mlx-lm's streaming responses don't either). Requests with logprobs
144
+ run on the serial lane under `--batch N` (like the other mlx-lm
145
+ sampler extensions). Invalid values are rejected with mlx-lm's exact
146
+ messages (see Errors).
147
+
91
148
  Streaming (`"stream": true`) is SSE: `data: <chunk>\n\n` per event,
92
149
  terminated by `data: "[DONE]"`. Chunks are `chat.completion.chunk`
93
150
  objects whose `choices[0].delta` carries `{role}`, then `{content}`
@@ -118,7 +175,12 @@ back to plain content.
118
175
  All errors are `{ "error": { "message": …, ... } }`.
119
176
 
120
177
  - `400` — malformed JSON, empty `messages`, unknown adapter id, vision
121
- request on a model without a sidecar, prompt build failures.
178
+ request on a model without a sidecar, prompt build failures,
179
+ non-numeric `logit_bias` keys/values (`logit_bias must be a dict of
180
+ int to float`, mlx-lm's coercion error), invalid logprobs params
181
+ (mlx-lm's exact validation: `logprobs must be of type bool`,
182
+ `top_logprobs must be of type int` / `at least 0` / `at most 11`;
183
+ `top_logprobs: -1` is the accepted "unset" sentinel).
122
184
  - `400` with `"type": "memory_admission"`, `"code":
123
185
  "context_over_budget"` — `prompt + max_tokens` exceeds the memory
124
186
  budget's max safe context (only when serving with `--memory-budget`;
@@ -126,11 +188,56 @@ All errors are `{ "error": { "message": …, ... } }`.
126
188
  up front). Lower `max_tokens` or shorten the prompt; the ceiling is
127
189
  visible at `/stats`.
128
190
 
191
+ ## POST /v1/completions (raw text completion)
192
+
193
+ `mlx_lm.server`'s text-completion endpoint: **no chat template** — the
194
+ `prompt` string is tokenized directly (the tokenizer's own BOS handling,
195
+ exactly mlx-lm's `tokenizer.encode(request.prompt)`) and the model
196
+ continues it. Same generation gateway, admission control, prompt cache,
197
+ and adapter selection as chat.
198
+
199
+ ```jsonc
200
+ {
201
+ "prompt": "Once upon a time", // REQUIRED, string only (token arrays
202
+ // rejected, matching mlx_lm.server)
203
+ "max_tokens": 512, // default 512 (mlx_lm.server's default) —
204
+ // NOT the chat lane's generous default
205
+ "stream": false,
206
+ "stop": "\n\n", // string or array, decoded-text matching
207
+ // plus every sampling/penalty field from /v1/chat/completions:
208
+ // temperature, top_p, top_k, seed, min_p, xtc_probability,
209
+ // xtc_threshold, logit_bias, repetition_penalty,
210
+ // repetition_context_size, presence_penalty, presence_context_size,
211
+ // frequency_penalty, frequency_context_size, adapter,
212
+ // logprobs (BOOL, mlx-lm's type — not OpenAI's legacy int),
213
+ // top_logprobs (0-11 or -1) — same response block as chat, see
214
+ // "logprobs / top_logprobs" above (mlx-lm shares the builder)
215
+ }
216
+ ```
217
+
218
+ Response is the OpenAI text-completion object:
219
+
220
+ ```jsonc
221
+ {
222
+ "id": "cmpl-…", "object": "text_completion", "created": 1760000000,
223
+ "model": "<loaded model id>",
224
+ "choices": [{ "index": 0, "text": "…", "finish_reason": "stop" | "length" }],
225
+ "usage": { "prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0,
226
+ "prompt_tokens_details": { "cached_tokens": 0 } }
227
+ }
228
+ ```
229
+
230
+ Streaming (`"stream": true`) is SSE: `text_completion` chunks whose
231
+ `choices[0].text` carries the delta; the final chunk carries
232
+ `finish_reason` and `usage` (always attached — mlx-lm gates usage behind
233
+ `stream_options.include_usage`; ours is an additive superset), then
234
+ `data: [DONE]`. There is no `echo` parameter (mlx_lm.server has none).
235
+
129
236
  ## POST /v1/messages (Anthropic Messages API)
130
237
 
131
238
  Anthropic-protocol surface over the same engine — on by default, like
132
239
  `optiq serve`. Point any Anthropic-SDK tool at the server
133
- (`ANTHROPIC_BASE_URL=http://localhost:8090`, any `x-api-key`) — Claude
240
+ (`ANTHROPIC_BASE_URL=http://localhost:8080`, any `x-api-key`) — Claude
134
241
  Code works as a client this way.
135
242
 
136
243
  - `system` (string or text blocks), `messages` with string or
@@ -141,7 +248,11 @@ Code works as a client this way.
141
248
  - `tools` (`{name, description, input_schema}`) map to function tools;
142
249
  server-tool types (web_search, …) are dropped silently.
143
250
  - `max_tokens`, `temperature`, `top_p`, `top_k`, `stop_sequences`,
144
- `stream` as in the Anthropic spec.
251
+ `stream` as in the Anthropic spec. The mlx-lm sampler/penalty
252
+ extensions (`min_p`, `xtc_probability`, `xtc_threshold`,
253
+ `repetition_penalty`, `presence_penalty`, `frequency_penalty` + their
254
+ `*_context_size` windows) pass through as extras; `logit_bias` does
255
+ not exist in this protocol and is not accepted here.
145
256
  - Response: `{id: "msg_…", type: "message", content: [{type: "text"} |
146
257
  {type: "tool_use"}…], stop_reason, usage: {input_tokens,
147
258
  output_tokens, cache_read_input_tokens}}` —
@@ -162,7 +273,11 @@ OpenAI SDK speak this now). Oracle: optiq responses shim.
162
273
  `function_call_output`), `instructions` (merged with any
163
274
  system/developer items into one leading system message),
164
275
  `max_output_tokens`, `temperature`, `top_p`, `top_k`, flat
165
- `tools`/`tool_choice` (built-in tool types dropped), `stream`.
276
+ `tools`/`tool_choice` (built-in tool types dropped), `stream`. The
277
+ mlx-lm sampler/penalty extensions (`min_p`, `xtc_probability`,
278
+ `xtc_threshold`, `logit_bias`, `repetition_penalty`,
279
+ `presence_penalty`, `frequency_penalty` + `*_context_size`) pass
280
+ through as extras.
166
281
  - **`previous_response_id` resumption**: pass a prior response id
167
282
  instead of resending the conversation; the server splices the stored
168
283
  input + output back in (instructions carry forward when omitted).
@@ -177,9 +292,46 @@ OpenAI SDK speak this now). Oracle: optiq responses shim.
177
292
  response.completed` (+ `response.function_call_arguments.delta/.done`
178
293
  for tool calls).
179
294
 
295
+ ## POST /v1/embeddings (OpenAI Embeddings API)
296
+
297
+ Text embeddings, available when the **served model is an embedding model**
298
+ (plain Qwen3 / Qwen3-Embedding) — consistent with the single-model server:
299
+ `mlx-bun serve mlx-community/Qwen3-Embedding-4B-4bit-DWQ`, then POST here. On
300
+ any other served model the route returns `400 invalid_request_error`.
301
+
302
+ - `input` (string or array of strings), `model` (ignored — the served model
303
+ is used), and a **non-standard** `instruction` (optional): applies
304
+ Qwen3-Embedding's query format `Instruct: {instruction}\nQuery:{text}`,
305
+ which steers *which* similarity axis the geometry reflects. Omit it for
306
+ plain document embeddings.
307
+ - Pooling is last-token + L2-normalization (the vectors are unit-length);
308
+ similarity is the dot product. Bit-exact vs the mlx-lm `qwen3` reference
309
+ (`tests/qwen3-embed-parity.test.ts`).
310
+ - Response: `{ "object": "list", "data": [{ "object": "embedding", "index":
311
+ 0, "embedding": [float…] }, …], "model": "<served id>", "usage": {
312
+ "prompt_tokens": N, "total_tokens": N } }`. Embedding is a single forward
313
+ (no decode loop), so it runs inline — not through the generation gateway.
314
+
315
+ ```sh
316
+ curl localhost:8080/v1/embeddings -H 'content-type: application/json' \
317
+ -d '{"input": ["the cat sat on the mat", "a kitten by the window"]}'
318
+ ```
319
+
180
320
  ## GET /v1/models
181
321
 
182
- `{ "object": "list", "data": [{ "id": "<model id>", "object": "model", … }] }`
322
+ `{ "object": "list", "data": [{ "id": "<model id>", "object": "model",
323
+ "created": <unix s>, … }] }`
324
+
325
+ The served model is FIRST (with extra capability fields:
326
+ `context_window`, `reasoning`, `vision`, `owned_by`), followed by every other
327
+ servable model the local registry knows (mlx-lm scans the HF cache
328
+ here; the registry is that scan, filtered to supported architectures).
329
+ `GET /v1/models/<id>` filters the list to that id — same list shape,
330
+ matching `mlx_lm.server`.
331
+
332
+ ## GET /health
333
+
334
+ `{"status": "ok"}` — byte-for-byte what `mlx_lm.server` returns.
183
335
 
184
336
  ## GET /stats
185
337
 
@@ -301,15 +453,37 @@ Select per request with the `adapter` body field. Prompt-cache entries
301
453
  are namespaced per adapter spec, so switching adapters never reuses
302
454
  another adapter's KV.
303
455
 
456
+ `serve --adapter <dir>` (alias `--adapter-path`, mlx_lm.server's
457
+ spelling) mounts an adapter at startup through this same machinery and
458
+ makes it the default for requests that send no `adapter` field; an
459
+ explicit `adapter` (including `"none"`) always wins, and hot-swap via
460
+ these endpoints is unchanged.
461
+
304
462
  ## Client setup: pi
305
463
 
306
- `~/.pi/agent/models.json`:
464
+ The supported one-command path:
465
+
466
+ ```bash
467
+ mlx-bun harness pi # install; then: pi --provider mlx-bun
468
+ mlx-bun harness pi --remove # undo
469
+ ```
470
+
471
+ It writes a self-contained extension to
472
+ `~/.pi/agent/extensions/mlx-bun-provider.ts` that registers the `mlx-bun`
473
+ provider with live discovery: at pi startup it fetches `/v1/models` from the
474
+ running server, so the stable `mlx-bun/local` model handle always resolves to
475
+ whatever the server is actually serving (context window, reasoning, and
476
+ vision capability included), and never goes stale across model swaps. The
477
+ model list baked at install time is only the fallback for when the server is
478
+ down.
479
+
480
+ Manual fallback — `~/.pi/agent/models.json`:
307
481
 
308
482
  ```jsonc
309
483
  {
310
484
  "providers": {
311
485
  "mlx-bun": {
312
- "baseUrl": "http://localhost:8090/v1",
486
+ "baseUrl": "http://127.0.0.1:8080/v1",
313
487
  "api": "openai-completions",
314
488
  "apiKey": "sk-anything-nonempty",
315
489
  "models": [{ "id": "<model id from /v1/models>" }]
@@ -318,5 +492,9 @@ another adapter's KV.
318
492
  }
319
493
  ```
320
494
 
321
- Any OpenAI SDK works the same way: `baseURL: "http://localhost:8090/v1"`,
495
+ Note the manual route bakes a concrete model id, which goes stale when you
496
+ serve a different model — the staleness `harness pi`'s `local` id exists to
497
+ solve.
498
+
499
+ Any OpenAI SDK works the same way: `baseURL: "http://127.0.0.1:8080/v1"`,
322
500
  any non-empty `apiKey`.
@@ -19,16 +19,21 @@ the design rationale is in
19
19
 
20
20
  | Flag | Arg | Default | What it does |
21
21
  | --- | --- | --- | --- |
22
- | `--host` | addr | all interfaces | Interface to bind. `127.0.0.1` = loopback only. |
23
- | `--port` | n | `8090` | Listen port. A pre-flight probe refuses to start if the port is already serving. |
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
+ | `--port` | n | `8080` | Listen port. A pre-flight probe refuses to start if the port is already serving. |
24
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⁹).** |
25
- | `--prompt-cache` | GB | `2` (≈2e9 bytes) | Byte cap for the prompt (prefix-reuse KV) cache. **Binary GiB (×2³⁰)** on input. `0`/unset ⇒ default; there is no flag to disable the cache outright. |
26
- | `--batch` | n | `1` (serial) | Max concurrent requests batched through the mlx-lm-parity engine. `>1` switches the **whole server** into bf16 continuous batching — a *mode*, not a load fallback. See [Execution modes](#execution-modes-serial-vs---batch-n). Alias: `--decode-concurrency` (mlx_lm.server drop-in). |
25
+ | `--prompt-cache` | GB | `2` (≈2e9 bytes) | Byte cap for the prompt (prefix-reuse KV) cache. **Binary GiB (×2³⁰)** on input. Unset ⇒ the 2 GB default; an explicit `--prompt-cache 0` **disables** the cache (maxBytes 0 evicts every entry). |
26
+ | `--ssd-cache` | dir | off | SSD cold tier under the prompt cache ([docs/design/ssd-kv-cold-tier.md](../design/ssd-kv-cold-tier.md)): prefix KV spills to disk on RAM eviction, is snapshotted after requests settle (debounced 1 s), and **survives restarts** — a long-context agent re-attach restores via zero-copy mmap instead of re-prefilling (measured 13.7k-token prefix: 12 s → 0.24 s TTFT; 0% decode overhead). Entries are keyed by model fingerprint **+ effective kv scheme** + tokenizer hash + adapter ns; incompatible/corrupt files self-quarantine. Serial lane only. Requires the RAM cache. |
27
+ | `--ssd-cache-max` | GB | `32` | SSD tier byte cap (binary GiB); oldest-mtime entries evicted at the cap. |
28
+ | `--ssd-cache-verify` | (bool) | off | Verify every tensor hash on restore. Reads all bytes eagerly (defeats lazy page fault-in) — integrity paranoia only; the header hash is always verified. |
29
+ | `--batch` | n | `1` (serial) | Max concurrent requests batched through the mlx-lm-parity engine. `>1` switches the **whole server** into bf16 continuous batching — a *mode*, not a load fallback. See [Execution modes](#execution-modes-serial-vs---batch-n). `--decode-concurrency` is accepted for drop-in compatibility, but the semantics differ: in mlx_lm.server it caps per-BatchGenerator decode parallelism (default 32); in mlx-bun it enables continuous batching with this cap (mlx-bun's default is the optimized serial path). |
27
30
  | `--kv-quant` | `config`\|`off`\|`4`\|`8` | `config` serial / `off`(bf16) under `--batch N` | KV-cache quantization. `config` = per-layer `kv_config.json` (optiq parity); `off` = bf16; `4`/`8` = uniform bits (group 64, start 0). Under `--batch N`, an explicit value routes those requests to the **serial** lane (batched is bf16-only). |
31
+ | `--adapter` | dir | none | Mount a LoRA adapter at startup (same machinery as `POST /v1/adapters`; the adapter id is the directory's basename) and make it the **default** for requests that send no `adapter` field. A request's explicit `adapter` — including `"none"` — always wins, and hot-swap via `/v1/adapters` is unchanged. `--adapter-path` is accepted as the mlx_lm.server-named alias. A bad adapter fails startup loudly rather than silently serving the base model. This is the flag `mlx-bun train`'s completion message points at. |
28
32
  | `--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). A request's `chat_template_kwargs.enable_thinking` overrides it. |
29
- | `--temperature` | n ∈ [0,5] | `generation_config.json` | Server-wide sampling default. Per-request `temperature` still wins; the browser chat (sends none) inherits this. |
33
+ | `--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. |
30
34
  | `--top-p` | n ∈ [0,1] | `generation_config.json` | Server-wide top-p default (per-request `top_p` wins). |
31
35
  | `--top-k` | n ∈ [0,1e6] | `generation_config.json` | Server-wide top-k default (per-request `top_k` wins). |
36
+ | `--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). |
32
37
  | `--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). |
33
38
  | `--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). |
34
39
  | `--hlg-width` | nats | `4` | HLG mid-region half-width (nats). Only meaningful with `--hlg-sampling on`. |
@@ -36,12 +41,18 @@ the design rationale is in
36
41
  | `--hlg-toe` | nats | `6` | HLG shadow rolloff scale (nats). Only meaningful with `--hlg-sampling on`. |
37
42
  | `--hlg-pivot-offset` | nats | `6` | HLG pivot point: nats below the top token. Only meaningful with `--hlg-sampling on`. |
38
43
  | `--expert-offload` | (bool) | off | **MoE models only.** Serve experts from a page-aligned file mmap (built on first use). Keeps the model out of memory pressure — physical footprint ≈ active params. Ignored with a warning on dense models. Bit-exact with the resident path. |
44
+ | `--l1` | (bool) | — | **Parity tier preset:** bit-for-bit IDENTICAL to mlx-lm (bf16 KV, unfused SDPA, no perf kernel). Sets the whole decode route; any explicit per-fork flag (`--kv-quant`/`--perf-kernel`/…) overrides the preset. See [docs/design/parity-tier-dag.md](../design/parity-tier-dag.md). |
45
+ | `--l2` | (bool) | — | **Parity tier preset:** bit-for-bit IDENTICAL to mlx-optiq (quantized KV per `kv_config.json` + fused N-tiled prefill SDPA + **stock unfused decode** — the composition the optiq goldens track, `scripts/regen-kvq-goldens.ts`). The perf kernel stays **off**: it is envelope-gated, not bit-exact (see `--l3`), and opting into it (`--l2 --perf-kernel on`) is an explicit choice that leaves the bare-tier guarantee. |
46
+ | `--l3` | (bool) | — | **Parity tier preset:** best performance, no bit-exact oracle (KL + test gated). On the decode path L3 = L2 + the envelope-gated perf kernel; L3 also owns the no-oracle features (HLG sampler, expert offload). No tier given ⇒ the per-flag defaults below, which equal `--l3`. |
39
47
  | `--compiled-decode` | on\|off | on | Replay the per-step decode graph in C++ (`MLX_BUN_COMPILED_DECODE`). Bit-exact A/B lever. **Serial lane only** (see note below). **Gemma4-dense only** — LoRA, MoE, and non-Gemma4 models (MiniCPM5 / Qwen3.5) run eager; an unsupported step falls back to eager for the rest of that generation. |
40
- | `--perf-kernel` | on\|off | **on** | Fused quantized-KV decode-SDPA Metal kernel (`MLX_BUN_PERF_KERNEL`), the perf side of the compat A/B. Engages on quantized caches at decode. **Serial lane only.** |
48
+ | `--perf-kernel` | on\|off | **on** | Fused quantized-KV decode-SDPA Metal kernel (`MLX_BUN_PERF_KERNEL`), the perf side of the compat A/B. **Not bit-exact** — envelope-gated (≥56/64 teacher-forced argmax vs the frozen compat trajectory, `tests/perf-kernel-oracle.test.ts`), so it is an **L3 lever**: on in `--l3`/no-tier default, **off in bare `--l1`/`--l2`**. Engages on quantized caches at decode. **Serial lane only.** |
41
49
  | `--fused-decode` | on\|off | off | Experimental: tile the quantized decode SDPA (`MLX_BUN_FUSED_DECODE`). **Serial lane only.** |
42
50
  | `--fused-sdpa` | on\|off | on | Fused SDPA path for quantized prefill/continuation (inverted env `MLX_BUN_NO_FUSED_SDPA`). **Serial lane only.** |
43
51
  | `--force-wire` | (bool) | off | Wire weights into memory for the whole generation (`MLX_BUN_FORCE_WIRE`). Near-ceiling models (e.g. 26B) need it. **Serial lane only.** |
44
52
 
53
+ The default host/port (`127.0.0.1:8080`) match `mlx_lm.server`, so running
54
+ mlx-bun alongside the Python reference server needs an explicit `--port`.
55
+
45
56
  The five performance levers are A/B knobs whose defaults are the measured
46
57
  winners; flip them to compare. They are set as `MLX_BUN_*` env vars
47
58
  before the model loads, so they apply to `mlx-bun pi` too. They affect
@@ -62,7 +73,9 @@ Most quality knobs can be set per request in the chat body and override
62
73
  the server-wide default. Precedence, highest first:
63
74
 
64
75
  1. explicit request field (`temperature`, `top_p`, `top_k`, `seed`,
65
- `repetition_penalty`, `max_tokens`/`max_completion_tokens`, `stop`,
76
+ `min_p`, `xtc_probability`/`xtc_threshold`, `logit_bias`,
77
+ `repetition_penalty`, `presence_penalty`/`frequency_penalty` (+ their
78
+ `*_context_size` windows), `max_tokens`/`max_completion_tokens`, `stop`,
66
79
  `chat_template_kwargs.enable_thinking`, `adapter`, `tools`)
67
80
  2. the matching `--temperature`/`--top-p`/`--top-k`/`--thinking` server default
68
81
  3. the model's `generation_config.json`
@@ -128,16 +141,36 @@ concurrently with each other.
128
141
  | vision (image parts) | ❌ serial — needs offset-0 single-seq prefill + bidirectional image mask |
129
142
  | LoRA `adapter` (resolves to ≥1) | ❌ serial — `loraState.active` is one per-generation field; per-row adapters unsupported |
130
143
  | `repetition_penalty` | ❌ serial — per-row logits processors are a later refinement |
144
+ | `min_p` / `xtc_*` / `logit_bias` / `presence_penalty` / `frequency_penalty` | ❌ serial — one gate for the whole sampler/processor family (safe v1; min_p/XTC are per-row samplers and could batch later) |
131
145
  | explicit `seed` | ❌ serial — reproducibility ⇒ solo (matches mlx-lm) |
132
146
  | KV quant active (explicit `--kv-quant`) | ❌ serial — batched is bf16-only in v1 |
147
+ | hybrid-cache model (Qwen3.5 gated-DeltaNet) | ❌ serial — model-level cache-capability gate (see below) |
133
148
  | `temperature` / `top_p` / `top_k` | ✅ batches (each row samples with its own seed) |
134
149
  | `stop` sequences | ✅ batches (per-row `StopMatcher` in the onToken closure) |
135
150
  | `tools` / `tool_choice` | ✅ batches (per-row tool router; decode-layer parse) |
136
151
  | `--thinking` / `enable_thinking` | ✅ batches (template-render concern, lane-independent) |
137
152
  | multi-turn / long prompt | ✅ batches, but **no prompt-cache reuse** (`cached_tokens=0`) |
138
153
 
139
- All three model families — full-attention (CPM), sliding-window (Gemma), and hybrid gated-DeltaNet (Qwen3.5) — batch; the
140
- scheduler assembles each layer's cache by attention type.
154
+ Full-attention (CPM) and sliding-window (Gemma) models batch — the
155
+ scheduler assembles each layer's cache by attention type. Hybrid
156
+ gated-DeltaNet models (Qwen3.5) **route serial**: their `SSMCache` has no
157
+ dynamic-B merge/filter, so the gateway's cache-capability gate (the
158
+ mirror of mlx-lm server.py's all-caches-have-`merge` check) sends every
159
+ request down the serial lane with the standard drain behavior. A batched
160
+ SSM path is planned (mlx-lm's `ArraysCache` is the oracle) — see
161
+ [batching-v2-plan.md](../design/batching-v2-plan.md) item (h).
162
+
163
+ A non-batchable request **drains** the batch: while it waits, the
164
+ scheduler stops admitting new rows, finishes the running ones, and
165
+ releases the GPU so the serial request runs (mlx-lm's `drain_batch`);
166
+ admission then resumes. So a steady stream of batchable traffic cannot
167
+ starve a serial-lane request.
168
+
169
+ `--batch N` is a **mode switch, not a load-dependent fallback**:
170
+ auto-batching "when >1 request arrives" was considered and rejected —
171
+ an idle vs. loaded server would produce different numerics for the same
172
+ request, breaking determinism and the drop-in-for-`mlx_lm.server`
173
+ promise.
141
174
 
142
175
  ## Compatibility matrix
143
176
 
@@ -157,6 +190,7 @@ inside it per the table above).
157
190
  | vision request | ✅ | ✅ via serial lane (in bf16 under Option B) |
158
191
  | LoRA `adapter` | ✅ | ✅ via serial lane |
159
192
  | `repetition_penalty` | ✅ | ✅ via serial lane |
193
+ | `min_p` / `xtc_*` / `logit_bias` / presence+frequency penalties | ✅ | ✅ via serial lane |
160
194
  | `seed` | ✅ | ✅ via serial lane |
161
195
  | `tools` / `stop` | ✅ | ✅ (batches) |
162
196
  | `--compiled-decode`/`--perf-kernel`/`--fused-*`/`--force-wire` | ✅ (serial perf tree) | **n/a — compat mode, no perf flags by design** |