mlx-bun 0.0.9 → 0.0.11

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 (179) hide show
  1. package/README.md +66 -21
  2. package/THIRD_PARTY_LICENSES.md +1 -0
  3. package/docs/archive/mlx-bun-lab-report.html +275 -0
  4. package/docs/archive/wwdc-mlx-bun.html +848 -0
  5. package/docs/design/audio-input-plan.md +356 -0
  6. package/docs/design/batching-perf-path.md +255 -0
  7. package/docs/design/batching-v2-plan.md +23 -9
  8. package/docs/design/bucketing-stage.md +2 -1
  9. package/docs/design/compat-cli-surface-design.md +4 -3
  10. package/docs/design/decode-speed-program.md +172 -0
  11. package/docs/design/docs-reorg-plan.md +1 -1
  12. package/docs/design/dspark-serving-program.md +371 -0
  13. package/docs/design/dspark-speculative-decoding.md +167 -5
  14. package/docs/design/faithful-l1-consolidation.md +310 -0
  15. package/docs/design/generic-model-support.md +8 -5
  16. package/docs/design/grammar-spec-batching-integration.md +301 -0
  17. package/docs/design/minicpm5-decode-megakernel.md +16 -11
  18. package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
  19. package/docs/design/omlx-adoption-map.md +98 -0
  20. package/docs/design/orpo-training.md +34 -30
  21. package/docs/design/paged-kv-cache.md +214 -0
  22. package/docs/design/parallel-slots.md +82 -49
  23. package/docs/design/parity-tier-dag.md +43 -33
  24. package/docs/design/repo-cleanup-plan.md +189 -0
  25. package/docs/design/runtime-isolation.md +19 -3
  26. package/docs/design/segmented-backward-training.md +18 -2
  27. package/docs/design/ssd-kv-cold-tier.md +288 -0
  28. package/docs/design/structured-output.md +484 -0
  29. package/docs/design/the-dreaming-master-plan.md +2 -3
  30. package/docs/design/turboquant-kv.md +235 -0
  31. package/docs/design/unified-engine-frontier-plan.md +774 -0
  32. package/docs/design/web-chat-beat-matrix.md +315 -0
  33. package/docs/design/web-chat-redesign.md +1112 -0
  34. package/docs/investigations/decode-roofline-lookagain.md +92 -6
  35. package/docs/investigations/dspark-handoff.md +182 -16
  36. package/docs/investigations/kernel-perf-review-2026-07.md +105 -26
  37. package/docs/investigations/lab-build-journal.md +2 -2
  38. package/docs/investigations/openwiki-evaluation.md +249 -0
  39. package/docs/planning/IDEAS.md +14 -0
  40. package/docs/planning/PRODUCT_ROADMAP.md +2 -3
  41. package/docs/planning/chat-ui-vision-memory-first.md +199 -0
  42. package/docs/planning/chat-ui-vision.md +50 -0
  43. package/docs/planning/memory-docs-and-dag-plan.md +15 -6
  44. package/docs/planning/release-notes-v0.0.10.md +57 -0
  45. package/docs/planning/release-notes-v0.0.11.md +239 -0
  46. package/docs/reference/cli.md +35 -16
  47. package/docs/reference/distribution.md +13 -6
  48. package/docs/reference/embedding.md +35 -14
  49. package/docs/reference/features-matrix.md +97 -0
  50. package/docs/reference/library-api.md +30 -9
  51. package/docs/reference/memory.md +26 -6
  52. package/docs/reference/models.md +11 -1
  53. package/docs/reference/orpo-quickstart.md +3 -4
  54. package/docs/reference/server-api.md +634 -22
  55. package/docs/reference/server-config.md +251 -119
  56. package/docs/reference/training.md +20 -16
  57. package/package.json +5 -3
  58. package/src/anthropic.ts +13 -0
  59. package/src/audio/conformer.ts +647 -0
  60. package/src/audio/decode.ts +133 -0
  61. package/src/audio/features.ts +202 -0
  62. package/src/audio/transcode.ts +67 -0
  63. package/src/cli.ts +402 -107
  64. package/src/config.ts +49 -2
  65. package/src/download.ts +40 -0
  66. package/src/eval/README.md +7 -6
  67. package/src/eval/kl.ts +59 -3
  68. package/src/eval/quality-db.ts +2 -2
  69. package/src/eval/runner.ts +3 -3
  70. package/src/expert-offload.ts +15 -2
  71. package/src/flags.ts +13 -0
  72. package/src/generate.ts +330 -16
  73. package/src/grammar.ts +585 -0
  74. package/src/hub-rest.ts +242 -0
  75. package/src/kv-store.ts +618 -122
  76. package/src/lora.ts +8 -0
  77. package/src/md-modules.d.ts +42 -0
  78. package/src/media-fetch.ts +284 -0
  79. package/src/memory/rest.ts +245 -0
  80. package/src/memory/vault.ts +74 -0
  81. package/src/mlx/array.ts +127 -19
  82. package/src/mlx/autograd.ts +2 -2
  83. package/src/mlx/ffi.ts +27 -3
  84. package/src/mlx/metal-kernel.ts +7 -4
  85. package/src/mlx/ops.ts +179 -23
  86. package/src/mlx/turboquant-ops.ts +831 -0
  87. package/src/mlx/turboquant-tables.ts +237 -0
  88. package/src/model/batched-mask.ts +62 -1
  89. package/src/model/batched-quant.ts +206 -0
  90. package/src/model/batched-rotating-quant.ts +346 -0
  91. package/src/model/batched-rotating.ts +46 -1
  92. package/src/model/compiled-decode.ts +4 -15
  93. package/src/model/factory.ts +10 -4
  94. package/src/model/gemma4-base.ts +391 -34
  95. package/src/model/gemma4.ts +122 -33
  96. package/src/model/generated/gemma4-12b.ts +9 -13
  97. package/src/model/generated/gemma4-26b.ts +10 -5
  98. package/src/model/generated/gemma4-e4b.ts +14 -9
  99. package/src/model/minicpm5.ts +58 -102
  100. package/src/model/paged-kv.ts +253 -0
  101. package/src/model/qwen3-delta.ts +123 -15
  102. package/src/model/qwen3-moe.ts +404 -0
  103. package/src/model/qwen3.ts +9 -8
  104. package/src/model/qwen3_5.ts +99 -25
  105. package/src/model/support.ts +12 -1
  106. package/src/model/universal/dense.ts +40 -5
  107. package/src/model/universal/rope.ts +36 -21
  108. package/src/pi-web.ts +1299 -82
  109. package/src/preflight.ts +27 -8
  110. package/src/prompt-cache.ts +338 -32
  111. package/src/quantize/quantizer.ts +16 -1
  112. package/src/registry.ts +61 -3
  113. package/src/serve/batch-scheduler.ts +1001 -76
  114. package/src/serve/generation-gateway.ts +292 -30
  115. package/src/serve/isolate.ts +417 -0
  116. package/src/serve/lane-registry.ts +44 -0
  117. package/src/serve/session-search.ts +326 -0
  118. package/src/server.ts +1460 -105
  119. package/src/spec/assistant-source.ts +150 -0
  120. package/src/spec/deepspec-source.ts +143 -0
  121. package/src/spec/dflash-source.ts +124 -0
  122. package/src/spec/dspark/ab-stats.ts +136 -0
  123. package/src/spec/dspark/calibration.ts +112 -0
  124. package/src/spec/dspark/deepspec-module.ts +801 -0
  125. package/src/spec/dspark/generate-dflash.ts +72 -20
  126. package/src/spec/dspark/loader.ts +23 -0
  127. package/src/spec/dspark/module-dflash.ts +278 -36
  128. package/src/spec/dspark/quantize-drafter.ts +91 -0
  129. package/src/spec/generate.ts +59 -25
  130. package/src/spec/ngram-source.ts +132 -0
  131. package/src/spec/serve-loop.ts +536 -0
  132. package/src/spec/source.ts +93 -0
  133. package/src/spec/two-model.ts +171 -0
  134. package/src/ssd-cache.ts +285 -0
  135. package/src/tokenizer.ts +288 -2
  136. package/src/tool-approvals.ts +131 -0
  137. package/src/tool-call.ts +408 -20
  138. package/src/train/flash-cce.ts +29 -26
  139. package/src/train/job.ts +0 -2
  140. package/src/train/loss.ts +53 -11
  141. package/src/train/prefix-shared.ts +30 -7
  142. package/src/train/segmented.ts +150 -46
  143. package/src/train/trainer.ts +22 -15
  144. package/src/vision/prompt.ts +217 -48
  145. package/src/web/app.html +1184 -1789
  146. package/src/web/app.js +5667 -0
  147. package/src/web/icon.svg +13 -0
  148. package/src/web/manifest.webmanifest +24 -0
  149. package/src/web/src/adapters-panel.ts +288 -0
  150. package/src/web/src/api.ts +57 -0
  151. package/src/web/src/assistant.ts +485 -0
  152. package/src/web/src/chat.ts +1050 -0
  153. package/src/web/src/composer.ts +959 -0
  154. package/src/web/src/dataset.ts +132 -0
  155. package/src/web/src/finetune.ts +267 -0
  156. package/src/web/src/hub.ts +428 -0
  157. package/src/web/src/main.ts +98 -0
  158. package/src/web/src/markdown.ts +0 -0
  159. package/src/web/src/memory-panel.ts +572 -0
  160. package/src/web/src/model-picker.ts +159 -0
  161. package/src/web/src/palette.ts +346 -0
  162. package/src/web/src/protocol.ts +62 -0
  163. package/src/web/src/quantize.ts +167 -0
  164. package/src/web/src/rag.ts +274 -0
  165. package/src/web/src/sessions.ts +469 -0
  166. package/src/web/src/shell.ts +804 -0
  167. package/src/web/src/status.ts +252 -0
  168. package/src/web/src/ui-catalog.ts +92 -0
  169. package/src/web/sw.js +71 -0
  170. package/src/web/vendor/README.md +61 -0
  171. package/src/web/vendor/hljs-LICENSE +29 -0
  172. package/src/web/vendor/hljs-theme.css +59 -0
  173. package/src/web/vendor/hljs.js +26 -0
  174. package/src/model/fused-decode-kernel.ts +0 -312
  175. package/src/model/fused-geglu-kernel.ts +0 -198
  176. package/src/model/fused-mlp-kernel.ts +0 -169
  177. package/src/model/fused-swiglu-kernel.ts +0 -240
  178. package/src/model/steel-linear-kernel.ts +0 -96
  179. package/src/status-page.html +0 -497
