mlx-bun 0.0.10 → 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 (173) 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 +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/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 +14 -6
  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 +3 -2
  27. package/docs/design/ssd-kv-cold-tier.md +130 -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/dspark-handoff.md +182 -16
  35. package/docs/investigations/lab-build-journal.md +2 -2
  36. package/docs/investigations/openwiki-evaluation.md +249 -0
  37. package/docs/planning/IDEAS.md +14 -0
  38. package/docs/planning/PRODUCT_ROADMAP.md +2 -3
  39. package/docs/planning/chat-ui-vision-memory-first.md +199 -0
  40. package/docs/planning/chat-ui-vision.md +50 -0
  41. package/docs/planning/memory-docs-and-dag-plan.md +15 -6
  42. package/docs/planning/release-notes-v0.0.11.md +239 -0
  43. package/docs/reference/cli.md +35 -16
  44. package/docs/reference/distribution.md +13 -6
  45. package/docs/reference/embedding.md +35 -14
  46. package/docs/reference/features-matrix.md +97 -0
  47. package/docs/reference/library-api.md +30 -9
  48. package/docs/reference/memory.md +26 -6
  49. package/docs/reference/models.md +11 -1
  50. package/docs/reference/orpo-quickstart.md +3 -4
  51. package/docs/reference/server-api.md +634 -22
  52. package/docs/reference/server-config.md +250 -121
  53. package/docs/reference/training.md +14 -15
  54. package/package.json +5 -3
  55. package/src/anthropic.ts +13 -0
  56. package/src/audio/conformer.ts +647 -0
  57. package/src/audio/decode.ts +133 -0
  58. package/src/audio/features.ts +202 -0
  59. package/src/audio/transcode.ts +67 -0
  60. package/src/cli.ts +356 -105
  61. package/src/config.ts +49 -2
  62. package/src/download.ts +40 -0
  63. package/src/eval/README.md +7 -6
  64. package/src/eval/kl.ts +59 -3
  65. package/src/eval/quality-db.ts +2 -2
  66. package/src/eval/runner.ts +3 -3
  67. package/src/expert-offload.ts +15 -2
  68. package/src/flags.ts +13 -0
  69. package/src/generate.ts +330 -24
  70. package/src/grammar.ts +585 -0
  71. package/src/hub-rest.ts +242 -0
  72. package/src/kv-store.ts +442 -88
  73. package/src/lora.ts +8 -0
  74. package/src/md-modules.d.ts +42 -0
  75. package/src/media-fetch.ts +284 -0
  76. package/src/memory/rest.ts +245 -0
  77. package/src/memory/vault.ts +74 -0
  78. package/src/mlx/array.ts +106 -19
  79. package/src/mlx/autograd.ts +2 -2
  80. package/src/mlx/ffi.ts +27 -3
  81. package/src/mlx/metal-kernel.ts +7 -4
  82. package/src/mlx/ops.ts +153 -23
  83. package/src/mlx/turboquant-ops.ts +831 -0
  84. package/src/mlx/turboquant-tables.ts +237 -0
  85. package/src/model/batched-mask.ts +62 -1
  86. package/src/model/batched-quant.ts +206 -0
  87. package/src/model/batched-rotating-quant.ts +346 -0
  88. package/src/model/batched-rotating.ts +46 -1
  89. package/src/model/compiled-decode.ts +4 -15
  90. package/src/model/factory.ts +10 -4
  91. package/src/model/gemma4-base.ts +363 -39
  92. package/src/model/gemma4.ts +122 -33
  93. package/src/model/generated/gemma4-12b.ts +9 -13
  94. package/src/model/generated/gemma4-26b.ts +10 -5
  95. package/src/model/generated/gemma4-e4b.ts +14 -9
  96. package/src/model/minicpm5.ts +58 -102
  97. package/src/model/paged-kv.ts +253 -0
  98. package/src/model/qwen3-delta.ts +76 -15
  99. package/src/model/qwen3-moe.ts +404 -0
  100. package/src/model/qwen3.ts +9 -8
  101. package/src/model/qwen3_5.ts +88 -23
  102. package/src/model/support.ts +12 -1
  103. package/src/model/universal/dense.ts +40 -5
  104. package/src/model/universal/rope.ts +36 -21
  105. package/src/pi-web.ts +1299 -82
  106. package/src/preflight.ts +27 -8
  107. package/src/prompt-cache.ts +293 -33
  108. package/src/quantize/quantizer.ts +16 -1
  109. package/src/registry.ts +57 -2
  110. package/src/serve/batch-scheduler.ts +965 -72
  111. package/src/serve/generation-gateway.ts +214 -11
  112. package/src/serve/isolate.ts +417 -0
  113. package/src/serve/lane-registry.ts +44 -0
  114. package/src/serve/session-search.ts +326 -0
  115. package/src/server.ts +1380 -135
  116. package/src/spec/assistant-source.ts +150 -0
  117. package/src/spec/deepspec-source.ts +143 -0
  118. package/src/spec/dflash-source.ts +124 -0
  119. package/src/spec/dspark/ab-stats.ts +136 -0
  120. package/src/spec/dspark/calibration.ts +112 -0
  121. package/src/spec/dspark/deepspec-module.ts +801 -0
  122. package/src/spec/dspark/generate-dflash.ts +72 -20
  123. package/src/spec/dspark/loader.ts +23 -0
  124. package/src/spec/dspark/module-dflash.ts +278 -36
  125. package/src/spec/dspark/quantize-drafter.ts +91 -0
  126. package/src/spec/generate.ts +59 -25
  127. package/src/spec/ngram-source.ts +132 -0
  128. package/src/spec/serve-loop.ts +536 -0
  129. package/src/spec/source.ts +93 -0
  130. package/src/spec/two-model.ts +171 -0
  131. package/src/ssd-cache.ts +107 -35
  132. package/src/tokenizer.ts +288 -2
  133. package/src/tool-approvals.ts +131 -0
  134. package/src/tool-call.ts +408 -20
  135. package/src/train/flash-cce.ts +2 -2
  136. package/src/train/job.ts +0 -2
  137. package/src/train/trainer.ts +1 -4
  138. package/src/vision/prompt.ts +217 -48
  139. package/src/web/app.html +1184 -1789
  140. package/src/web/app.js +5667 -0
  141. package/src/web/icon.svg +13 -0
  142. package/src/web/manifest.webmanifest +24 -0
  143. package/src/web/src/adapters-panel.ts +288 -0
  144. package/src/web/src/api.ts +57 -0
  145. package/src/web/src/assistant.ts +485 -0
  146. package/src/web/src/chat.ts +1050 -0
  147. package/src/web/src/composer.ts +959 -0
  148. package/src/web/src/dataset.ts +132 -0
  149. package/src/web/src/finetune.ts +267 -0
  150. package/src/web/src/hub.ts +428 -0
  151. package/src/web/src/main.ts +98 -0
  152. package/src/web/src/markdown.ts +0 -0
  153. package/src/web/src/memory-panel.ts +572 -0
  154. package/src/web/src/model-picker.ts +159 -0
  155. package/src/web/src/palette.ts +346 -0
  156. package/src/web/src/protocol.ts +62 -0
  157. package/src/web/src/quantize.ts +167 -0
  158. package/src/web/src/rag.ts +274 -0
  159. package/src/web/src/sessions.ts +469 -0
  160. package/src/web/src/shell.ts +804 -0
  161. package/src/web/src/status.ts +252 -0
  162. package/src/web/src/ui-catalog.ts +92 -0
  163. package/src/web/sw.js +71 -0
  164. package/src/web/vendor/README.md +61 -0
  165. package/src/web/vendor/hljs-LICENSE +29 -0
  166. package/src/web/vendor/hljs-theme.css +59 -0
  167. package/src/web/vendor/hljs.js +26 -0
  168. package/src/model/fused-decode-kernel.ts +0 -312
  169. package/src/model/fused-geglu-kernel.ts +0 -198
  170. package/src/model/fused-mlp-kernel.ts +0 -169
  171. package/src/model/fused-swiglu-kernel.ts +0 -240
  172. package/src/model/steel-linear-kernel.ts +0 -96
  173. package/src/status-page.html +0 -497
