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.
- package/README.md +66 -21
- package/THIRD_PARTY_LICENSES.md +1 -0
- package/docs/archive/mlx-bun-lab-report.html +275 -0
- package/docs/archive/wwdc-mlx-bun.html +848 -0
- package/docs/design/audio-input-plan.md +356 -0
- package/docs/design/batching-perf-path.md +255 -0
- package/docs/design/batching-v2-plan.md +23 -9
- package/docs/design/bucketing-stage.md +2 -1
- package/docs/design/compat-cli-surface-design.md +4 -3
- package/docs/design/decode-speed-program.md +172 -0
- package/docs/design/docs-reorg-plan.md +1 -1
- package/docs/design/dspark-serving-program.md +371 -0
- package/docs/design/dspark-speculative-decoding.md +167 -5
- package/docs/design/faithful-l1-consolidation.md +310 -0
- package/docs/design/generic-model-support.md +8 -5
- package/docs/design/grammar-spec-batching-integration.md +301 -0
- package/docs/design/minicpm5-decode-megakernel.md +16 -11
- package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
- package/docs/design/omlx-adoption-map.md +98 -0
- package/docs/design/orpo-training.md +34 -30
- package/docs/design/paged-kv-cache.md +214 -0
- package/docs/design/parallel-slots.md +82 -49
- package/docs/design/parity-tier-dag.md +43 -33
- package/docs/design/repo-cleanup-plan.md +189 -0
- package/docs/design/runtime-isolation.md +19 -3
- package/docs/design/segmented-backward-training.md +18 -2
- package/docs/design/ssd-kv-cold-tier.md +288 -0
- package/docs/design/structured-output.md +484 -0
- package/docs/design/the-dreaming-master-plan.md +2 -3
- package/docs/design/turboquant-kv.md +235 -0
- package/docs/design/unified-engine-frontier-plan.md +774 -0
- package/docs/design/web-chat-beat-matrix.md +315 -0
- package/docs/design/web-chat-redesign.md +1112 -0
- package/docs/investigations/decode-roofline-lookagain.md +92 -6
- package/docs/investigations/dspark-handoff.md +182 -16
- package/docs/investigations/kernel-perf-review-2026-07.md +105 -26
- package/docs/investigations/lab-build-journal.md +2 -2
- package/docs/investigations/openwiki-evaluation.md +249 -0
- package/docs/planning/IDEAS.md +14 -0
- package/docs/planning/PRODUCT_ROADMAP.md +2 -3
- package/docs/planning/chat-ui-vision-memory-first.md +199 -0
- package/docs/planning/chat-ui-vision.md +50 -0
- package/docs/planning/memory-docs-and-dag-plan.md +15 -6
- package/docs/planning/release-notes-v0.0.10.md +57 -0
- package/docs/planning/release-notes-v0.0.11.md +239 -0
- package/docs/reference/cli.md +35 -16
- package/docs/reference/distribution.md +13 -6
- package/docs/reference/embedding.md +35 -14
- package/docs/reference/features-matrix.md +97 -0
- package/docs/reference/library-api.md +30 -9
- package/docs/reference/memory.md +26 -6
- package/docs/reference/models.md +11 -1
- package/docs/reference/orpo-quickstart.md +3 -4
- package/docs/reference/server-api.md +634 -22
- package/docs/reference/server-config.md +251 -119
- package/docs/reference/training.md +20 -16
- package/package.json +5 -3
- package/src/anthropic.ts +13 -0
- package/src/audio/conformer.ts +647 -0
- package/src/audio/decode.ts +133 -0
- package/src/audio/features.ts +202 -0
- package/src/audio/transcode.ts +67 -0
- package/src/cli.ts +402 -107
- package/src/config.ts +49 -2
- package/src/download.ts +40 -0
- package/src/eval/README.md +7 -6
- package/src/eval/kl.ts +59 -3
- package/src/eval/quality-db.ts +2 -2
- package/src/eval/runner.ts +3 -3
- package/src/expert-offload.ts +15 -2
- package/src/flags.ts +13 -0
- package/src/generate.ts +330 -16
- package/src/grammar.ts +585 -0
- package/src/hub-rest.ts +242 -0
- package/src/kv-store.ts +618 -122
- package/src/lora.ts +8 -0
- package/src/md-modules.d.ts +42 -0
- package/src/media-fetch.ts +284 -0
- package/src/memory/rest.ts +245 -0
- package/src/memory/vault.ts +74 -0
- package/src/mlx/array.ts +127 -19
- package/src/mlx/autograd.ts +2 -2
- package/src/mlx/ffi.ts +27 -3
- package/src/mlx/metal-kernel.ts +7 -4
- package/src/mlx/ops.ts +179 -23
- package/src/mlx/turboquant-ops.ts +831 -0
- package/src/mlx/turboquant-tables.ts +237 -0
- package/src/model/batched-mask.ts +62 -1
- package/src/model/batched-quant.ts +206 -0
- package/src/model/batched-rotating-quant.ts +346 -0
- package/src/model/batched-rotating.ts +46 -1
- package/src/model/compiled-decode.ts +4 -15
- package/src/model/factory.ts +10 -4
- package/src/model/gemma4-base.ts +391 -34
- package/src/model/gemma4.ts +122 -33
- package/src/model/generated/gemma4-12b.ts +9 -13
- package/src/model/generated/gemma4-26b.ts +10 -5
- package/src/model/generated/gemma4-e4b.ts +14 -9
- package/src/model/minicpm5.ts +58 -102
- package/src/model/paged-kv.ts +253 -0
- package/src/model/qwen3-delta.ts +123 -15
- package/src/model/qwen3-moe.ts +404 -0
- package/src/model/qwen3.ts +9 -8
- package/src/model/qwen3_5.ts +99 -25
- package/src/model/support.ts +12 -1
- package/src/model/universal/dense.ts +40 -5
- package/src/model/universal/rope.ts +36 -21
- package/src/pi-web.ts +1299 -82
- package/src/preflight.ts +27 -8
- package/src/prompt-cache.ts +338 -32
- package/src/quantize/quantizer.ts +16 -1
- package/src/registry.ts +61 -3
- package/src/serve/batch-scheduler.ts +1001 -76
- package/src/serve/generation-gateway.ts +292 -30
- package/src/serve/isolate.ts +417 -0
- package/src/serve/lane-registry.ts +44 -0
- package/src/serve/session-search.ts +326 -0
- package/src/server.ts +1460 -105
- package/src/spec/assistant-source.ts +150 -0
- package/src/spec/deepspec-source.ts +143 -0
- package/src/spec/dflash-source.ts +124 -0
- package/src/spec/dspark/ab-stats.ts +136 -0
- package/src/spec/dspark/calibration.ts +112 -0
- package/src/spec/dspark/deepspec-module.ts +801 -0
- package/src/spec/dspark/generate-dflash.ts +72 -20
- package/src/spec/dspark/loader.ts +23 -0
- package/src/spec/dspark/module-dflash.ts +278 -36
- package/src/spec/dspark/quantize-drafter.ts +91 -0
- package/src/spec/generate.ts +59 -25
- package/src/spec/ngram-source.ts +132 -0
- package/src/spec/serve-loop.ts +536 -0
- package/src/spec/source.ts +93 -0
- package/src/spec/two-model.ts +171 -0
- package/src/ssd-cache.ts +285 -0
- package/src/tokenizer.ts +288 -2
- package/src/tool-approvals.ts +131 -0
- package/src/tool-call.ts +408 -20
- package/src/train/flash-cce.ts +29 -26
- package/src/train/job.ts +0 -2
- package/src/train/loss.ts +53 -11
- package/src/train/prefix-shared.ts +30 -7
- package/src/train/segmented.ts +150 -46
- package/src/train/trainer.ts +22 -15
- package/src/vision/prompt.ts +217 -48
- package/src/web/app.html +1184 -1789
- package/src/web/app.js +5667 -0
- package/src/web/icon.svg +13 -0
- package/src/web/manifest.webmanifest +24 -0
- package/src/web/src/adapters-panel.ts +288 -0
- package/src/web/src/api.ts +57 -0
- package/src/web/src/assistant.ts +485 -0
- package/src/web/src/chat.ts +1050 -0
- package/src/web/src/composer.ts +959 -0
- package/src/web/src/dataset.ts +132 -0
- package/src/web/src/finetune.ts +267 -0
- package/src/web/src/hub.ts +428 -0
- package/src/web/src/main.ts +98 -0
- package/src/web/src/markdown.ts +0 -0
- package/src/web/src/memory-panel.ts +572 -0
- package/src/web/src/model-picker.ts +159 -0
- package/src/web/src/palette.ts +346 -0
- package/src/web/src/protocol.ts +62 -0
- package/src/web/src/quantize.ts +167 -0
- package/src/web/src/rag.ts +274 -0
- package/src/web/src/sessions.ts +469 -0
- package/src/web/src/shell.ts +804 -0
- package/src/web/src/status.ts +252 -0
- package/src/web/src/ui-catalog.ts +92 -0
- package/src/web/sw.js +71 -0
- package/src/web/vendor/README.md +61 -0
- package/src/web/vendor/hljs-LICENSE +29 -0
- package/src/web/vendor/hljs-theme.css +59 -0
- package/src/web/vendor/hljs.js +26 -0
- package/src/model/fused-decode-kernel.ts +0 -312
- package/src/model/fused-geglu-kernel.ts +0 -198
- package/src/model/fused-mlp-kernel.ts +0 -169
- package/src/model/fused-swiglu-kernel.ts +0 -240
- package/src/model/steel-linear-kernel.ts +0 -96
- 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
|
+
}
|