@@ -1,6 +1,6 @@
1
1
  # Server API
2
2
 
3
- `mlx-bun serve` (or `bun scripts/serve.ts`) exposes an OpenAI-compatible
3
+ `mlx-bun serve` exposes an OpenAI-compatible
4
4
  HTTP API on one model. The request's `model` field is ignored; the
5
5
  loaded model's id is echoed back. By default generation is serialized
6
6
  through a single queue (one GPU, batch = 1); `--batch N` switches the
@@ -54,8 +54,10 @@ Request body (OpenAI chat schema; unknown fields ignored):
54
54
  },
55
55
  "reasoning_effort": "medium", // "none"|"minimal"|"low"|"medium"|"high"
56
56
  // gates enable_thinking on Qwen3.5/MiniCPM5:
57
- // "none" → off, any other level → on.
58
- // Overrides chat_template_kwargs.enable_thinking.
57
+ // "none" → off, any other level → on. Only
58
+ // consulted when chat_template_kwargs
59
+ // .enable_thinking is not explicitly set —
60
+ // an explicit enable_thinking always wins.
59
61
  "hlg": { // HLG tone-curve sampling (per request).
60
62
  "enabled": true, // merged over --hlg-sampling server defaults.
61
63
  "width": 4, // logit-width of the tone plateau
@@ -63,6 +65,11 @@ Request body (OpenAI chat schema; unknown fields ignored):
63
65
  "toe": 6, // bottom toe width
64
66
  "pivot_offset": 6 // pivot point offset from top
65
67
  },
68
+ "response_format": // structured output (grammar-constrained
69
+ { "type": "json_object" }, // decoding) — also guided_grammar,
70
+ // guided_regex, guided_choice,
71
+ // structured_outputs; see "Structured
72
+ // output" below
66
73
  "adapter": "id" // LoRA: "id", stacked "a+b", or "none"
67
74
  }