@@ -0,0 +1,417 @@
1
+ // Runtime isolation — the parent half (docs/design/runtime-isolation.md).
2
+ //
3
+ // ARCHITECTURE (decision 2026-07-05, deviating from the doc's original
4
+ // structured-IPC sketch — recorded there): the inference ENGINE is the
5
+ // ENTIRE existing server, spawned as a child process listening on a unix
6
+ // domain socket; this module is the parent — a thin reverse proxy that
7
+ // binds the TCP port, forwards everything to the child, and NEVER touches
8
+ // MLX. Rationale: the gateway-as-IPC-client cut would have to serialize
9
+ // grammar controllers (WASM), vision embeddings (GPU arrays), sampler
10
+ // closures, and prompt-cache hooks across a message protocol — weeks of
11
+ // re-plumbing; HTTP-over-UDS reuses the whole serving stack verbatim,
12
+ // streams SSE for free, and turns cancellation into plain abort
13
+ // propagation (client disconnect → proxied fetch aborts → the child's
14
+ // existing disconnect handling fires).
15
+ //
16
+ // Isolation properties delivered:
17
+ // - The parent event loop only proxies (fully async) — UI/API stay
18
+ // instant no matter what the GPU is doing.
19
+ // - A child crash (uncatchable Metal OOM/SIGTRAP) 502s in-flight requests
20
+ // and RESPAWNS the engine; the parent — and the user's session — never
21
+ // go down. "The AI may crash, the UI never may."
22
+ // - P2 (multi-model): one child per model, each on its own socket; the
23
+ // parent routes by the request's `model` field and switches by
24
+ // spawn-overlap (new child loads while the old one keeps serving).
25
+ //
26
+ // Not proxied in v1: /ws/chat (WebSocket upgrade) — answered 501 with a
27
+ // pointer; the pi-based chat UI runs against the direct (non-isolated)
28
+ // server until WS proxying lands.
29
+
30
+ import { unlinkSync } from "node:fs";
31
+ import { tmpdir } from "node:os";
32
+ import { join } from "node:path";
33
+
34
+ /** How to (re)start the engine child. `argv[0]` is the executable. */
35
+ export interface EngineSpec {
36
+ argv: string[];
37
+ socketPath: string;
38
+ /** Max ms to wait for the child's /health after spawn (weights load —
39
+ * large models take a while). Default 15 min. */
40
+ readyTimeoutMs?: number;
41
+ }
42
+
43
+ const HOP_BY_HOP = new Set([
44
+ "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
45
+ "te", "trailer", "transfer-encoding", "upgrade",
46
+ ]);
47
+
48
+ export function defaultSocketPath(): string {
49
+ return join(tmpdir(), `mlx-bun-engine-${process.pid}.sock`);
50
+ }
51
+
52
+ /** The persistent engine child: spawn, health-gate, respawn-on-crash. */
53
+ export class EngineChild {
54
+ readonly spec: EngineSpec;
55
+ #proc: ReturnType<typeof Bun.spawn> | null = null;
56
+ #ready: Promise<void>;
57
+ #stopping = false;
58
+ restarts = 0;
59
+ #lastSpawnAt = 0;
60
+
61
+ constructor(spec: EngineSpec) {
62
+ this.spec = spec;
63
+ this.#ready = this.#spawn();
64
+ }
65
+
66
+ get ready(): Promise<void> {
67
+ return this.#ready;
68
+ }
69
+
70
+ get pid(): number | null {
71
+ return this.#proc?.pid ?? null;
72
+ }
73
+
74
+ async #spawn(): Promise<void> {
75
+ this.#lastSpawnAt = Date.now();
76
+ try { unlinkSync(this.spec.socketPath); } catch {}
77
+ const proc = Bun.spawn(this.spec.argv, {
78
+ stdio: ["ignore", "inherit", "inherit"], // load progress → user's terminal
79
+ env: process.env,
80
+ });
81
+ this.#proc = proc;
82
+ void proc.exited.then((code) => {
83
+ if (this.#stopping || this.#proc !== proc) return;
84
+ console.error(`[isolate] engine exited (code ${code}) — respawning`);
85
+ this.restarts++;
86
+ // Crash-loop backoff: an engine that dies within 10 s of spawning
87
+ // (bad flags, OOM on load) waits 5 s before the retry.
88
+ const delay = Date.now() - this.#lastSpawnAt < 10_000 ? 5_000 : 0;
89
+ this.#ready = new Promise((r) => setTimeout(r, delay)).then(() => this.#spawn());
90
+ });
91
+ // Health-gate: poll the child's /health over the socket until it
92
+ // answers — model load happens behind this.
93
+ const deadline = Date.now() + (this.spec.readyTimeoutMs ?? 15 * 60_000);
94
+ while (Date.now() < deadline) {
95
+ if (proc.killed || this.#proc !== proc) throw new Error("engine died during startup");
96
+ try {
97
+ const r = await fetch("http://engine/health", {
98
+ unix: this.spec.socketPath,
99
+ signal: AbortSignal.timeout(2_000),
100
+ } as RequestInit & { unix: string });
101
+ if (r.ok) return;
102
+ } catch { /* not up yet */ }
103
+ await new Promise((r) => setTimeout(r, 250));
104
+ }
105
+ throw new Error("engine did not become healthy in time");
106
+ }
107
+
108
+ /** Forward one request to the child; the response streams through.
109
+ * Bodyless requests (GET/HEAD) retry ONCE after a respawn completes —
110
+ * a request that races an engine crash waits out the reload instead of
111
+ * 502ing; requests with consumed stream bodies cannot be replayed and
112
+ * 502 by design (the doc's "fail in-flight cleanly"). */
113
+ async forward(request: Request): Promise<Response> {
114
+ try {
115
+ return await this.#forwardOnce(request);
116
+ } catch (e) {
117
+ const retriable = (request.method === "GET" || request.method === "HEAD") &&
118
+ !request.signal.aborted;
119
+ if (!retriable) throw e;
120
+ // The exited-handler may not have swapped #ready yet — give it a beat.
121
+ await new Promise((r) => setTimeout(r, 250));
122
+ return await this.#forwardOnce(request);
123
+ }
124
+ }
125
+
126
+ async #forwardOnce(request: Request): Promise<Response> {
127
+ await this.#ready;
128
+ const headers = new Headers();
129
+ request.headers.forEach((v, k) => {
130
+ if (!HOP_BY_HOP.has(k.toLowerCase())) headers.set(k, v);
131
+ });
132
+ const proxied = await fetch(request.url, {
133
+ method: request.method,
134
+ headers,
135
+ body: request.body,
136
+ signal: request.signal, // client abort → child sees the disconnect
137
+ redirect: "manual",
138
+ unix: this.spec.socketPath,
139
+ // Streaming request bodies need half-duplex; buffered bodies ignore it.
140
+ duplex: "half",
141
+ } as RequestInit & { unix: string; duplex: string });
142
+ const outHeaders = new Headers();
143
+ proxied.headers.forEach((v, k) => {
144
+ if (!HOP_BY_HOP.has(k.toLowerCase())) outHeaders.set(k, v);
145
+ });
146
+ return new Response(proxied.body, { status: proxied.status, headers: outHeaders });
147
+ }
148
+
149
+ stop(): void {
150
+ this.#stopping = true;
151
+ this.#proc?.kill();
152
+ try { unlinkSync(this.spec.socketPath); } catch {}
153
+ }
154
+ }
155
+
156
+ /** Model resolution the parent injects (Registry-backed in production;
157
+ * a plain map in tests). Returns null for unknown names — those keep the
158
+ * drop-in ignore semantics (served by the default model, like mlx-lm). */
159
+ export type ModelResolver = (query: string) => { repoId: string; path: string } | null;
160
+
161
+ /** Child-per-model pool (P2, runtime-isolation.md): LRU residency over
162
+ * engine children. Eviction is graceful and lossless — POST /admin/drain
163
+ * (gateway quiesce + demote the prompt cache to the SSD tier, keyed by
164
+ * that model's own fingerprint dir) and only then stop the child; the
165
+ * evicted model's whole state survives on disk, so bringing it back is a
166
+ * spawn + mmap fault-in + zero-copy KV restore, not a re-prefill. A
167
+ * SWITCH is spawn-overlap: the new child health-gates while the old one
168
+ * keeps serving — nobody's session is interrupted. */
169
+ export class ModelPool {
170
+ #children = new Map<string, EngineChild>(); // repoId → child
171
+ #lru: string[] = []; // most-recent last
172
+ #spawning = new Map<string, Promise<EngineChild>>();
173
+ readonly defaultKey: string;
174
+
175
+ constructor(
176
+ private readonly opts: {
177
+ rawArgs: string[];
178
+ selfArgv: string[]; // how to re-exec this CLI
179
+ poolMax: number; // resident children cap (≥1)
180
+ resolve: ModelResolver;
181
+ defaultKey: string;
182
+ defaultChild: EngineChild;
183
+ socketFor?: (repoId: string) => string;
184
+ },
185
+ ) {
186
+ this.defaultKey = opts.defaultKey;
187
+ this.#children.set(opts.defaultKey, opts.defaultChild);
188
+ this.#lru.push(opts.defaultKey);
189
+ }
190
+
191
+ get residentKeys(): string[] {
192
+ return [...this.#children.keys()];
193
+ }
194
+
195
+ child(key: string): EngineChild | undefined {
196
+ return this.#children.get(key);
197
+ }
198
+
199
+ #bump(key: string): void {
200
+ this.#lru = this.#lru.filter((k) => k !== key);
201
+ this.#lru.push(key);
202
+ }
203
+
204
+ #socketFor(repoId: string): string {
205
+ if (this.opts.socketFor) return this.opts.socketFor(repoId);
206
+ const slug = repoId.replace(/[^a-zA-Z0-9.-]+/g, "_").slice(-60);
207
+ return join(tmpdir(), `mlx-bun-engine-${process.pid}-${slug}.sock`);
208
+ }
209
+
210
+ /** Route a request's `model` field to a child, spawning on first use.
211
+ * undefined/empty/unknown → the default child (drop-in semantics). */
212
+ async childFor(modelField: string | undefined | null): Promise<EngineChild> {
213
+ if (!modelField) return this.#use(this.defaultKey);
214
+ // Already resident under this exact id?
215
+ if (this.#children.has(modelField)) return this.#use(modelField);
216
+ const rec = (() => {
217
+ try { return this.opts.resolve(modelField); } catch { return null; }
218
+ })();
219
+ if (!rec) return this.#use(this.defaultKey); // unknown → ignore, like mlx-lm
220
+ if (this.#children.has(rec.repoId)) return this.#use(rec.repoId);
221
+ const inflight = this.#spawning.get(rec.repoId);
222
+ if (inflight) return inflight.then(() => this.#use(rec.repoId));
223
+ const spawnP = (async () => {
224
+ const sock = this.#socketFor(rec.repoId);
225
+ const child = new EngineChild({
226
+ argv: [...this.opts.selfArgv, ...engineArgvForModel(this.opts.rawArgs, sock, rec.path)],
227
+ socketPath: sock,
228
+ });
229
+ // SPAWN-OVERLAP: the old model keeps serving while this loads.
230
+ await child.ready;
231
+ this.#children.set(rec.repoId, child);
232
+ this.#lru.push(rec.repoId);
233
+ await this.#evictOverCap();
234
+ return child;
235
+ })();
236
+ this.#spawning.set(rec.repoId, spawnP);
237
+ try {
238
+ return await spawnP;
239
+ } finally {
240
+ this.#spawning.delete(rec.repoId);
241
+ }
242
+ }
243
+
244
+ #use(key: string): EngineChild {
245
+ this.#bump(key);
246
+ return this.#children.get(key)!;
247
+ }
248
+
249
+ /** Evict least-recently-used children over the cap: drain + demote (the
250
+ * child's caches spill to its SSD fingerprint dir), then stop. */
251
+ async #evictOverCap(): Promise<void> {
252
+ while (this.#children.size > Math.max(1, this.opts.poolMax)) {
253
+ const victim = this.#lru.find((k) => this.#children.has(k));
254
+ if (!victim || this.#children.size <= 1) return;
255
+ const child = this.#children.get(victim)!;
256
+ this.#children.delete(victim);
257
+ this.#lru = this.#lru.filter((k) => k !== victim);
258
+ try {
259
+ await fetch("http://engine/admin/drain", {
260
+ method: "POST",
261
+ unix: child.spec.socketPath,
262
+ signal: AbortSignal.timeout(120_000),
263
+ } as RequestInit & { unix: string });
264
+ } catch { /* best-effort — state also persists via evict-spill */ }
265
+ child.stop();
266
+ console.log(`[isolate] evicted engine ${victim} (pool cap ${this.opts.poolMax})`);
267
+ }
268
+ }
269
+
270
+ stopAll(): void {
271
+ for (const c of this.#children.values()) c.stop();
272
+ this.#children.clear();
273
+ }
274
+ }
275
+
276
+ /** Generation endpoints whose JSON body carries the routing `model` field. */
277
+ const MODEL_ROUTED = new Set([
278
+ "/v1/chat/completions", "/v1/completions", "/v1/messages",
279
+ "/v1/responses", "/v1/embeddings",
280
+ ]);
281
+
282
+ export interface ProxyServerOptions {
283
+ port: number;
284
+ hostname?: string;
285
+ engine: EngineSpec;
286
+ /** Multi-model pool (P2). Omitted = single-engine P1 behavior. */
287
+ pool?: {
288
+ rawArgs: string[];
289
+ selfArgv: string[];
290
+ poolMax: number;
291
+ resolve: ModelResolver;
292
+ defaultKey: string;
293
+ socketFor?: (repoId: string) => string;
294
+ };
295
+ }
296
+
297
+ /** The parent server: UI-facing TCP listener that proxies to the engine
298
+ * child (or, with `pool`, to the request's model's child — P2 routing).
299
+ * Returns the Bun server + handles (tests kill/inspect). */
300
+ export function startProxyServer(opts: ProxyServerOptions): {
301
+ server: ReturnType<typeof Bun.serve>;
302
+ engine: EngineChild;
303
+ pool: ModelPool | null;
304
+ } {
305
+ const engine = new EngineChild(opts.engine);
306
+ const pool = opts.pool
307
+ ? new ModelPool({ ...opts.pool, defaultChild: engine })
308
+ : null;
309
+ const server = Bun.serve({
310
+ port: opts.port,
311
+ ...(opts.hostname ? { hostname: opts.hostname } : {}),
312
+ idleTimeout: 0,
313
+ async fetch(request) {
314
+ const url = new URL(request.url);
315
+ if (url.pathname === "/ws/chat")
316
+ return Response.json(
317
+ { error: { message: "WebSocket chat is not proxied under --isolate yet; run without --isolate for the web chat UI" } },
318
+ { status: 501 },
319
+ );
320
+ if (url.pathname === "/engine" && request.method === "GET")
321
+ return Response.json({
322
+ isolated: true,
323
+ pid: engine.pid,
324
+ restarts: engine.restarts,
325
+ socket: engine.spec.socketPath,
326
+ ...(pool ? { pool: { resident: pool.residentKeys, default: pool.defaultKey } } : {}),
327
+ });
328
+ try {
329
+ // P2 routing: generation endpoints carry `model` in the JSON body —
330
+ // buffer it (small), pick/spawn that model's child, forward the
331
+ // buffered body. Everything else rides the default engine.
332
+ if (pool && request.method === "POST" && MODEL_ROUTED.has(url.pathname)) {
333
+ const raw = await request.text();
334
+ let modelField: string | undefined;
335
+ try { modelField = (JSON.parse(raw) as { model?: string }).model; } catch { /* malformed → default child answers with its own 400 */ }
336
+ const target = await pool.childFor(modelField);
337
+ return await target.forward(new Request(request.url, {
338
+ method: request.method,
339
+ headers: request.headers,
340
+ body: raw,
341
+ signal: request.signal,
342
+ }));
343
+ }
344
+ return await engine.forward(request);
345
+ } catch (e) {
346
+ if (request.signal.aborted) return new Response(null, { status: 499 });
347
+ return Response.json(
348
+ {
349
+ error: {
350
+ message: `inference engine unavailable (${(e as Error).message}) — respawning; retry shortly`,
351
+ type: "engine_unavailable",
352
+ },
353
+ },
354
+ { status: 502 },
355
+ );
356
+ }
357
+ },
358
+ });
359
+ return { server, engine, pool };
360
+ }
361
+
362
+ /** serve's value-taking flags — needed to tell a flag VALUE from the bare
363
+ * model positional when stripping. Keep in sync with cli.ts serve opts
364
+ * (a stale entry here mis-classifies one token as a positional). */
365
+ const SERVE_VALUE_FLAGS = new Set([
366
+ "--port", "--host", "--model", "--query", "--draft-model",
367
+ "--num-draft-tokens", "--draft-kind", "--ngram-max", "--ngram-min",
368
+ "--batch", "--decode-concurrency", "--kv-quant",
369
+ "--kv-group-size", "--quantized-kv-start", "--kv-budget",
370
+ "--memory-budget", "--prompt-cache", "--ssd-cache", "--ssd-cache-max",
371
+ "--ssd-demote-idle", "--adapter", "--max-tokens", "--unix",
372
+ "--model-pool", "--compiled-decode", "--compiled-activations",
373
+ "--fused-sdpa", "--thinking",
374
+ ]);
375
+ const PARENT_BOOL_FLAGS = new Set(["--isolate", "--open", "--no-open"]);
376
+ const PARENT_VALUE_FLAGS = new Set(["--port", "--host", "--model-pool"]);
377
+
378
+ /** Build the engine child's argv from the parent's raw CLI args: strip the
379
+ * parent-only flags (--isolate, --port/--host — the child binds the
380
+ * socket; --open/--no-open — the browser is the parent's business;
381
+ * --model-pool — pool policy is the parent's) and append the socket.
382
+ * Exported for tests. */
383
+ export function engineArgv(rawArgs: string[], socketPath: string): string[] {
384
+ const out: string[] = [];
385
+ for (let i = 0; i < rawArgs.length; i++) {
386
+ const a = rawArgs[i]!;
387
+ if (PARENT_BOOL_FLAGS.has(a)) continue;
388
+ if (PARENT_VALUE_FLAGS.has(a)) { i++; continue; }
389
+ out.push(a);
390
+ }
391
+ out.push("--unix", socketPath);
392
+ return out;
393
+ }
394
+
395
+ /** engineArgv for a SPECIFIC model (the pool's per-model children): also
396
+ * strip every model selector — --model/--query values AND the bare
397
+ * positional query after `serve` — then pin `--model <path>`. */
398
+ export function engineArgvForModel(
399
+ rawArgs: string[], socketPath: string, modelPath: string,
400
+ ): string[] {
401
+ const stripped: string[] = [];
402
+ let positionalSkipped = false;
403
+ for (let i = 0; i < rawArgs.length; i++) {
404
+ const a = rawArgs[i]!;
405
+ if (PARENT_BOOL_FLAGS.has(a)) continue;
406
+ if (a === "--model" || a === "--query") { i++; continue; }
407
+ if (PARENT_VALUE_FLAGS.has(a)) { i++; continue; }
408
+ if (SERVE_VALUE_FLAGS.has(a)) { stripped.push(a, rawArgs[++i]!); continue; }
409
+ if (!a.startsWith("-") && a !== "serve" && !positionalSkipped) {
410
+ positionalSkipped = true; // the bare model query
411
+ continue;
412
+ }
413
+ stripped.push(a);
414
+ }
415
+ if (stripped[0] !== "serve") stripped.unshift("serve");
416
+ return [...stripped, "--model", modelPath, "--unix", socketPath];
417
+ }
@@ -0,0 +1,44 @@
1
+ // Lane registry — the in-process correlation channel between server.ts (which
2
+ // decides + knows the serving lane per request) and pi-web.ts (the WS bridge,
3
+ // same process, different module). Exists because the pi SDK's own usage
4
+ // parsing (parseChunkUsage in @earendil-works/pi-ai's openai-completions.js)
5
+ // hard-codes the OpenAI usage fields it reads (prompt_tokens/completion_tokens/
6
+ // prompt_tokens_details only) and drops any custom field — so a `lane` value
7
+ // riding on the HTTP usage payload never reaches the SDK's AssistantMessage.usage
8
+ // or the turn_end AgentSessionEvent. What DOES survive is `responseId` (set from
9
+ // the chat-completion response's `id` field, which our server always sets to
10
+ // `chatcmpl-<uuid>`), so this registry is keyed by that same id: server.ts
11
+ // records the lane the moment it decides one (before generation starts, so it's
12
+ // available even in flight), pi-web.ts looks it up via the turn's
13
+ // AssistantMessage.responseId once the turn ends.
14
+ //
15
+ // Bounded + self-expiring (a small LRU-ish cap, not a TTL timer) since this is
16
+ // a same-process debugging/telemetry aid, not a durable store — entries for
17
+ // long-dead requests must not leak memory over a long-running server.
18
+
19
+ export type Lane = "serial" | "serial+spec" | "batched";
20
+
21
+ const MAX_ENTRIES = 512;
22
+ const lanes = new Map<string, Lane>();
23
+
24
+ /** Record the lane a request (by its `chatcmpl-…`/`cmpl-…` id) was served on.
25
+ * Call once the lane is known — safe to call again if it refines (e.g.
26
+ * "serial" -> "serial+spec" once spec stats are available). */
27
+ export function recordLane(requestId: string, lane: Lane): void {
28
+ lanes.delete(requestId); // re-insert at the end (Map preserves insertion order)
29
+ lanes.set(requestId, lane);
30
+ if (lanes.size > MAX_ENTRIES) {
31
+ const oldest = lanes.keys().next().value;
32
+ if (oldest !== undefined) lanes.delete(oldest);
33
+ }
34
+ }
35
+
36
+ /** Look up the lane for a request id, if still resident. */
37
+ export function getLane(requestId: string): Lane | undefined {
38
+ return lanes.get(requestId);
39
+ }
40
+
41
+ /** Test-only: drop everything. */
42
+ export function clearLaneRegistry(): void {
43
+ lanes.clear();
44
+ }