mlx-bun 0.0.4
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/LICENSE +21 -0
- package/README.md +362 -0
- package/THIRD_PARTY_LICENSES.md +41 -0
- package/bin/mlx-bun.mjs +32 -0
- package/docs/curve-designer.html +303 -0
- package/docs/design/adapters-end-to-end.md +201 -0
- package/docs/design/compat-cli-surface-design.md +227 -0
- package/docs/design/docs-reorg-plan.md +152 -0
- package/docs/design/hlg-sampling.md +340 -0
- package/docs/design/memory-system.md +339 -0
- package/docs/design/optimization_plan.md +344 -0
- package/docs/design/parallel-slots.md +515 -0
- package/docs/design/runtime-isolation.md +247 -0
- package/docs/design/segmented-backward-training.md +508 -0
- package/docs/investigations/chunk-finetune-experiment.md +84 -0
- package/docs/investigations/curve-bisector-routing.md +123 -0
- package/docs/investigations/curve-runs/bisector-route.json +98 -0
- package/docs/investigations/curve-runs/bisector-trace.json +1393 -0
- package/docs/investigations/curve-runs/curve-terrain.json +1179 -0
- package/docs/investigations/curve-runs/curve-terrain.svg +42 -0
- package/docs/investigations/curve-terrain.html +105 -0
- package/docs/investigations/expert-offload-single-user-moe.md +292 -0
- package/docs/investigations/hlg-ab.html +34 -0
- package/docs/investigations/hlg-explorer.html +238 -0
- package/docs/investigations/hlg-figs/cliff.svg +1 -0
- package/docs/investigations/hlg-figs/cross.svg +1 -0
- package/docs/investigations/hlg-figs/edges.svg +1 -0
- package/docs/investigations/hlg-figs/k20-grids.svg +1 -0
- package/docs/investigations/hlg-figs/k20-peaks.svg +1 -0
- package/docs/investigations/hlg-figs/lw-curve.svg +1 -0
- package/docs/investigations/hlg-figs/terrain.svg +1 -0
- package/docs/investigations/hlg-report.html +262 -0
- package/docs/investigations/hlg-runs/hlg-ab.json +149 -0
- package/docs/investigations/hlg-runs/hlg-blind-prompts.md +87 -0
- package/docs/investigations/hlg-runs/hlg-blind.json +107 -0
- package/docs/investigations/hlg-runs/hlg-cliff.json +197 -0
- package/docs/investigations/hlg-runs/hlg-cross.json +505 -0
- package/docs/investigations/hlg-runs/hlg-edge.json +388 -0
- package/docs/investigations/hlg-runs/hlg-eval1.json +25 -0
- package/docs/investigations/hlg-runs/hlg-frontier.json +86 -0
- package/docs/investigations/hlg-runs/hlg-grid.json +242 -0
- package/docs/investigations/hlg-runs/hlg-grid2.json +343 -0
- package/docs/investigations/hlg-runs/hlg-lw-coarse.json +110 -0
- package/docs/investigations/hlg-runs/hlg-map.json +383 -0
- package/docs/investigations/hlg-runs/run0-original.log +159 -0
- package/docs/investigations/hlg-runs/runA-recommended.log +131 -0
- package/docs/investigations/hlg-runs/runAA-cliff-cpm5.log +26 -0
- package/docs/investigations/hlg-runs/runAB-grid2-e4b.log +18 -0
- package/docs/investigations/hlg-runs/runAC-peak-e4b-partial.log +25 -0
- package/docs/investigations/hlg-runs/runAD-ab-samples.log +8 -0
- package/docs/investigations/hlg-runs/runAE-edge-e4b.log +51 -0
- package/docs/investigations/hlg-runs/runAF-logit-calibration.log +10 -0
- package/docs/investigations/hlg-runs/runAG-eval-config2.log +16 -0
- package/docs/investigations/hlg-runs/runB-gain-variants.log +192 -0
- package/docs/investigations/hlg-runs/runC-pivots.log +162 -0
- package/docs/investigations/hlg-runs/runD-oetf.log +132 -0
- package/docs/investigations/hlg-runs/runE-eotf.log +162 -0
- package/docs/investigations/hlg-runs/runF-shaper-working.log +86 -0
- package/docs/investigations/hlg-runs/runG-shaper-sweep.log +101 -0
- package/docs/investigations/hlg-runs/runH-wsweep.log +176 -0
- package/docs/investigations/hlg-runs/runI-asweep.log +176 -0
- package/docs/investigations/hlg-runs/runJ-knobmap.log +49 -0
- package/docs/investigations/hlg-runs/runK-frontier.log +19 -0
- package/docs/investigations/hlg-runs/runL-widemap.log +65 -0
- package/docs/investigations/hlg-runs/runM-frontier-emb.log +15 -0
- package/docs/investigations/hlg-runs/runN-verify.log +116 -0
- package/docs/investigations/hlg-runs/runO-frontier-seed5000.log +15 -0
- package/docs/investigations/hlg-runs/runP-grid-AxSm.log +15 -0
- package/docs/investigations/hlg-runs/runQ-grid-tgW.log +15 -0
- package/docs/investigations/hlg-runs/runR-blind-transcript.log +79 -0
- package/docs/investigations/hlg-runs/runS-cross-coherence.log +33 -0
- package/docs/investigations/hlg-runs/runT-cross-cpm5.log +33 -0
- package/docs/investigations/hlg-runs/runU-cross-12b.log +33 -0
- package/docs/investigations/hlg-runs/runV-lw-e4b.log +22 -0
- package/docs/investigations/hlg-runs/runW-lw-12b.log +22 -0
- package/docs/investigations/hlg-runs/runX-lw-cpm5.log +22 -0
- package/docs/investigations/hlg-runs/runY-cliff-e4b.log +26 -0
- package/docs/investigations/hlg-runs/runZ-cliff-12b.log +26 -0
- package/docs/investigations/hlg-sampling-investigation.md +970 -0
- package/docs/investigations/lab-build-journal.md +157 -0
- package/docs/investigations/pi-builtin-investigation.md +308 -0
- package/docs/investigations/starter-model-port-handoff.md +323 -0
- package/docs/planning/IDEAS.md +26 -0
- package/docs/planning/PRODUCT_ROADMAP.md +426 -0
- package/docs/planning/ResearchTopics.md +119 -0
- package/docs/planning/chunk-task-roadmap.md +77 -0
- package/docs/planning/journal.md +155 -0
- package/docs/reference/distribution.md +121 -0
- package/docs/reference/embedding.md +168 -0
- package/docs/reference/library-api.md +153 -0
- package/docs/reference/server-api.md +224 -0
- package/docs/reference/server-config.md +243 -0
- package/docs/segmented-backward-handoff.md +148 -0
- package/package.json +68 -0
- package/src/anthropic.ts +491 -0
- package/src/chat-template.ts +186 -0
- package/src/cli.ts +1075 -0
- package/src/config.ts +229 -0
- package/src/curve-sampler.ts +122 -0
- package/src/dataset/generators.ts +1087 -0
- package/src/dataset/index.ts +12 -0
- package/src/dataset/job.ts +81 -0
- package/src/dataset/llm.ts +71 -0
- package/src/dataset/registry.ts +528 -0
- package/src/download.ts +283 -0
- package/src/eval/README.md +86 -0
- package/src/eval/kl-prompts.ts +29 -0
- package/src/eval/kl.ts +294 -0
- package/src/eval/quality-db.ts +92 -0
- package/src/eval/runner.ts +102 -0
- package/src/eval/score.ts +49 -0
- package/src/eval/tasks/bfcl.ts +500 -0
- package/src/eval/tasks/gsm8k.ts +85 -0
- package/src/eval/tasks/hashhop.ts +109 -0
- package/src/eval/tasks/humaneval.ts +252 -0
- package/src/eval/tasks/ifeval.ts +354 -0
- package/src/eval/tasks/mmlu.ts +145 -0
- package/src/evaldb.ts +199 -0
- package/src/expert-offload-build.ts +77 -0
- package/src/expert-offload.ts +61 -0
- package/src/expert-trace.ts +67 -0
- package/src/fit.ts +254 -0
- package/src/generate.ts +451 -0
- package/src/harness-pi.ts +177 -0
- package/src/hf-push.ts +628 -0
- package/src/index.ts +22 -0
- package/src/jobs/db.ts +139 -0
- package/src/jobs/index.ts +29 -0
- package/src/jobs/job-entry.ts +106 -0
- package/src/jobs/runner.ts +304 -0
- package/src/jobs/sse.ts +114 -0
- package/src/jobs/types.ts +58 -0
- package/src/kv-store.ts +185 -0
- package/src/lora.ts +314 -0
- package/src/md-modules.d.ts +7 -0
- package/src/mlx/array.ts +237 -0
- package/src/mlx/autograd.ts +267 -0
- package/src/mlx/checkpoint.ts +116 -0
- package/src/mlx/compile.ts +158 -0
- package/src/mlx/custom-vjp.ts +140 -0
- package/src/mlx/ffi.ts +433 -0
- package/src/mlx/metal-kernel.ts +130 -0
- package/src/mlx/ops.ts +707 -0
- package/src/mmap.ts +90 -0
- package/src/model/batched-mask.ts +204 -0
- package/src/model/batched-rotating.ts +300 -0
- package/src/model/compiled-decode.ts +681 -0
- package/src/model/factory.ts +27 -0
- package/src/model/fingerprint.ts +33 -0
- package/src/model/flash-attention.ts +674 -0
- package/src/model/fused-decode-kernel.ts +305 -0
- package/src/model/fused-geglu-kernel.ts +87 -0
- package/src/model/gemma4-base.ts +1616 -0
- package/src/model/gemma4.ts +1012 -0
- package/src/model/generated/gemma4-12b.ts +526 -0
- package/src/model/generated/gemma4-26b.ts +500 -0
- package/src/model/generated/gemma4-e4b.ts +757 -0
- package/src/model/generated/index.ts +18 -0
- package/src/model/minicpm5.ts +278 -0
- package/src/model/qwen3-delta.ts +224 -0
- package/src/model/qwen3_5.ts +429 -0
- package/src/model/support.ts +46 -0
- package/src/native-pack.ts +152 -0
- package/src/pi-provider.ts +95 -0
- package/src/pi-terminal.ts +264 -0
- package/src/pi-web.ts +924 -0
- package/src/preflight.ts +123 -0
- package/src/prompt-cache.ts +113 -0
- package/src/quantize/allocator.ts +627 -0
- package/src/quantize/calibration.ts +180 -0
- package/src/quantize/config-writer.ts +132 -0
- package/src/quantize/index.ts +57 -0
- package/src/quantize/job.ts +136 -0
- package/src/quantize/quantizer.ts +462 -0
- package/src/quantize/safetensors-writer.ts +171 -0
- package/src/quantize/sensitivity.ts +234 -0
- package/src/registry.ts +336 -0
- package/src/responses.ts +774 -0
- package/src/safetensors.ts +158 -0
- package/src/sampler.ts +517 -0
- package/src/serve/batch-scheduler.ts +362 -0
- package/src/serve/generation-gateway.ts +160 -0
- package/src/server.ts +1891 -0
- package/src/spec/drafter.ts +261 -0
- package/src/spec/generate.ts +221 -0
- package/src/status-page.html +497 -0
- package/src/tokenizer.ts +38 -0
- package/src/tool-call.ts +242 -0
- package/src/train/dataset.ts +461 -0
- package/src/train/export.ts +32 -0
- package/src/train/forward.ts +229 -0
- package/src/train/index.ts +43 -0
- package/src/train/job.ts +148 -0
- package/src/train/lora-params.ts +194 -0
- package/src/train/loss.ts +342 -0
- package/src/train/merge.ts +195 -0
- package/src/train/optimizer.ts +176 -0
- package/src/train/rank.ts +101 -0
- package/src/train/segmented.ts +520 -0
- package/src/train/trainer.ts +600 -0
- package/src/tui.ts +161 -0
- package/src/vision/embedder.ts +168 -0
- package/src/vision/preprocess.ts +247 -0
- package/src/vision/prompt.ts +158 -0
- package/src/vision/siglip.ts +599 -0
- package/src/web/app.html +2471 -0
- package/src/web/skills/web-research/SKILL.md +44 -0
- package/src/web/skills.ts +59 -0
- package/src/web-tools.ts +426 -0
- package/src/weights.ts +91 -0
package/src/generate.ts
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
// Streaming generation — port of mlx-lm's generate_step:
|
|
2
|
+
// - prefill in chunks; cache state evaluated per chunk (bounded transient
|
|
3
|
+
// memory), logits never computed for non-final prefill positions
|
|
4
|
+
// - decode pipelining via mx.async_eval: step n+1's graph is built and
|
|
5
|
+
// dispatched before step n's token is read back, so the GPU never idles
|
|
6
|
+
// on the JS round-trip
|
|
7
|
+
// - sampling stays on-device; only the chosen token id crosses to JS
|
|
8
|
+
|
|
9
|
+
import { MlxArray, gpuStream } from "./mlx/array";
|
|
10
|
+
import {
|
|
11
|
+
clearCache,
|
|
12
|
+
maxRecommendedWorkingSetSize,
|
|
13
|
+
setWiredLimit,
|
|
14
|
+
synchronize,
|
|
15
|
+
} from "./mlx/ffi";
|
|
16
|
+
import * as ops from "./mlx/ops";
|
|
17
|
+
import { CompiledDecode } from "./model/compiled-decode";
|
|
18
|
+
import { Gemma4Model, KVCache, RotatingKVCache, type Cache } from "./model/gemma4";
|
|
19
|
+
import type { RuntimeModel } from "./model/factory";
|
|
20
|
+
import type { KvQuantSpec } from "./config";
|
|
21
|
+
import {
|
|
22
|
+
makeLogitsProcessors, makeSampler,
|
|
23
|
+
type LogitsProcessorOptions, type SamplerOptions, toLogprobs,
|
|
24
|
+
} from "./sampler";
|
|
25
|
+
|
|
26
|
+
export interface GenerateOptions extends SamplerOptions, LogitsProcessorOptions {
|
|
27
|
+
maxTokens?: number;
|
|
28
|
+
eosTokenIds?: number[];
|
|
29
|
+
prefillChunkSize?: number;
|
|
30
|
+
/** Pre-warmed KV caches (e.g. from the prompt cache). cache[0].offset
|
|
31
|
+
* prompt tokens are treated as already prefilled; only the suffix is
|
|
32
|
+
* forwarded. Caller keeps ownership — generate() will not dispose. */
|
|
33
|
+
cache?: Cache[];
|
|
34
|
+
/** Vision path: pre-merged (unscaled) input embeddings [1, L, hidden]
|
|
35
|
+
* covering the whole prompt; prefilled in one shot (no chunking).
|
|
36
|
+
* Caller keeps ownership. */
|
|
37
|
+
promptEmbeddings?: MlxArray;
|
|
38
|
+
/** bool [L] marking image tokens (bidirectional attention among them). */
|
|
39
|
+
imageMask?: MlxArray;
|
|
40
|
+
/** Quantize full-attention KV caches to this many bits (4 or 8).
|
|
41
|
+
* Rotating (sliding-window) caches stay bf16 — they're window-capped
|
|
42
|
+
* and upstream rotating-cache quantization is NYI. */
|
|
43
|
+
kvBits?: number;
|
|
44
|
+
kvGroupSize?: number;
|
|
45
|
+
/** Per-layer mixed-precision KV from kv_config.json (config.kvQuant).
|
|
46
|
+
* Overrides kvBits, like optiq serve's --kv-config. layerIdx indexes
|
|
47
|
+
* the cache list (== layer index for the donor prefix); entries for
|
|
48
|
+
* rotating/sliding caches are skipped until Phase 9. */
|
|
49
|
+
kvConfig?: KvQuantSpec[];
|
|
50
|
+
/** Convert once a cache's offset reaches this (uniform-kvBits default
|
|
51
|
+
* 5000 = mlx-lm; kvConfig default 0 = optiq serve). */
|
|
52
|
+
quantizedKvStart?: number;
|
|
53
|
+
/** Mounted LoRA adapter ids to apply (resolved/validated by
|
|
54
|
+
* AdapterManager.resolveSpec). Residuals sum in order. Set on the
|
|
55
|
+
* model's LoraState for exactly the duration of this generation —
|
|
56
|
+
* a plain field, safe because the generation queue is serialized. */
|
|
57
|
+
adapters?: string[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Port of mlx-lm maybe_quantize_kv_cache + optiq serve's per-layer
|
|
61
|
+
* patched variant (incl. patch_rotating_to_quantized: rotating caches
|
|
62
|
+
* convert too — Phase 9). kvConfig overrides kvBits, matching optiq's
|
|
63
|
+
* --kv-config precedence; shipped kv_config.json files list
|
|
64
|
+
* full-attention layers only, so rotating quantization engages through
|
|
65
|
+
* uniform kvBits (like optiq --kv-bits) or a config that names sliding
|
|
66
|
+
* layers. */
|
|
67
|
+
export function maybeQuantizeKv(cache: Cache[], options: GenerateOptions): void {
|
|
68
|
+
const { kvBits, kvConfig } = options;
|
|
69
|
+
if (!kvBits && !kvConfig?.length) return;
|
|
70
|
+
const start = options.quantizedKvStart ?? (kvConfig?.length ? 0 : 5000);
|
|
71
|
+
const byLayer = kvConfig?.length
|
|
72
|
+
? new Map(kvConfig.map((e) => [e.layerIdx, e]))
|
|
73
|
+
: null;
|
|
74
|
+
for (let i = 0; i < cache.length; i++) {
|
|
75
|
+
const c = cache[i]!;
|
|
76
|
+
if (!(c instanceof KVCache || c instanceof RotatingKVCache) || c.offset < start) continue;
|
|
77
|
+
// OptiQ's mixed-KV hook skips empty caches: the first prompt prefill
|
|
78
|
+
// runs bf16, then the populated cache is quantized before decode.
|
|
79
|
+
// Converting empty caches at start=0 makes prefill itself quantized
|
|
80
|
+
// and diverges from the oracle path.
|
|
81
|
+
if (c.offset === 0) continue;
|
|
82
|
+
if (byLayer) {
|
|
83
|
+
const e = byLayer.get(i);
|
|
84
|
+
if (e) cache[i] = c.toQuantized(e.groupSize, e.bits);
|
|
85
|
+
} else {
|
|
86
|
+
cache[i] = c.toQuantized(options.kvGroupSize ?? 64, kvBits!);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface GenerateStats {
|
|
92
|
+
promptTokens: number;
|
|
93
|
+
/** Prompt tokens skipped via a pre-warmed cache. */
|
|
94
|
+
cachedTokens: number;
|
|
95
|
+
generatedTokens: number;
|
|
96
|
+
prefillTps: number;
|
|
97
|
+
decodeTps: number;
|
|
98
|
+
prefillMs: number;
|
|
99
|
+
decodeMs: number;
|
|
100
|
+
/** Exact token sequence whose KV now lives in the cache (prompt + every
|
|
101
|
+
* decoded token that was forwarded, including a trailing EOS the
|
|
102
|
+
* pipeline forwarded before reading it). For PromptCache.put(). */
|
|
103
|
+
cacheTokens: number[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface GeneratedToken {
|
|
107
|
+
token: number;
|
|
108
|
+
index: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export class Generation implements AsyncIterable<GeneratedToken> {
|
|
112
|
+
stats: GenerateStats | null = null;
|
|
113
|
+
readonly #iter: AsyncGenerator<GeneratedToken, GenerateStats>;
|
|
114
|
+
|
|
115
|
+
constructor(iter: AsyncGenerator<GeneratedToken, GenerateStats>) {
|
|
116
|
+
this.#iter = iter;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async *[Symbol.asyncIterator](): AsyncGenerator<GeneratedToken> {
|
|
120
|
+
try {
|
|
121
|
+
while (true) {
|
|
122
|
+
const r = await this.#iter.next();
|
|
123
|
+
if (r.done) {
|
|
124
|
+
this.stats = r.value;
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
yield r.value;
|
|
128
|
+
}
|
|
129
|
+
} finally {
|
|
130
|
+
// Consumer broke early (e.g. a decoded-text stop sequence fired):
|
|
131
|
+
// drive the inner generator's shutdown so its finallys run (array
|
|
132
|
+
// disposal, wired/adapter scopes) and capture the stats its
|
|
133
|
+
// early-return path still reports.
|
|
134
|
+
if (this.stats === null) {
|
|
135
|
+
const r = await this.#iter.return(undefined as unknown as GenerateStats);
|
|
136
|
+
if (r.done && r.value) this.stats = r.value;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Scoped wired limit, raised only for near-ceiling models. mlx-lm's
|
|
143
|
+
// wired_limit context wires unconditionally per generation; we deviate
|
|
144
|
+
// with a measured justification (PLAN Phase 6 verification findings):
|
|
145
|
+
// - 26B-A4B (16.4 GB = 92% of the 17.8 GiB working set) NEEDS wiring —
|
|
146
|
+
// 8.6 tok/s without, 32.3 with (Metal evicts weight buffers per token).
|
|
147
|
+
// - 12B/e4b (≤47%) hit reference parity WITHOUT wiring, and wiring in a
|
|
148
|
+
// multi-model process (the test suite) pins memory the OTHER resident
|
|
149
|
+
// models need — async GPU exec OOM, which is uncatchable (the mlx
|
|
150
|
+
// completion-handler throw terminates the process).
|
|
151
|
+
// Scope semantics match the reference: set → generate → synchronize →
|
|
152
|
+
// restore; nothing stays pinned between generations. Re-entrant: only
|
|
153
|
+
// the outermost wiring scope touches the limit.
|
|
154
|
+
const WIRE_THRESHOLD = 0.75;
|
|
155
|
+
let wiredScopeDepth = 0;
|
|
156
|
+
let wiredOldLimit = 0;
|
|
157
|
+
function enterWiredScope(): void {
|
|
158
|
+
if (wiredScopeDepth++ === 0)
|
|
159
|
+
wiredOldLimit = setWiredLimit(maxRecommendedWorkingSetSize());
|
|
160
|
+
}
|
|
161
|
+
function exitWiredScope(): void {
|
|
162
|
+
if (--wiredScopeDepth === 0) {
|
|
163
|
+
synchronize(gpuStream);
|
|
164
|
+
setWiredLimit(wiredOldLimit);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function generate(
|
|
169
|
+
model: RuntimeModel,
|
|
170
|
+
promptTokens: number[],
|
|
171
|
+
options: GenerateOptions = {},
|
|
172
|
+
): Generation {
|
|
173
|
+
let inner = generateInner(model, promptTokens, options);
|
|
174
|
+
if (options.adapters?.length) inner = adapterScoped(model, options.adapters, inner);
|
|
175
|
+
const wire =
|
|
176
|
+
process.env.MLX_BUN_FORCE_WIRE === "1" ||
|
|
177
|
+
model.weightsBytes > WIRE_THRESHOLD * maxRecommendedWorkingSetSize();
|
|
178
|
+
return new Generation(wire ? wiredScoped(inner) : inner);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Hold the model's active-adapter list for exactly this generation. */
|
|
182
|
+
async function* adapterScoped(
|
|
183
|
+
model: RuntimeModel,
|
|
184
|
+
adapters: string[],
|
|
185
|
+
inner: AsyncGenerator<GeneratedToken, GenerateStats>,
|
|
186
|
+
): AsyncGenerator<GeneratedToken, GenerateStats> {
|
|
187
|
+
model.loraState.active = adapters;
|
|
188
|
+
try {
|
|
189
|
+
return yield* inner;
|
|
190
|
+
} finally {
|
|
191
|
+
model.loraState.active = [];
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Wrap the generator so the wired limit is held exactly while it runs
|
|
196
|
+
* (incl. early break/return/throw — finally fires on .return()). */
|
|
197
|
+
async function* wiredScoped(
|
|
198
|
+
inner: AsyncGenerator<GeneratedToken, GenerateStats>,
|
|
199
|
+
): AsyncGenerator<GeneratedToken, GenerateStats> {
|
|
200
|
+
enterWiredScope();
|
|
201
|
+
try {
|
|
202
|
+
return yield* inner;
|
|
203
|
+
} finally {
|
|
204
|
+
exitWiredScope();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function* generateInner(
|
|
209
|
+
model: RuntimeModel,
|
|
210
|
+
promptTokens: number[],
|
|
211
|
+
options: GenerateOptions,
|
|
212
|
+
): AsyncGenerator<GeneratedToken, GenerateStats> {
|
|
213
|
+
const {
|
|
214
|
+
maxTokens = 512,
|
|
215
|
+
eosTokenIds = model.config.eosTokenIds,
|
|
216
|
+
prefillChunkSize = 2048,
|
|
217
|
+
} = options;
|
|
218
|
+
const sampler = makeSampler(options);
|
|
219
|
+
const processors = makeLogitsProcessors(options);
|
|
220
|
+
const needsTokenHistory = processors.length > 0;
|
|
221
|
+
|
|
222
|
+
const ownsCache = !options.cache;
|
|
223
|
+
const cache = options.cache ?? model.makeCache();
|
|
224
|
+
const cachedTokens = cache[0]!.offset;
|
|
225
|
+
if (cachedTokens >= promptTokens.length)
|
|
226
|
+
throw new Error(
|
|
227
|
+
`pre-warmed cache (${cachedTokens} tokens) must be a strict prefix of the prompt (${promptTokens.length})`,
|
|
228
|
+
);
|
|
229
|
+
/** device-side token history (only maintained when processors need it) */
|
|
230
|
+
let history: MlxArray | null = null;
|
|
231
|
+
|
|
232
|
+
// logits [1,1,V] → sampled token array [1] (all on-device)
|
|
233
|
+
const sampleStep = (logits3d: MlxArray, step: number): MlxArray => {
|
|
234
|
+
const V = logits3d.shape[2]!;
|
|
235
|
+
let logits = ops.reshape(logits3d, [1, V]);
|
|
236
|
+
for (const p of processors) logits = disposing(logits, p(history, logits));
|
|
237
|
+
const lp = toLogprobs(logits);
|
|
238
|
+
logits.dispose();
|
|
239
|
+
const tok = sampler(lp, step);
|
|
240
|
+
lp.dispose();
|
|
241
|
+
return tok;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const pushHistory = (tok: MlxArray) => {
|
|
245
|
+
if (!needsTokenHistory) return;
|
|
246
|
+
if (!history) {
|
|
247
|
+
history = ops.reshape(tok, [1]);
|
|
248
|
+
} else {
|
|
249
|
+
const prev = history;
|
|
250
|
+
history = ops.concatAxis([prev, tok], 0);
|
|
251
|
+
prev.dispose();
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// Decode-loop state lives at function scope so the finally can still
|
|
256
|
+
// report stats and dispose in-flight arrays when the consumer
|
|
257
|
+
// terminates the generator early (break on a stop sequence — the
|
|
258
|
+
// forced .return() resumes at the yield and runs the finally).
|
|
259
|
+
let prefillMs = 0;
|
|
260
|
+
let tDecode = 0;
|
|
261
|
+
let decodeMs = 0;
|
|
262
|
+
let generated = 0;
|
|
263
|
+
const forwarded: number[] = [];
|
|
264
|
+
let pending: MlxArray | null = null;
|
|
265
|
+
let nextPending: MlxArray | null = null;
|
|
266
|
+
let finished = false;
|
|
267
|
+
let threw = false;
|
|
268
|
+
const makeStats = (): GenerateStats => ({
|
|
269
|
+
promptTokens: promptTokens.length,
|
|
270
|
+
cachedTokens,
|
|
271
|
+
generatedTokens: generated,
|
|
272
|
+
prefillMs,
|
|
273
|
+
decodeMs,
|
|
274
|
+
prefillTps: ((promptTokens.length - cachedTokens) / prefillMs) * 1000,
|
|
275
|
+
decodeTps: (generated / decodeMs) * 1000,
|
|
276
|
+
cacheTokens: [...promptTokens, ...forwarded],
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
// ---- prefill ----
|
|
281
|
+
maybeQuantizeKv(cache, options);
|
|
282
|
+
const tPrefill = performance.now();
|
|
283
|
+
let h0: MlxArray;
|
|
284
|
+
if (options.promptEmbeddings) {
|
|
285
|
+
if (cachedTokens !== 0)
|
|
286
|
+
throw new Error("promptEmbeddings cannot be combined with a pre-warmed cache");
|
|
287
|
+
if (needsTokenHistory)
|
|
288
|
+
history = ops.fromInt32(promptTokens, [promptTokens.length]);
|
|
289
|
+
// e2b/e4b need the spliced token ids to build per-layer inputs
|
|
290
|
+
// (image positions zeroed inside forwardEmbeddings).
|
|
291
|
+
const embedIds = ops.fromInt32(promptTokens, [1, promptTokens.length]);
|
|
292
|
+
h0 = model.forwardEmbeddings(
|
|
293
|
+
options.promptEmbeddings, cache, options.imageMask ?? null, embedIds,
|
|
294
|
+
);
|
|
295
|
+
embedIds.dispose();
|
|
296
|
+
} else {
|
|
297
|
+
let pos = cachedTokens;
|
|
298
|
+
while (promptTokens.length - pos > prefillChunkSize) {
|
|
299
|
+
const chunk = promptTokens.slice(pos, pos + prefillChunkSize);
|
|
300
|
+
const ids = ops.fromInt32(chunk, [1, chunk.length]);
|
|
301
|
+
const h = model.forwardHidden(ids, cache);
|
|
302
|
+
ids.dispose();
|
|
303
|
+
h.dispose(); // logits never computed for non-final chunks
|
|
304
|
+
ops.evalAll(cache.flatMap((c) => c.state()));
|
|
305
|
+
maybeQuantizeKv(cache, options);
|
|
306
|
+
// mlx-lm _prefill clears the allocator cache after every chunk;
|
|
307
|
+
// without this, prefill transients pile up in the buffer cache
|
|
308
|
+
// and the first decode step pays a one-shot reclaim stall that
|
|
309
|
+
// scales with prompt length (~800 ms after an 8k prefill —
|
|
310
|
+
// measured, scripts/decode-split.ts; the context-scaling decode
|
|
311
|
+
// gap's main term).
|
|
312
|
+
clearCache();
|
|
313
|
+
pos += prefillChunkSize;
|
|
314
|
+
}
|
|
315
|
+
if (needsTokenHistory) {
|
|
316
|
+
history = ops.fromInt32(promptTokens, [promptTokens.length]);
|
|
317
|
+
}
|
|
318
|
+
const lastChunk = promptTokens.slice(pos);
|
|
319
|
+
const ids0 = ops.fromInt32(lastChunk, [1, lastChunk.length]);
|
|
320
|
+
h0 = model.forwardHidden(ids0, cache);
|
|
321
|
+
ids0.dispose();
|
|
322
|
+
}
|
|
323
|
+
const [, L0, H] = h0.shape as [number, number, number];
|
|
324
|
+
const hLast = h0.slice([0, L0 - 1, 0], [1, L0, H]);
|
|
325
|
+
h0.dispose();
|
|
326
|
+
const logits0 = model.logitsFromHidden(hLast);
|
|
327
|
+
hLast.dispose();
|
|
328
|
+
pending = sampleStep(logits0, 0); // token array [1]
|
|
329
|
+
logits0.dispose();
|
|
330
|
+
// mirror mlx-lm generate_step: async-dispatch the first token's
|
|
331
|
+
// compute; the prefill clock keeps running until the token ARRIVES
|
|
332
|
+
// (first itemUint32 below). mlx-lm stops its prompt clock at the
|
|
333
|
+
// first yielded token, which bills the prefill→decode boundary
|
|
334
|
+
// (allocator reclaim of prefill transients + first-step dispatch)
|
|
335
|
+
// to prompt_time, not decode — replicated so cross-stack decode
|
|
336
|
+
// tok/s measure the same quantity. The boundary cost is real and
|
|
337
|
+
// scales with prompt length; it belongs to "having prefilled".
|
|
338
|
+
ops.asyncEvalAll([pending]);
|
|
339
|
+
|
|
340
|
+
// ---- decode (pipelined) ----
|
|
341
|
+
// Compiled decode (docs/design/optimization_plan.md Phase A): replay the per-step
|
|
342
|
+
// graph in C++ instead of rebuilding it through bun:ffi every token.
|
|
343
|
+
// Bit-exact with the uncompiled path (tests/compiled-decode.test.ts);
|
|
344
|
+
// MLX_BUN_COMPILED_DECODE=0 is the kill switch / A-B lever. LoRA
|
|
345
|
+
// generations stay uncompiled (adapter weights would bake into the
|
|
346
|
+
// trace as constants). Any unsupported cache state falls back for
|
|
347
|
+
// the rest of the generation.
|
|
348
|
+
// MoE models stay uncompiled: GatherQMM lacks output_shapes in mlx
|
|
349
|
+
// 0.6.0, and shapeless replay re-infers the whole tape whenever the
|
|
350
|
+
// growing attention windows change shape (= every step). Remove this
|
|
351
|
+
// when upstream implements GatherQMM::output_shapes.
|
|
352
|
+
let compiled =
|
|
353
|
+
process.env.MLX_BUN_COMPILED_DECODE !== "0" &&
|
|
354
|
+
!options.adapters?.length &&
|
|
355
|
+
model.config.modelType.startsWith("gemma4") &&
|
|
356
|
+
!model.config.text.enableMoeBlock
|
|
357
|
+
? CompiledDecode.for(model as Gemma4Model)
|
|
358
|
+
: null;
|
|
359
|
+
let stop = false;
|
|
360
|
+
while (!stop) {
|
|
361
|
+
const cur = pending!;
|
|
362
|
+
// build step n+1's graph from the *unread* pending token
|
|
363
|
+
nextPending = null;
|
|
364
|
+
if (generated + 1 < maxTokens) {
|
|
365
|
+
maybeQuantizeKv(cache, options);
|
|
366
|
+
pushHistory(cur);
|
|
367
|
+
let logits: MlxArray | null = null;
|
|
368
|
+
let evalWith: MlxArray[] = [];
|
|
369
|
+
if (compiled && CompiledDecode.supports(cache)) {
|
|
370
|
+
try {
|
|
371
|
+
const r = compiled.step(cur, cache);
|
|
372
|
+
logits = r.logits;
|
|
373
|
+
evalWith = r.evalWith;
|
|
374
|
+
} catch (e) {
|
|
375
|
+
// growth done by a partial prepare is benign for the
|
|
376
|
+
// uncompiled path (updateAndFetch re-checks capacity)
|
|
377
|
+
compiled = null;
|
|
378
|
+
console.warn(`compiled decode disabled for this generation: ${e}`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (!logits) {
|
|
382
|
+
const ids = ops.reshape(cur, [1, 1]);
|
|
383
|
+
const h = model.forwardHidden(ids, cache);
|
|
384
|
+
ids.dispose();
|
|
385
|
+
logits = model.logitsFromHidden(h);
|
|
386
|
+
h.dispose();
|
|
387
|
+
}
|
|
388
|
+
nextPending = sampleStep(logits, generated + 1);
|
|
389
|
+
logits.dispose();
|
|
390
|
+
ops.asyncEvalAll([nextPending, ...evalWith]);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// sync-read step n's token while n+1 computes
|
|
394
|
+
const token = ops.itemUint32(cur);
|
|
395
|
+
if (generated === 0) {
|
|
396
|
+
// first token arrived: prompt clock stops, decode clock starts
|
|
397
|
+
// (mlx-lm stream_generate's n==0 clock swap; the first token is
|
|
398
|
+
// "free" on the decode clock there too)
|
|
399
|
+
prefillMs = performance.now() - tPrefill;
|
|
400
|
+
tDecode = performance.now();
|
|
401
|
+
}
|
|
402
|
+
cur.dispose();
|
|
403
|
+
pending = null;
|
|
404
|
+
generated++;
|
|
405
|
+
// if a next-step graph was built, this token's KV entered the cache
|
|
406
|
+
if (nextPending !== null) forwarded.push(token);
|
|
407
|
+
|
|
408
|
+
if (eosTokenIds.includes(token)) {
|
|
409
|
+
nextPending?.dispose();
|
|
410
|
+
nextPending = null;
|
|
411
|
+
stop = true;
|
|
412
|
+
} else {
|
|
413
|
+
yield { token, index: generated - 1 };
|
|
414
|
+
// mlx-lm generate_step: clear_cache after token 0 (drops the
|
|
415
|
+
// remaining prefill transients) and every 256 tokens after
|
|
416
|
+
if ((generated - 1) % 256 === 0) clearCache();
|
|
417
|
+
if (nextPending === null) {
|
|
418
|
+
stop = true;
|
|
419
|
+
} else {
|
|
420
|
+
pending = nextPending;
|
|
421
|
+
nextPending = null;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
decodeMs = performance.now() - tDecode;
|
|
426
|
+
finished = true;
|
|
427
|
+
return makeStats();
|
|
428
|
+
} catch (e) {
|
|
429
|
+
threw = true;
|
|
430
|
+
throw e;
|
|
431
|
+
} finally {
|
|
432
|
+
if (!finished) {
|
|
433
|
+
pending?.dispose();
|
|
434
|
+
nextPending?.dispose();
|
|
435
|
+
}
|
|
436
|
+
if (ownsCache) for (const c of cache) c.dispose();
|
|
437
|
+
history?.dispose();
|
|
438
|
+
if (!finished && !threw) {
|
|
439
|
+
// forced early return (consumer break at a yield): still report
|
|
440
|
+
// stats — `forwarded` only lists tokens whose KV actually entered
|
|
441
|
+
// the cache, so cacheTokens stays exact for PromptCache.put().
|
|
442
|
+
decodeMs = performance.now() - tDecode;
|
|
443
|
+
return makeStats();
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function disposing(old: MlxArray, next: MlxArray): MlxArray {
|
|
449
|
+
old.dispose();
|
|
450
|
+
return next;
|
|
451
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// mlx-bun harness pi — register mlx-bun as a provider in the user's pi
|
|
2
|
+
// install (Phase 16 P1, docs/investigations/pi-builtin-investigation.md).
|
|
3
|
+
//
|
|
4
|
+
// Mechanism: a generated extension file in ~/.pi/agent/extensions/ that
|
|
5
|
+
// calls pi.registerProvider() with dynamic discovery — it fetches
|
|
6
|
+
// /v1/models from the running server at pi startup so the model list
|
|
7
|
+
// tracks the server, and falls back to the list baked at install time
|
|
8
|
+
// when the server is down. No models.json surgery; reversible by
|
|
9
|
+
// deleting the one file (or `mlx-bun harness pi --remove`).
|
|
10
|
+
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
|
|
15
|
+
export const PI_EXTENSION_FILENAME = "mlx-bun-provider.ts";
|
|
16
|
+
export const PI_PROVIDER_ID = "mlx-bun";
|
|
17
|
+
/** Stable pi-facing model id. The server runs exactly one model at a time
|
|
18
|
+
* (memory: the 26B alone is 18 GB) and which one varies run-to-run, so
|
|
19
|
+
* advertising the real, changing repo id makes pi's persisted default go
|
|
20
|
+
* stale ("No models match …"). A fixed id resolves to whatever single
|
|
21
|
+
* model is loaded; the real model is carried in the `name` field, and the
|
|
22
|
+
* server still reports the true id in /v1/models and responses. Gives a
|
|
23
|
+
* clean `mlx-bun/local` handle instead of `mlx-bun/mlx-community/…`. */
|
|
24
|
+
export const PI_LOCAL_MODEL_ID = "local";
|
|
25
|
+
export const DEFAULT_BASE_URL = "http://localhost:8090/v1";
|
|
26
|
+
|
|
27
|
+
export function defaultPiExtensionsDir(): string {
|
|
28
|
+
return join(homedir(), ".pi", "agent", "extensions");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PiDetection {
|
|
32
|
+
found: boolean;
|
|
33
|
+
binPath?: string;
|
|
34
|
+
version?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Locate the user's pi install without spawning it (the bin shim may
|
|
38
|
+
* resolve to a node too old for pi-tui's /v-flag regexes). */
|
|
39
|
+
export function detectPi(): PiDetection {
|
|
40
|
+
const candidates = [Bun.which("pi"), join(homedir(), ".bun", "bin", "pi")]
|
|
41
|
+
.filter((p): p is string => !!p && existsSync(p));
|
|
42
|
+
for (const bin of candidates) {
|
|
43
|
+
let version: string | undefined;
|
|
44
|
+
try {
|
|
45
|
+
// bin -> .../node_modules/@earendil-works/pi-coding-agent/dist/cli.js
|
|
46
|
+
let dir = dirname(realpathSync(bin));
|
|
47
|
+
for (let i = 0; i < 4 && dir !== "/"; i++, dir = dirname(dir)) {
|
|
48
|
+
const pkg = join(dir, "package.json");
|
|
49
|
+
if (existsSync(pkg)) {
|
|
50
|
+
const meta = JSON.parse(readFileSync(pkg, "utf8")) as { name?: string; version?: string };
|
|
51
|
+
if (meta.name === "@earendil-works/pi-coding-agent") version = meta.version;
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} catch {}
|
|
56
|
+
return { found: true, binPath: bin, version };
|
|
57
|
+
}
|
|
58
|
+
return { found: false };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ServerModel {
|
|
62
|
+
id: string;
|
|
63
|
+
contextWindow: number;
|
|
64
|
+
maxTokens: number;
|
|
65
|
+
reasoning: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Discover models from a running mlx-bun server; [] when unreachable. */
|
|
69
|
+
export async function fetchServerModels(baseUrl: string, timeoutMs = 3000): Promise<ServerModel[]> {
|
|
70
|
+
try {
|
|
71
|
+
const res = await fetch(`${baseUrl.replace(/\/$/, "")}/models`, {
|
|
72
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
73
|
+
});
|
|
74
|
+
if (!res.ok) return [];
|
|
75
|
+
const body = (await res.json()) as { data?: Array<{ id: string; context_window?: number; reasoning?: boolean }> };
|
|
76
|
+
return (body.data ?? []).map((m) => ({
|
|
77
|
+
id: m.id,
|
|
78
|
+
contextWindow: m.context_window ?? 32768,
|
|
79
|
+
maxTokens: 8192,
|
|
80
|
+
reasoning: !!m.reasoning,
|
|
81
|
+
}));
|
|
82
|
+
} catch {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** GET /v1/models with a short timeout; null when no server is up. The
|
|
88
|
+
* `mlx-bun pi` and `serve` commands use this to decide reuse-vs-start. */
|
|
89
|
+
export async function probeServer(baseUrl: string, timeoutMs = 1500): Promise<ServerModel[] | null> {
|
|
90
|
+
const models = await fetchServerModels(baseUrl, timeoutMs);
|
|
91
|
+
return models.length > 0 ? models : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Render the pi extension source. Plain self-contained TS — pi loads it
|
|
95
|
+
* with jiti, so no imports and no dependency on pi's type packages. */
|
|
96
|
+
export function renderPiExtension(baseUrl: string, fallbackModels: ServerModel[]): string {
|
|
97
|
+
const fallback = JSON.stringify(
|
|
98
|
+
fallbackModels.map((m) => modelEntry(m)),
|
|
99
|
+
null,
|
|
100
|
+
2,
|
|
101
|
+
);
|
|
102
|
+
return `// Generated by \`mlx-bun harness pi\` — safe to delete.
|
|
103
|
+
// Registers the local mlx-bun server as a pi provider. Models are
|
|
104
|
+
// discovered live from the server at pi startup; the baked list below
|
|
105
|
+
// is the fallback when the server isn't running.
|
|
106
|
+
const BASE_URL = ${JSON.stringify(baseUrl)};
|
|
107
|
+
const FALLBACK_MODELS = ${fallback};
|
|
108
|
+
|
|
109
|
+
export default async function (pi: any) {
|
|
110
|
+
let models = FALLBACK_MODELS;
|
|
111
|
+
try {
|
|
112
|
+
const res = await fetch(\`\${BASE_URL}/models\`, { signal: AbortSignal.timeout(2000) });
|
|
113
|
+
if (res.ok) {
|
|
114
|
+
const { data } = await res.json();
|
|
115
|
+
if (Array.isArray(data) && data.length > 0) {
|
|
116
|
+
models = data.map((m: any) => ({
|
|
117
|
+
id: ${JSON.stringify(PI_LOCAL_MODEL_ID)},
|
|
118
|
+
name: \`\${m.id} (mlx-bun local)\`,
|
|
119
|
+
reasoning: !!m.reasoning,
|
|
120
|
+
...(m.reasoning ? { compat: { thinkingFormat: "qwen-chat-template" } } : {}),
|
|
121
|
+
input: ["text"],
|
|
122
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
123
|
+
contextWindow: m.context_window ?? 32768,
|
|
124
|
+
maxTokens: 8192,
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} catch {}
|
|
129
|
+
if (models.length === 0) return; // nothing to register
|
|
130
|
+
pi.registerProvider(${JSON.stringify(PI_PROVIDER_ID)}, {
|
|
131
|
+
baseUrl: BASE_URL,
|
|
132
|
+
apiKey: "sk-mlx-bun-local",
|
|
133
|
+
api: "openai-completions",
|
|
134
|
+
models,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function modelEntry(m: ServerModel) {
|
|
141
|
+
return {
|
|
142
|
+
id: PI_LOCAL_MODEL_ID,
|
|
143
|
+
name: `${m.id} (mlx-bun local)`,
|
|
144
|
+
reasoning: !!m.reasoning,
|
|
145
|
+
...(m.reasoning ? { compat: { thinkingFormat: "qwen-chat-template" } } : {}),
|
|
146
|
+
input: ["text"],
|
|
147
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
148
|
+
contextWindow: m.contextWindow,
|
|
149
|
+
maxTokens: m.maxTokens,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface InstallResult {
|
|
154
|
+
path: string;
|
|
155
|
+
baseUrl: string;
|
|
156
|
+
bakedModels: string[];
|
|
157
|
+
serverReachable: boolean;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function installPiExtension(
|
|
161
|
+
baseUrl: string = DEFAULT_BASE_URL,
|
|
162
|
+
extensionsDir: string = defaultPiExtensionsDir(),
|
|
163
|
+
): Promise<InstallResult> {
|
|
164
|
+
const models = await fetchServerModels(baseUrl);
|
|
165
|
+
mkdirSync(extensionsDir, { recursive: true });
|
|
166
|
+
const path = join(extensionsDir, PI_EXTENSION_FILENAME);
|
|
167
|
+
writeFileSync(path, renderPiExtension(baseUrl, models));
|
|
168
|
+
return { path, baseUrl, bakedModels: models.map((m) => m.id), serverReachable: models.length > 0 };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Returns true when a file was removed. */
|
|
172
|
+
export function removePiExtension(extensionsDir: string = defaultPiExtensionsDir()): boolean {
|
|
173
|
+
const path = join(extensionsDir, PI_EXTENSION_FILENAME);
|
|
174
|
+
if (!existsSync(path)) return false;
|
|
175
|
+
rmSync(path);
|
|
176
|
+
return true;
|
|
177
|
+
}
|