68
75
  ```
@@ -83,6 +90,65 @@ Message `content` is a string or an array of parts:
83
90
  (http/https URLs also accepted; PNG, JPEG, HEIC, AVIF, WebP, TIFF, GIF,
84
91
  BMP via native OS codecs; requires a model with the vision sidecar).
85
92
 
93
+ Remote (http/https) media URLs — image and audio alike — go through a
94
+ destination policy: **private/loopback/link-local hosts are refused by
95
+ default** (the request URL is attacker-controlled input — SSRF), every
96
+ redirect hop is re-validated, and the fetch has a **10 s timeout** and a
97
+ **64 MB response cap**. Violations are clean `400`s
98
+ (`prompt build failed: image url rejected: …`). Serving media from a LAN
99
+ host (a NAS, another machine) is the opt-in
100
+ [`--allow-private-media`](server-config.md#start-flags) flag. `data:`
101
+ URLs are decoded locally and never policy-checked.
102
+
103
+ ### Audio input
104
+
105
+ Audio rides the same content-part array (OpenAI-canonical shape plus two
106
+ aliases):
107
+
108
+ ```jsonc
109
+ { "type": "input_audio", "input_audio": { "data": "<base64>", "format": "wav" } }
110
+ { "type": "audio", "data": "<base64>" } // alias
111
+ { "type": "audio_url", "audio_url": { "url": "data:…|http(s)://…" } } // alias
112
+ ```
113
+
114
+ (`audio_url` http/https fetches follow the same destination policy,
115
+ timeout, and size cap as `image_url` — see above.)
116
+
117
+ ```bash
118
+ curl -s http://localhost:8080/v1/chat/completions \
119
+ -H 'content-type: application/json' \
120
+ -d "$(jq -n --arg b64 "$(base64 -i clip.wav)" '{
121
+ messages: [{ role: "user", content: [
122
+ { type: "input_audio", input_audio: { data: $b64, format: "wav" } },
123
+ { type: "text", text: "Transcribe this audio." }
124
+ ]}],
125
+ max_tokens: 64, temperature: 0
126
+ }')"
127
+ ```
128
+
129
+ - **Models**: any model whose `config.json` carries an `audio_config`
130
+ AND whose `optiq_vision.safetensors` sidecar ships the audio-tower
131
+ tensors — gemma-4 **e4b** today. Auto-detected, no flags (mirrors
132
+ vision). A request WITH audio on any other model gets an explicit
133
+ `400` naming the model — never a silent text-only answer.
134
+ - **Formats**: WAV is decoded natively (PCM 16/24/32-bit + float32, any
135
+ channel count / sample rate). mp3, m4a/AAC, FLAC, ogg(-CAF), AIFF and
136
+ anything else CoreAudio reads transcode via macOS `afconvert`;
137
+ undecodable bytes are a `400` (`audio transcode failed …`).
138
+ - **Internal format**: 16 kHz mono — multi-channel input is mean-mixed,
139
+ other rates resample. Clips truncate at **30 s** (the oracle
140
+ processor's 480 000-sample cap); one clip costs
141
+ `ceil(duration_ms / 40)` prompt tokens, at most **750**.
142
+ - Multiple clips per message and **mixed image+audio** requests work;
143
+ media splices in document order.
144
+ - **Routing**: audio requests always run on the **serial lane** (never
145
+ batched, even under `--batch N`) and skip the prompt cache — soft
146
+ tokens are identical placeholder ids, so prefix matching across
147
+ different clips would false-hit.
148
+ - The Anthropic surface (`/v1/messages`) has no audio block type in its
149
+ protocol; audio blocks there are a `400` pointing back to this
150
+ endpoint.
151
+
86
152
  Non-streaming response:
87
153
 
88
154
  ```jsonc
@@ -103,11 +169,41 @@ Non-streaming response:
103
169
  }],
104
170
  "usage": {
105
171
  "prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0,
106
- "prompt_tokens_details": { "cached_tokens": 0 } // prompt-cache reuse
172
+ "prompt_tokens_details": { "cached_tokens": 0 }, // prompt-cache reuse
173
+ "lane": "serial", // "serial" | "serial+spec" | "batched" —
174
+ // which execution lane served THIS
175
+ // request (GenerationGateway.willBatch
176
+ // + whether a draft actually contributed
177
+ // accepted tokens). Chat completions
178
+ // only (streaming: final usage chunk).
179
+ "speculation": { // only when a draft source is mounted
180
+ "drafted": 0, // draft tokens proposed
181
+ "accepted": 0, // drafts accepted by the verify step
182
+ "targetCalls": 0 // target-model forward calls
183
+ }
107
184
  }
108
185
  }
109
186
  ```
110
187
 
188
+ `usage.lane` is server-driven, never client-inferred (the web chat's perf-strip
189
+ lane badge reads it, not a heuristic): `"batched"` when `--batch N` picked up
190
+ the request, `"serial+spec"` when a mounted draft source actually
191
+ contributed accepted draft tokens to this reply, `"serial"` otherwise. It
192
+ rides the SAME usage block on both streaming (final chunk before `[DONE]`) and
193
+ non-streaming responses.
194
+
195
+ Speculative decoding is a **server-level mode** (`serve --draft-model`,
196
+ or the model-free `serve --draft-kind ngram`, which mounts no draft
197
+ model); there is no per-request draft field. The `speculation` usage
198
+ extension
199
+ appears on chat and text completions alike, non-streaming and on the
200
+ final stream chunk. Spec-eligible requests are text-only on base weights
201
+ (no adapter, no logprobs capture, bf16 KV); ineligible ones decode
202
+ normally and omit the field. The spec path bypasses the prompt cache
203
+ (`cached_tokens` 0), and while a draft is mounted every request routes
204
+ through the serial lane (mlx_lm.server parity: `is_batchable = draft is
205
+ None`) — speculation and `--batch N` are different modes.
206
+
111
207
  ### logprobs / top_logprobs
112
208
 
113
209
  With `"logprobs": true` and/or `"top_logprobs": k` (1–11), the
@@ -170,12 +266,59 @@ markup still streams live, only the markup is withheld and converted to
170
266
  (string-typed params stay strings); markup that fails to parse falls
171
267
  back to plain content.
172
268
 
269
+ ### Structured output (grammar-constrained decoding)
270
+
271
+ Both `/v1/chat/completions` and `/v1/completions` accept the
272
+ OpenAI/oMLX/vLLM structured-output fields (snake_case on the wire):
273
+
274
+ ```jsonc
275
+ {
276
+ "response_format": // OpenAI: {type:"json_object"} = any valid
277
+ { "type": "json_schema", // JSON; {type:"json_schema"} constrains
278
+ "json_schema": { // to the schema; {type:"text"} = no-op
279
+ "name": "…", "schema": { /* JSON schema */ }, "strict": true,
280
+ "any_whitespace": true } }, // false = compact separators (no
281
+ // whitespace freedom in the grammar —
282
+ // escape hatch for base models that
283
+ // greedily emit whitespace to max_tokens)
284
+ "guided_grammar": "root ::= …", // raw EBNF grammar string (vLLM/oMLX)
285
+ "guided_regex": "[A-Z][a-z]*", // regex — the regex∩EBNF subset ONLY
286
+ // (classes, |, *, +, ?, parens);
287
+ // regex-only syntax (\d, ., anchors)
288
+ // hits the degrade path — a known gap
289
+ "guided_choice": ["yes", "no"], // output is exactly one of these strings
290
+ "structured_outputs": { /* … */ } // bare JSON schema (oMLX/vLLM alias)
291
+ }
292
+ ```
293
+
294
+ Precedence when several are set mirrors oMLX: `guided_grammar` >
295
+ `response_format` json_schema > json_object > `structured_outputs` >
296
+ `guided_regex` > `guided_choice`.
297
+
298
+ Enforcement is a per-step token bitmask at the sampler (xgrammar):
299
+ invalid next tokens are masked to −inf **after** the logits processors,
300
+ so valid-token numerics are untouched. Generation halts as soon as the
301
+ grammar is satisfied (e.g. the closing `}` of a complete JSON object)
302
+ with `finish_reason: "stop"`. Works on both lanes — serial and
303
+ `--batch N` (per-row matchers).
304
+
305
+ Degrade path — a grammar that fails to compile is never rejected (no
306
+ 400/500): chat prepends a system message instructing valid JSON output
307
+ (schema included when one was given) and the response carries a
308
+ `Warning` header (`grammar not enforced: …`); `/v1/completions` has no
309
+ chat template to inject into, so it emits the `Warning` header only.
310
+ `MLX_BUN_GRAMMAR=0` disables the feature entirely (the fields are
311
+ ignored: no enforcement, no injection, no Warning). Design and fidelity
312
+ notes: [structured-output.md](../design/structured-output.md).
313
+
173
314
  ### Errors
174
315
 
175
316
  All errors are `{ "error": { "message": …, ... } }`.
176
317
 
177
318
  - `400` — malformed JSON, empty `messages`, unknown adapter id, vision
178
- request on a model without a sidecar, prompt build failures,
319
+ request on a model without a sidecar, audio request on a model
320
+ without an audio tower (message names the model), audio transcode
321
+ failures, prompt build failures,
179
322
  non-numeric `logit_bias` keys/values (`logit_bias must be a dict of
