mlx-bun 0.0.10 → 0.0.12

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