180
323
  int to float`, mlx-lm's coercion error), invalid logprobs params
181
324
  (mlx-lm's exact validation: `logprobs must be of type bool`,
@@ -211,7 +354,12 @@ and adapter selection as chat.
211
354
  // frequency_penalty, frequency_context_size, adapter,
212
355
  // logprobs (BOOL, mlx-lm's type — not OpenAI's legacy int),
213
356
  // top_logprobs (0-11 or -1) — same response block as chat, see
214
- // "logprobs / top_logprobs" above (mlx-lm shares the builder)
357
+ // "logprobs / top_logprobs" above (mlx-lm shares the builder),
358
+ // response_format / guided_grammar / guided_regex / guided_choice /
359
+ // structured_outputs — structured output, same semantics and
360
+ // precedence as chat (see "Structured output" above); the degrade
361
+ // path here emits the Warning header only (no template to inject a
362
+ // system prompt into)
215
363
  }
216
364
  ```
217
365
 
@@ -224,6 +372,7 @@ Response is the OpenAI text-completion object:
224
372
  "choices": [{ "index": 0, "text": "…", "finish_reason": "stop" | "length" }],
225
373
  "usage": { "prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0,
226
374
  "prompt_tokens_details": { "cached_tokens": 0 } }
375
+ // + "speculation" under --draft-model, as on chat
227
376
  }
228
377
  ```
229
378
 
@@ -244,7 +393,9 @@ Code works as a client this way.
244
393
  content-block arrays; `tool_use` / `tool_result` blocks map to the
245
394
  native gemma tool-calling path (better than the optiq shim, which
246
395
  inlines them as text); `image` blocks (base64 or url source) hit the
247
- vision path on sidecar models.
396
+ vision path on sidecar models. The protocol has no audio input block;
397
+ audio-shaped blocks (`input_audio` / `audio` / `audio_url`) are
398
+ rejected with a `400` pointing to `/v1/chat/completions`.
248
399
  - `tools` (`{name, description, input_schema}`) map to function tools;
249
400
  server-tool types (web_search, …) are dropped silently.
250
401
  - `max_tokens`, `temperature`, `top_p`, `top_k`, `stop_sequences`,
@@ -322,13 +473,52 @@ curl localhost:8080/v1/embeddings -H 'content-type: application/json' \
322
473
  `{ "object": "list", "data": [{ "id": "<model id>", "object": "model",
323
474
  "created": <unix s>, … }] }`
324
475
 
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).
476
+ The served model is FIRST, with extra capability fields:
477
+
478
+ ```jsonc
479
+ {
480
+ "id": "<served model id>", "object": "model", "created": 0,
481
+ "owned_by": "mlx-bun",
482
+ "context_window": 32768,
483
+ "reasoning": false, // switchable thinking channel (gates the
484
+ // web chat's thinking toggle)
485
+ "vision": false, // image input accepted (tower loaded or
486
+ // lazily loadable)
487
+ "audio": false, // input_audio content parts accepted
488
+ // (tower loaded or lazily loadable)
489
+ "gen_defaults": { // model-author sampling defaults
490
+ "temperature": 0.7, // (generation_config.json, with any
491
+ "top_p": 0.95, // --temperature/--top-p/--top-k server
492
+ "top_k": 0 // override applied); null when neither
493
+ // the model nor the server set one
494
+ }
495
+ }
496
+ ```
497
+
498
+ Followed by every other servable model the local registry knows (mlx-lm
499
+ scans the HF cache here; the registry is that scan, filtered to supported
500
+ architectures, one row per repo via `listCanonical()` — duplicate snapshots
501
+ from upstream re-pushes are not separate entries):
502
+
503
+ ```jsonc
504
+ { "id": "<repo id>", "object": "model", "created": 0,
505
+ "vision": false, // visionCapable(): sidecar OR encoder-free
506
+ // unified vision config
507
+ "tier": "targeted" | "generic" // supportTier(): dedicated/generated forward
508
+ // ("targeted") vs the Tier-0 universal
509
+ // module ("generic"); entries with no tier
510
+ // (unsupported) are filtered out entirely
511
+ }
512
+ ```
513
+
329
514
  `GET /v1/models/<id>` filters the list to that id — same list shape,
330
515
  matching `mlx_lm.server`.
331
516
 
517
+ `vision` / `audio` say whether the served model accepts `image_url` /
518
+ `input_audio` content parts (tower loaded or lazily loadable — see
519
+ *Audio input* above for what audio requires), so clients can discover
520
+ capabilities instead of probing for a `400`.
521
+
332
522
  ## GET /health
333
523
 
334
524
  `{"status": "ok"}` — byte-for-byte what `mlx_lm.server` returns.
@@ -340,9 +530,14 @@ matching `mlx_lm.server`.
340
530
  "server": { "owner": "serve" | "pi-session" | "embedded", "model": "...", "started_at": 0 },
341
531
  "prompt_cache": { "entries": 0, "bytes": 0, "max_bytes": 0, "hits": 0, "misses": 0 },
342
532
  "response_store": { "entries": 0, "bytes": 0, "max_bytes": 33554432, "ttl_ms": 3600000 },
343
- "kv_quant": { "mode": "mixed (kv_config.json)" | "uniform-kv8" | "bf16",
344
- "layers": { "kv4": 8, "bf16": 40 },
533
+ "kv_quant": { "mode": "mixed (kv_config.json)" | "uniform-kv8" | "turbo k8v3" | "bf16",
534
+ "layers": { "kv4": 8, "bf16": 40 }, // turbo: { "turbo-k8v3": 8, "bf16": 40 }
345
535
  "attention": { "global": 10, "sliding_window": 38 } },
536
+ // present only when --ssd-cache is on:
537
+ "ssd_cache": { "dir": "...", "entries": 0, "bytes": 0, "max_bytes": 0,
538
+ "restores": 0, "spills": 0, "restore_ms_last": 0, "demotions": 0,
539
+ // bounded write-behind queue (MLX_BUN_SSD_SPILL_QUEUE_GB):
540
+ "pending_spills": 0, "pending_spill_bytes": 0, "dropped_spills": 0 },
346
541
  "admission": {
347
542
  "max_safe_context": 0, // tokens; requests above this 400
348
543
  "memory_budget_bytes": null, // explicit budget, or null (machine default)
@@ -352,7 +547,11 @@ matching `mlx_lm.server`.
352
547
  "batch": {
353
548
  "configured": 1, // the --batch N value
354
549
  "batched": false, // batching enabled (N>1)
355
- "active_rows": 0 // rows currently decoding in the batch
550
+ "active_rows": 0, // rows currently decoding in the batch
551
+ "pending_rows": 0, // queued + mid-prefill rows waiting
552
+ "submitted_rows": 0, // cumulative rows routed to the batch lane since start
553
+ "kv_bytes": 0, // projected aggregate KV bytes of admitted rows
554
+ "kv_budget_bytes": null // --kv-budget cap, or null (uncapped)
356
555
  }
357
556
  }
358
557
  ```
@@ -371,8 +570,13 @@ tensor bytes are read).
371
570
  "model_type": "gemma3" | "minicpm5" | "qwen3" | …,
372
571
  "size_bytes": 0,
373
572
  "quant_bits": 4,
374
- "vision": false, // has vision sidecar
375
- "supported": true, // recognized model family
573
+ "vision": false, // visionCapable(): sidecar OR encoder-free
574
+ // unified vision config (not sidecar-only)
575
+ "audio": false, // audio-capable (audio_config + sidecar audio-tower tensors)
576
+ "supported": true, // supportTier() !== null (recognized family)
577
+ "support_tier": "targeted" | "generic" | null, // dedicated/generated
578
+ // forward vs the Tier-0 universal module;
579
+ // null only when supported is false
376
580
  "serving": false, // currently loaded in this server
377
581
  "assessment": { // null if config unreadable
378
582
  "fits": true,
@@ -383,6 +587,173 @@ tensor bytes are read).
383
587
  }
384
588
  ```
385
589
 
590
+ One row per repo (`listCanonical()`; duplicate snapshots from upstream
591
+ re-pushes surface only via the CLI's `mlx-bun ls --all-revisions`).
592
+
593
+ ## GET /api/gc/plan · POST /api/gc/execute
594
+
595
+ Reclaim disk from superseded snapshots + orphaned blobs in the local HF
596
+ cache — the web-UI equivalent of `mlx-bun gc` (thin wrappers over
597
+ `src/registry.ts`'s `planGc`/`executeGc`; same planner the CLI uses). Every
598
+ downloaded revision the HF cache keeps a `snapshots/<commit>` dir for; a
599
+ snapshot no `refs/*` points at is superseded and, once no surviving snapshot
600
+ symlinks to a blob, that blob is dead too. Loopback-served admin routes, no
601
+ separate auth (same bind as every other `/api/*` route).
602
+
603
+ `GET /api/gc/plan` — read-only (config.json + safetensors index reads only,
604
+ no tensor bytes, no deletion):
605
+
606
+ ```jsonc
607
+ {
608
+ "ok": true,
609
+ "superseded": [{
610
+ "repo_id": "…",
611
+ "prune_snapshots": 1, // unreferenced, safe to delete
612
+ "skipped_snapshots": 0, // unreferenced but hold files the kept
613
+ // revision lacks — needs --force via the
614
+ // CLI to actually prune; not deletable here
615
+ "dead_blobs": 3,
616
+ "reclaim_bytes": 0
617
+ }],
618
+ "reclaim_bytes": 0 // sum across every repo
619
+ }
620
+ ```
621
+
622
+ `POST /api/gc/execute` with body `{"yes": true}` — deletes exactly what a
623
+ fresh plan would show (recomputed at execute time, not the caller's stale
624
+ plan); `{"yes": true}` is required or the request 400s, mirroring the CLI's
625
+ `--yes` gate so a stray call can't delete anything by accident:
626
+
627
+ ```jsonc
628
+ { "ok": true, "snapshots": 1, "blobs": 3, "reclaimed_bytes": 0 }
629
+ ```
630
+
631
+ Errors follow the same `{ "ok": false, "error": "…" }` shape as the other
632
+ `/api/*` job routes.
633
+
634
+ ## GET /api/memory/\* · POST /api/memory/init
635
+
636
+ Thin loopback JSON wrappers over `src/memory/vault.ts` for the web chat's
637
+ Memory panel (docs/design/web-chat-redesign.md §5.5) — same loopback-only
638
+ admin-route posture as `/api/gc/*`, no separate auth. Handlers live in
639
+ `src/memory/rest.ts`; they never touch the agent-tool surface
640
+ (`src/memory/tools.ts`), which stays read-only and unchanged. Every route
641
+ below degrades gracefully — a missing vault, an unknown article, or a bad
642
+ param returns `{ "ok": false, ... }` (with an appropriate HTTP status), never
643
+ a 500 or an unhandled throw.
644
+
645
+ When no vault exists yet, every `GET` route (except `/init`) returns:
646
+
647
+ ```jsonc
648
+ { "ok": false, "enabled": false, "error": "no memory vault yet", "root": "…" }
649
+ ```
650
+
651
+ `GET /api/memory/status` — `vaultStatus()` as JSON:
652
+
653
+ ```jsonc
654
+ { "ok": true, "enabled": true, "status": { "root": "…", "exists": true, "articleCount": 12, "referenceCount": 9, "isGitRepo": true, "recentArticles": [{ "article": "…", "mtimeMs": 0 }] } }
655
+ ```
656
+
657
+ `GET /api/memory/list` — article stems plus `Reference/*` doc ids, kept
658
+ separate (mirrors `memory_list`):
659
+
660
+ ```jsonc
661
+ { "ok": true, "articles": ["Alpha", "Beta"], "reference": ["Reference/mlx-bun_README"] }
662
+ ```
663
+
664
+ `GET /api/memory/search?q=<query>&scope=all|articles|reference&limit=50` —
665
+ wraps `searchArticles`; same summaries/hits shape the `memory_search` tool
666
+ formats from:
667
+
668
+ ```jsonc
669
+ { "ok": true, "summaries": [{ "article": "…", "occurrences": 3, "matched_terms": ["…"] }], "hits": [{ "article": "…", "anchor": "…", "line": 10, "excerpt": "…" }] }
670
+ ```
671
+
672
+ `GET /api/memory/article?name=<article>` — rendered source plus the
673
+ deterministic metadata the panel needs to render it (infobox, lead, series
674
+ banner, section skeleton) without re-parsing client-side; 404s
675
+ `{ "ok": false }` for an unknown name:
676
+
677
+ ```jsonc
678
+ { "ok": true, "name": "…", "path": "…", "content": "# …", "infobox": { "type": "…", "entityKind": "thing", "fields": [] }, "lead": "…", "series": null, "structure": [] }
679
+ ```
680
+
681
+ `GET /api/memory/links?name=<article>` — inbound/outbound wikilinks (same
682
+ data `memory_links` resolves); articles only, not `Reference/*` (matching
683
+ the tool's own scope):
684
+
685
+ ```jsonc
686
+ { "ok": true, "name": "…", "outbound": ["…"], "inbound": ["…"] }
687
+ ```
688
+
689
+ `GET /api/memory/history?name=<article>&limit=50` — the article's git log
690
+ (read-only `git log` plumbing on the vault's own repo, via `Bun.spawn` —
691
+ never a shell string):
692
+
693
+ ```jsonc
694
+ { "ok": true, "name": "…", "isGitRepo": true, "entries": [{ "hash": "…", "date": "YYYY-MM-DD", "subject": "…" }] }
695
+ ```
696
+
697
+ `GET /api/memory/diff?name=<article>&rev=<hash>` — that commit's diff for
698
+ the article (`git show <rev> -- <path>`). `rev` must match
699
+ `/^[0-9a-f]{4,40}$/` (a bare commit hash — never a ref expression like
700
+ `HEAD~1` or `main^`) or the request 400s before touching git; `name` is
701
+ resolved through the same existence-checked normalization `readArticle`
702
+ uses, so a path-traversal or otherwise-invalid name 404s rather than
703
+ reaching the `git` argv:
704
+
705
+ ```jsonc
706
+ { "ok": true, "name": "…", "rev": "…", "diff": "diff --git a/articles/…" }
707
+ ```
708
+
709
+ `POST /api/memory/init` with optional body `{"path"?: string}` — the
710
+ first-run consent-card backend. Delegates to the exact same `setupVault()`
711
+ the CLI's `mlx-bun memory init` calls (idempotent: create dirs, write
712
+ README + Meta pages only if missing, git init + initial commit); omits only
713
+ the CLI's interactive extras (seed-from-existing-vault prompt, nightly
714
+ schedule prompt), which stay TTY-only. `path`, if given, must resolve under
715
+ the default vault root or the OS temp directory — `setupVault()` mkdir's
716
+ into it and `git init`s it if it isn't already a repo, so anything outside
717
+ those trees 400s before touching the filesystem rather than letting a
718
+ caller redirect vault setup at an arbitrary directory:
719
+
720
+ ```jsonc
721
+ { "ok": true, "result": { "root": "…", "created": ["…"], "gitInitialized": true, "alreadySetUp": false }, "status": { "root": "…", "exists": true, "articleCount": 0, "referenceCount": 9, "isGitRepo": true, "recentArticles": [] } }
722
+ ```
723
+
724
+ ## GET /api/settings/tool-approvals · DELETE /api/settings/tool-approvals
725
+
726
+ The web chat's durable "always allow this tool" list for gated tools
727
+ (`bash`/`edit`/`write`) — docs/design/web-chat-redesign.md §5.4/§6.5/§9
728
+ Phase 2, risk #6. Backed by `~/.mlx-bun/tool-approvals.json`
729
+ (`src/tool-approvals.ts`), a versioned `{version, allows}` file (same
730
+ plain-JSON-at-0600 convention as `~/.mlx-bun/hf.json`) keyed by stable tool
731
+ NAME, not per-call arguments — "always allow bash" means every future
732
+ `bash` call skips the browser's approval card, on every chat and every
733
+ browser tab, until forgotten. These two routes are read/forget only:
734
+ granting an always-allow happens through the approval card itself (the
735
+ `approval` WS frame on `/ws/chat`, `alwaysAllow: true`), never a bare
736
+ settings-form POST.
737
+
738
+ `GET /api/settings/tool-approvals`:
739
+
740
+ ```jsonc
741
+ { "ok": true, "alwaysAllow": ["bash", "edit"] }
742
+ ```
743
+
744
+ `DELETE /api/settings/tool-approvals` with body `{"tool": "bash"}` —
745
+ revokes one tool's always-allow (idempotent; forgetting an entry that
746
+ isn't present is not an error). Returns the updated set:
747
+
748
+ ```jsonc
749
+ { "ok": true, "alwaysAllow": ["edit"] }
750
+ ```
751
+
752
+ Separately, `GET /api/settings/hf-token` / `POST /api/settings/hf-token`
753
+ manage the Hugging Face write token the same settings modal's other
754
+ section uses (see the `upload` CLI command in [cli.md](./cli.md) for the
755
+ push-to-hub flow this token drives).
756
+
386
757
  ## GET /downloads
387
758
 
388
759
  Snapshot of the last 5 model downloads (active, completed, or errored)
@@ -406,6 +777,175 @@ initiated via `mlx-bun download` or the web library panel.
406
777
  }
407
778
  ```
408
779
 
780
+ ## Model Hub (`/api/hub/*`)
781
+
782
+ The web chat's Model Hub panel (docs/design/web-chat-redesign.md §9 Phase 3,
783
+ beat-matrix Axis 3): browse downloaded models, search Hugging Face, and kick
784
+ off downloads — all loopback-served, no separate auth, same posture as
785
+ `/api/gc/*` and `/api/memory/*`. Handlers live in `src/hub-rest.ts` (pure
786
+ functions, no loaded-model dependency); route dispatch in `src/server.ts`
787
+ just matches path + method.
788
+
789
+ `GET /api/hub/local` — every model in the local registry (same source
790
+ `/library` reads), each with a `/fit`-computed hardware verdict at a fixed
791
+ 8k context — the beat matrix's "real hardware verdict per row" that optiq's
792
+ Hub lacks:
793
+
794
+ ```jsonc
795
+ {
796
+ "ok": true,
797
+ "models": [{
798
+ "repo_id": "…",
799
+ "model_type": "gemma3" | "minicpm5" | "qwen3" | …,
800
+ "size_bytes": 0,
801
+ "quant_bits": 4,
802
+ "quant_group_size": 64,
803
+ "vision": false,
804
+ "supported": true,
805
+ "support_tier": "targeted" | "generic" | null,
806
+ "assessment": { "fits": true, "max_safe_context": 8192, "predicted_decode_tps": 0.0 } | null
807
+ }]
808
+ }
809
+ ```
810
+
811
+ `GET /api/hub/search?q=<query>` — server-side Hugging Face model search,
812
+ filtered to the `mlx` library tag (the tag every mlx-community / MLX-format
813
+ repo carries) so results are MLX-compatible by construction, normalized to
814
+ the fields the Hub panel needs. Never downloads anything — search only.
815
+ Degrades gracefully on any network failure (DNS, timeout, non-2xx) to an
816
+ explicit offline state rather than a 500:
817
+
818
+ ```jsonc
819
+ { "ok": true, "offline": false, "results": [{ "id": "mlx-community/…", "downloads": 0, "likes": 0, "size_estimate": null }] }
820
+ ```
821
+
822
+ ```jsonc
823
+ { "ok": true, "offline": true, "error": "…", "results": [] }
824
+ ```
825
+
826
+ `size_estimate` is always `null` in v1 — the search endpoint doesn't return
827
+ per-repo file sizes and a second per-repo call would slow the search down;
828
+ the fit badge lands once a model is actually downloaded (`/api/hub/local`).
829
+
830
+ `POST /api/hub/download` with body `{"repo": "org/name"}` — starts a
831
+ background download via the existing `downloadModel()` +
832
+ `src/download.ts` process tracker and returns immediately; progress is
833
+ already visible via [`GET /downloads`](#get-downloads). Refuses a duplicate
834
+ kick-off for a repo that's already downloading:
835
+
836
+ ```jsonc
837
+ { "ok": true, "repo": "org/name", "started": true }
838
+ ```
839
+
840
+ ```jsonc
841
+ { "ok": false, "error": "a download for org/name is already in progress" }
842
+ ```
843
+ (409)
844
+
845
+ `POST /api/hub/serve` with body `{"model": "org/name"}` — **always answers
846
+ `restart_required`, never performs a live in-process swap.** Investigated
847
+ against `docs/design/runtime-isolation.md`: the web chat's `/ws/chat` path
848
+ is explicitly not proxied even under `--isolate` (`src/serve/isolate.ts`
849
+ answers it 501), and `--isolate` itself is opt-in, not the default — so the
850
+ process a web chat session is actually attached to always has exactly one
851
+ model loaded with no drop-weights-and-reload seam reachable from that path.
852
+ (The `--isolate` proxy's `ModelPool`, P2 of that doc, *is* a real
853
+ spawn-overlap live-swap mechanism — it just lives entirely on the isolated
854
+ `/v1/*` HTTP surface, not on `/ws/chat`.) Faking a swap here would leave the
855
+ server in a half-state; the honest answer is the restart command:
856
+
857
+ ```jsonc
858
+ { "ok": false, "restart_required": true, "command": "mlx-bun serve org/name" }
859
+ ```
860
+
861
+ Wiring `/ws/chat` through the isolate proxy (or otherwise reaching
862
+ `ModelPool`'s spawn-overlap swap from the web chat) is the Hub's live-swap
863
+ follow-up, not yet scheduled.
864
+
865
+ ## GET /api/sessions/search · GET /api/sessions/export
866
+
867
+ Full-text search across web-chat session message **bodies** and chat
868
+ export (docs/design/web-chat-redesign.md §9 Phase 3, beat-matrix Axis
869
+ 10/11's full-text-search BEAT: Claude's own session search is title-only —
870
+ a widely-cited annoyance — and local storage removes any server-cost
871
+ excuse to skip real body search). Same loopback-only, no-separate-auth
872
+ posture as `/api/memory/*`/`/api/hub/*`. Handlers live in
873
+ `src/serve/session-search.ts` (pure, read-only, no loaded-model
874
+ dependency); route dispatch in `src/server.ts` just matches path + method.
875
+ Both routes read the same session directory the web chat's `PiWebSession`
876
+ writes to (`~/.mlx-bun/sessions`, pi's own JSONL-per-session format) and
877
+ are JSONL-tolerant — a corrupt or mid-write line is skipped, never thrown.
878
+
879
+ `GET /api/sessions/search?q=<query>` — case-insensitive substring scan (v1;
880
+ no index — personal-chat-corpus scale, see the module header comment for
881
+ the upgrade path if the corpus ever grows enough to matter) over every
882
+ session file's user/assistant message text, capped at 50 matches total (10
883
+ per session). Each match reports a plain-text ±60-char snippet plus the
884
+ match's own `[start,end)` offsets **into that snippet** — offsets, not
885
+ pre-rendered HTML highlighting, so the frontend escapes the snippet then
886
+ inserts its own `<mark>` (the same escape-then-restore discipline the
887
+ markdown renderer uses for code spans, applied here to search
888
+ highlighting instead):
889
+
890
+ ```jsonc
891
+ {
892
+ "ok": true,
893
+ "results": [{
894
+ "sessionPath": "/Users/…/.mlx-bun/sessions/20260706_…jsonl",
895
+ "sessionTitle": "Chicken recipes",
896
+ "matches": [{ "snippet": "…tell me about rosemary and thyme…", "ranges": [[14, 22]], "role": "user" }]
897
+ }]
898
+ }
899
+ ```
900
+
901
+ 400s `{"ok": false, "error": "q is required"}` without a query.
902
+
903
+ `GET /api/sessions/export?path=<session file>` — the raw session JSONL,
904
+ parsed into an array of entries (one per line, in file order) — the
905
+ frontend's Markdown/JSON export renders from this for a session that isn't
906
+ the currently-open one (the open session's own history is already loaded
907
+ client-side, but this endpoint is used uniformly for both cases so there's
908
+ one code path, not two). `path` must resolve under the session directory —
909
+ exactly the same guard shape as `PiWebSession`'s private
910
+ `isUnderSessionDir` (the root itself, or a path prefixed by `root + "/"`)
911
+ — reimplemented as a small standalone check in `session-search.ts` rather
912
+ than exported from that class:
913
+
914
+ ```jsonc
915
+ { "ok": true, "path": "…", "entries": [{ "type": "session", "id": "…", "cwd": "…" }, { "type": "message", "message": { "role": "user", "content": "…" } }] }
916
+ ```
917
+
918
+ 400s without `path`; 403s a `path` outside the session directory; 404s a
919
+ path inside the directory that doesn't exist on disk.
920
+
921
+ ## Web app static routes + PWA installability
922
+
923
+ The web chat (`GET /`) and everything it loads same-origin, no CDN, ever:
924
+
925
+ | Route | Content | Notes |
926
+ | --- | --- | --- |
927
+ | `GET /` | `src/web/app.html` | The unified SPA shell. |
928
+ | `GET /assets/app.js` | `src/web/app.js` | GENERATED from `src/web/src/*.ts` by `bun scripts/build-web.ts` — see `tests/web-build.test.ts`'s freshness gate. |
929
+ | `GET /assets/hljs.js`, `GET /assets/hljs.css` | `src/web/vendor/hljs*` | Vendored syntax highlighting, no CDN (see that dir's README). |
930
+ | `GET /manifest.webmanifest` | `src/web/manifest.webmanifest` | PWA manifest — name/short_name/theme colors match the app's design tokens. |
931
+ | `GET /assets/icon.svg` | `src/web/icon.svg` | A single inline SVG app icon. No binary PNGs are shipped (the hygiene gate forbids tracked binaries beyond its allowlist); some browsers don't render an SVG as an install icon — accepted, noted in [features-matrix.md](features-matrix.md). |
932
+ | `GET /sw.js` | `src/web/sw.js` | Service worker, served `cache-control: no-store` at the root scope (required for a worker to control `/`). |
933
+
934
+ **PWA scope (beat-matrix Axis 10 "PWA installability"):** the manifest +
935
+ service worker exist for *installability and an instant static-shell
936
+ paint* — "Add to Home Screen" / a browser "Install app" prompt, and the
937
+ next visit's HTML/JS/CSS loading from cache while the WebSocket connects.
938
+ This is explicitly **not** offline chat: `src/web/sw.js`'s fetch handler
939
+ only intercepts the exact shell files listed above (`/`, `/assets/app.js`,
940
+ `/assets/hljs.js`, `/assets/hljs.css`) and lets every dynamic route
941
+ (`/api/*`, `/v1/*`, `/ws/chat`, `/downloads`, …) fall straight through to
942
+ the network unconditionally — caching a chat API response would be
943
+ actively misleading given the app is a thin client over a local model
944
+ process that has to actually be running. Registration
945
+ (`src/web/src/shell.ts`'s `initServiceWorker()`) is guarded to secure
946
+ contexts: `https:` or `localhost`/`127.0.0.1`, matching the browser's own
947
+ service-worker eligibility rule.
948
+
409
949
  ## GET /fit
410
950
 
411
951
  Fit assessment for the loaded model on this machine, plus a capability
@@ -443,15 +983,37 @@ the prediction.
443
983
 
444
984
  ## Adapters (LoRA hot-swap)
445
985
 
446
- - `GET /v1/adapters` — `{ adapters: [{ id, path, rank, scale, size_bytes, mounted_layers }] }`
986
+ - `GET /v1/adapters` — `{ adapters: [{ id, path, rank, scale, size_bytes,
987
+ mounted_layers, ram_bytes }] }` — currently-mounted adapters only.
988
+ `ram_bytes` is the actual resident size of the adapter's mounted
989
+ `lora_a`/`lora_b` arrays (summed `MlxArray.nbytes` — real RAM cost while
990
+ mounted, not a guess from the on-disk file size); the web chat's adapter
991
+ routing table (§5.6 of `docs/design/web-chat-redesign.md`) shows this per
992
+ loaded adapter.
993
+ - `GET /v1/adapters/available` — `{ adapters: [{ id, path, rank, scale,
994
+ base_model, mounted, compatible }] }` — every adapter found on disk
995
+ (`~/.cache/mlx-bun-finetunes`, `~/.cache/mlx-bun/adapters`), unfiltered.
996
+ `compatible` is true when the adapter's recorded base model matches the
997
+ currently-served model (compared by bare repo name) or the adapter
998
+ recorded no base at all; `mounted` is true when it's already loaded.
999
+ The web chat's adapter chip uses `compatible` to gray out entries it
1000
+ won't let you select rather than hiding them.
447
1001
  - `POST /v1/adapters` — `{ "id": "...", "path": "/dir" }`; mounts
448
- through the generation queue (never races a forward pass). 400 on
449
- shape/compat mismatch — validation is all-or-nothing.
1002
+ through the generation queue (never races a forward pass). Response:
1003
+ `{ id, mounted_layers, rank, scale, ram_bytes }`. 400 on shape/compat
1004
+ mismatch — validation is all-or-nothing.
450
1005
  - `DELETE /v1/adapters/<id>` — unmount; 404 if not mounted.
451
1006
 
452
- Select per request with the `adapter` body field. Prompt-cache entries
453
- are namespaced per adapter spec, so switching adapters never reuses
454
- another adapter's KV.
1007
+ Select per request with the `adapter` body field, which also accepts a
1008
+ composed spec — `"a+b"` (or `"a,b"`) stacks two mounted adapters, their
1009
+ LoRA residuals summed in order (`AdapterManager.resolveSpec` /
1010
+ `parseAdapterSpec` in `src/lora.ts`); every named id must already be
1011
+ mounted or the request 400s with the unknown id named. The web chat's
1012
+ adapter routing table exposes this as a "stack" action on top of the
1013
+ composer's single-select quick-switcher. Prompt-cache entries are
1014
+ namespaced per adapter spec (including composed ones), so switching
1015
+ adapters — or switching which ones are stacked — never reuses another
1016
+ combination's KV.
455
1017
 
456
1018
  `serve --adapter <dir>` (alias `--adapter-path`, mlx_lm.server's
457
1019
  spelling) mounts an adapter at startup through this same machinery and
@@ -459,6 +1021,56 @@ makes it the default for requests that send no `adapter` field; an
459
1021
  explicit `adapter` (including `"none"`) always wins, and hot-swap via
460
1022
  these endpoints is unchanged.
461
1023
 
1024
+ ## App-aware assistant (`/ws/chat`)
1025
+
1026
+ The web chat agent can see and act on the app it lives in —
1027
+ `docs/design/web-chat-redesign.md` §6.6, beat matrix Axis 12. No
1028
+ screenshots and no vision model are involved: the browser sends a
1029
+ structured DOM snapshot as a WS frame, and the model gets three tools that
1030
+ read/navigate/highlight that snapshot.
1031
+
1032
+ Three tools join the web chat's tool allowlist unconditionally (same
1033
+ class as `web_search`/`read` — read-only-on-the-machine, never gated by
1034
+ the approval card, regardless of memory or `codingTools` state; see
1035
+ `APP_AWARE_TOOL_NAMES` in `src/pi-web.ts`):
1036
+
1037
+ - **`get_current_app_context`** — returns the last context the browser
1038
+ pushed: `{ route, view?, step?, snapshot }`. `snapshot` is a capped
1039
+ (~120) list of visible interactive elements as `{ref, label, kind,
1040
+ role?, selector, spotlightId?}`, agent chrome excluded.
1041
+ - **`navigate_app({ route | page })`** — validates against the route
1042
+ catalog (`chat`, `quantize`, `finetune`, `dataset`, `status`; unknown
1043
+ routes 400 as a tool error, never silently sent to the browser) and
1044
+ emits a `ui_navigate` frame. Reversible; no approval needed.
1045
+ - **`spotlight_ui({ ref | label | selector | target, route?, message?
1046
+ })`** — emits a `ui_spotlight` frame; the browser resolves the target
1047
+ (ref from its last snapshot, then live selector, then fuzzy label
1048
+ match, then a curated catalog id) and shows a brief highlight. Never
1049
+ blocks: the overlay traps no focus and passes clicks straight through
1050
+ to the highlighted control, auto-dismisses in ~3s, and any keystroke,
1051
+ click, or scroll dismisses it instantly.
1052
+
1053
+ Client → server WS frame (pushed by the browser on every route change and
1054
+ every wizard-step change, never on a timer):
1055
+
1056
+ ```jsonc
1057
+ { "type": "context", "context": { "route": "quantize", "step": { "index": 1, "count": 4, "label": "Configure" }, "snapshot": { "route": "quantize", "capturedAt": "…", "elements": [/* … */] } } }
1058
+ ```
1059
+
1060
+ Server → client frames the two action tools produce:
1061
+
1062
+ ```jsonc
1063
+ { "type": "ui_navigate", "route": "quantize" }
1064
+ { "type": "ui_spotlight", "target": "quantize-source", "message": "Paste a Hugging Face repo id or local path here" }
1065
+ ```
1066
+
1067
+ The server also auto-prepends a compact one-line ambient context (e.g.
1068
+ `[user is on: Quantize · step 2/4]`) to the system prompt on every turn —
1069
+ never a full snapshot dump — so the model is never answering completely
1070
+ blind even when it doesn't call a tool. `ui_act` (approval-gated form-fill
1071
+ and job-start actions) is explicitly out of scope for this v1; only
1072
+ navigate/spotlight are wired.
1073
+
462
1074
  ## Client setup: pi
463
1075
 
464
1076
  The supported one-command path: