mlx-bun 0.0.11 → 0.0.13

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 (82) hide show
  1. package/README.md +63 -27
  2. package/THIRD_PARTY_LICENSES.md +9 -0
  3. package/docs/design/colibri-glm52-port.md +1486 -0
  4. package/docs/design/pre-colibri-stabilization.md +329 -0
  5. package/docs/investigations/colibri-oracle-pin.md +1284 -0
  6. package/docs/investigations/indexshare-performance-spike.md +357 -0
  7. package/docs/planning/release-notes-v0.0.12.md +80 -0
  8. package/docs/planning/release-notes-v0.0.13.md +77 -0
  9. package/docs/reference/cli.md +28 -2
  10. package/docs/reference/distribution.md +25 -1
  11. package/docs/reference/embedding.md +5 -2
  12. package/docs/reference/features-matrix.md +5 -2
  13. package/docs/reference/library-api.md +41 -13
  14. package/docs/reference/models.md +49 -2
  15. package/docs/reference/server-api.md +113 -20
  16. package/docs/reference/server-config.md +21 -14
  17. package/docs/reference/training.md +10 -1
  18. package/package.json +1 -1
  19. package/src/anthropic.ts +54 -44
  20. package/src/chat-template.ts +189 -11
  21. package/src/cli.ts +127 -8
  22. package/src/config.ts +81 -26
  23. package/src/diffusion/diffusion-generate.ts +14 -8
  24. package/src/download.ts +103 -12
  25. package/src/eval/ifeval.ts +63 -120
  26. package/src/eval/runner.ts +14 -5
  27. package/src/eval/tasks/ifeval.ts +167 -31
  28. package/src/expert-io.ts +391 -0
  29. package/src/expert-residency.ts +1134 -0
  30. package/src/expert-usage.ts +415 -0
  31. package/src/generate.ts +114 -17
  32. package/src/hf-push.ts +1 -1
  33. package/src/index.ts +14 -2
  34. package/src/jobs/runner.ts +37 -18
  35. package/src/kv-store.ts +147 -8
  36. package/src/lora.ts +77 -14
  37. package/src/media-fetch.ts +198 -56
  38. package/src/mlx/array.ts +43 -0
  39. package/src/mlx/autograd.ts +51 -14
  40. package/src/mlx/checkpoint.ts +46 -13
  41. package/src/mlx/custom-vjp.ts +83 -35
  42. package/src/model/factory.ts +96 -5
  43. package/src/model/gemma4-base.ts +32 -0
  44. package/src/model/glm52-atlas.ts +511 -0
  45. package/src/model/glm52-cache.ts +706 -0
  46. package/src/model/glm52-config.ts +262 -0
  47. package/src/model/glm52-container.ts +305 -0
  48. package/src/model/glm52-coupling.ts +388 -0
  49. package/src/model/glm52-dsa.ts +602 -0
  50. package/src/model/glm52-expert-layout.ts +253 -0
  51. package/src/model/glm52-layout.ts +152 -0
  52. package/src/model/glm52-memory.ts +473 -0
  53. package/src/model/glm52-mla.ts +786 -0
  54. package/src/model/glm52-moe.ts +223 -0
  55. package/src/model/glm52-pilot.ts +537 -0
  56. package/src/model/glm52-reference.ts +445 -0
  57. package/src/model/glm52-residency.ts +557 -0
  58. package/src/model/glm52-resident-weights.ts +135 -0
  59. package/src/model/glm52-streamed-experts.ts +453 -0
  60. package/src/model/glm52-streamed-metal.ts +610 -0
  61. package/src/model/glm52-weights.ts +503 -0
  62. package/src/model/glm52.ts +1153 -0
  63. package/src/model/support.ts +8 -1
  64. package/src/native/expert_io.c +523 -0
  65. package/src/native-pack.ts +6 -4
  66. package/src/responses.ts +62 -49
  67. package/src/serve/batch-scheduler.ts +95 -16
  68. package/src/serve/generation-gateway.ts +41 -16
  69. package/src/server.ts +471 -93
  70. package/src/spec/glm52-mtp-source.ts +392 -0
  71. package/src/spec/serve-loop.ts +99 -18
  72. package/src/spec/source.ts +31 -6
  73. package/src/ssd-cache.ts +6 -2
  74. package/src/tool-call.ts +30 -2
  75. package/src/train/job.ts +0 -10
  76. package/src/train/lora-params.ts +17 -6
  77. package/src/train/trainer.ts +59 -26
  78. package/src/web/app.html +13 -2
  79. package/src/web/app.js +30 -3
  80. package/src/web/src/status.ts +51 -3
  81. package/src/web-tools.ts +54 -2
  82. package/src/weights.ts +82 -19
package/src/ssd-cache.ts CHANGED
@@ -42,10 +42,12 @@ export interface SsdIndexEntry {
42
42
 
43
43
  /** Trimmability from a file header alone (mirror of the live caches'
44
44
  * isTrimmable(): kv/qkv/turboquant always trim; rotating kinds only pre-wrap
45
- * (offset < maxSize); ssm never). Keep in sync with gemma4-base/qwen3-delta. */
45
+ * (offset < maxSize); GLM compressed kinds always trim; ssm never). Keep in
46
+ * sync with gemma4-base/qwen3-delta/glm52-cache. */
46
47
  const headerTrimmable = (caches: { kind: string; offset: number; maxSize?: number }[]): boolean =>
47
48
  caches.every((c) =>
48
- c.kind === "kv" || c.kind === "qkv" || c.kind === "turboquant"
49
+ c.kind === "kv" || c.kind === "qkv" || c.kind === "turboquant" ||
50
+ c.kind === "mla" || c.kind === "mla-dsa" || c.kind === "mtp-mla"
49
51
  ? true
50
52
  : c.kind === "rotating" || c.kind === "rotating-qkv"
51
53
  ? c.offset < (c.maxSize ?? 0)
@@ -111,6 +113,7 @@ export class SsdCacheStore {
111
113
  try {
112
114
  const h = readKvHeader(path); // verifies the header hash
113
115
  if (
116
+ h.modelId !== this.#opts.modelId ||
114
117
  h.configFingerprint !== this.#opts.configFingerprint ||
115
118
  h.tokenizerHash !== this.#opts.tokenizerHash
116
119
  )
@@ -159,6 +162,7 @@ export class SsdCacheStore {
159
162
  const t0 = performance.now();
160
163
  try {
161
164
  const expect: KvLoadExpect = {
165
+ modelId: this.#opts.modelId,
162
166
  configFingerprint: this.#opts.configFingerprint,
163
167
  tokenizerHash: this.#opts.tokenizerHash,
164
168
  ns: entry.ns,
package/src/tool-call.ts CHANGED
@@ -162,6 +162,8 @@ export function parseToolCalls(text: string): ParsedToolCall[] {
162
162
  const TOOL_CALL_BLOCK_RE = /<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/gi;
163
163
  const XML_FUNCTION_EQUALS_RE = /^\s*<function=([^>\s]+)>\s*([\s\S]*?)\s*<\/function>\s*$/i;
164
164
  const XML_PARAMETER_EQUALS_RE = /<parameter=([^>\s]+)>\s*([\s\S]*?)\s*<\/parameter>/gi;
165
+ const GLM52_ARG_KEY_RE = /<arg_key>/i;
166
+ const GLM52_ARG_PAIR_RE = /<arg_key>\s*([\s\S]*?)\s*<\/arg_key>\s*<arg_value>\s*([\s\S]*?)\s*<\/arg_value>/gi;
165
167
  const XML_FUNCTION_ATTR_RE = /<function\s+name=["']([^"']+)["']\s*>\s*([\s\S]*?)\s*<\/function>/gi;
166
168
  // CDATA alternative first so a `</param>` inside a CDATA block never
167
169
  // terminates the value early.
@@ -560,6 +562,29 @@ function parseXmlEqualsToolCall(block: string, tools: ToolSpec[]): ParsedToolCal
560
562
  return { name, arguments: args };
561
563
  }
562
564
 
565
+ function parseGlm52ToolCall(block: string, tools: ToolSpec[]): ParsedToolCall | null {
566
+ const firstArg = block.search(GLM52_ARG_KEY_RE);
567
+ const name = (firstArg < 0 ? block : block.slice(0, firstArg)).trim();
568
+ if (!name || !/^[^\s<>]+$/.test(name)) return null;
569
+ if (firstArg < 0) return { name, arguments: {} };
570
+
571
+ const body = block.slice(firstArg);
572
+ const args: Record<string, unknown> = {};
573
+ let cursor = 0;
574
+ let matched = false;
575
+ for (const pair of body.matchAll(GLM52_ARG_PAIR_RE)) {
576
+ if (body.slice(cursor, pair.index).trim()) return null;
577
+ const key = pair[1]!.trim();
578
+ if (!key) throw new Error(`tool '${name}' contains an empty argument name`);
579
+ args[key] = decodeToolValue(pair[2]!, toolParameterSchema(tools, name, key));
580
+ cursor = pair.index + pair[0].length;
581
+ matched = true;
582
+ }
583
+ GLM52_ARG_PAIR_RE.lastIndex = 0;
584
+ if (!matched || body.slice(cursor).trim()) return null;
585
+ return { name, arguments: args };
586
+ }
587
+
563
588
  function parseXmlAttrToolCalls(text: string, tools: ToolSpec[]): ParsedToolCall[] {
564
589
  const calls: ParsedToolCall[] = [];
565
590
  for (const fn of text.matchAll(XML_FUNCTION_ATTR_RE)) {
@@ -582,7 +607,8 @@ function parseXmlAttrToolCalls(text: string, tools: ToolSpec[]): ParsedToolCall[
582
607
  * after generation, and only when tools are active; tokenizer ids are
583
608
  * model-family-specific and must not be used globally. Supports the
584
609
  * OpenAI JSON `<tool_call>...</tool_call>` contract, Qwen-style
585
- * `<function=name><parameter=...>`, and MiniCPM5's native
610
+ * `<function=name><parameter=...>`, GLM-5.2's
611
+ * `name<arg_key>...<arg_value>...`, and MiniCPM5's native
586
612
  * `<function name="..."><param name="...">...` template shape. */
587
613
  export function parseGeneratedToolCalls(text: string, tools: ToolSpec[]): ParsedToolCall[] {
588
614
  if (!tools.length) return [];
@@ -590,7 +616,9 @@ export function parseGeneratedToolCalls(text: string, tools: ToolSpec[]): Parsed
590
616
  const calls: ParsedToolCall[] = [];
591
617
  for (const block of text.matchAll(TOOL_CALL_BLOCK_RE)) {
592
618
  const body = block[1]!.trim();
593
- const parsed = parseJsonToolCall(body) ?? parseXmlEqualsToolCall(body, tools);
619
+ const parsed = parseJsonToolCall(body) ??
620
+ parseXmlEqualsToolCall(body, tools) ??
621
+ parseGlm52ToolCall(body, tools);
594
622
  if (!parsed) throw new Error("unsupported tool_call payload format");
595
623
  calls.push(parsed);
596
624
  }
package/src/train/job.ts CHANGED
@@ -123,16 +123,6 @@ function parseConfig(raw: Record<string, unknown>): { modelDir: string; dataDir:
123
123
  export const finetuneRunner: JobRunner = async (emit, config) => {
124
124
  const { modelDir, dataDir, cfg } = parseConfig(config);
125
125
 
126
- // Gemma/e4b needs these training env flags set BEFORE the model loads — same
127
- // as the `mlx-bun train` CLI. The subprocess job path doesn't inherit them
128
- // from the server, so without this a web-submitted ORPO/SFT run on Gemma uses
129
- // the wrong kernels. ??= so an explicit env (or the CLI) still wins.
130
- try {
131
- const isGemma = (await Bun.file(`${modelDir}/config.json`).text()).toLowerCase().includes("gemma");
132
- if (isGemma) {
133
- }
134
- } catch {}
135
-
136
126
  emit({ type: "stage", stage: "load", progress: 0.01, message: `loading model ${modelDir}` });
137
127
 
138
128
  const { loadModelConfig } = await import("../config");
@@ -165,15 +165,21 @@ export interface SaveAdapterConfig {
165
165
  rsLora?: boolean;
166
166
  }
167
167
 
168
+ export interface SaveAdapterOptions {
169
+ /** Override config-file writes for deterministic durability tests. */
170
+ writeFile?: (path: string, data: string) => Promise<unknown>;
171
+ }
172
+
168
173
  /** Save the trained adapter into `dir` in the AdapterManager.mount format:
169
174
  * adapters.safetensors (lora_a / lora_b tensor names), optiq_lora_config.json,
170
175
  * and a PEFT-compatible adapter_config.json. */
171
- export function saveAdapter(
176
+ export async function saveAdapter(
172
177
  lora: TrainableLora,
173
178
  dir: string,
174
179
  cfg: SaveAdapterConfig,
175
180
  appliedRanks: Record<string, number>,
176
- ): void {
181
+ options: SaveAdapterOptions = {},
182
+ ): Promise<void> {
177
183
  mkdirSync(dir, { recursive: true });
178
184
 
179
185
  // Build the native string→array map and insert lora_a / lora_b per target.
@@ -211,8 +217,6 @@ export function saveAdapter(
211
217
  applied_ranks: appliedRanks,
212
218
  source_model: cfg.baseModel,
213
219
  };
214
- Bun.write(`${dir}/optiq_lora_config.json`, JSON.stringify(optiqCfg, null, 2) + "\n");
215
-
216
220
  // PEFT-compatible adapter_config.json (loadable by mlx-lm / PEFT).
217
221
  const peftCfg = {
218
222
  fine_tune_type: "lora",
@@ -223,15 +227,22 @@ export function saveAdapter(
223
227
  fan_in_fan_out: false,
224
228
  inference_mode: false,
225
229
  init_lora_weights: true,
226
- lora_alpha: Math.round(cfg.rank * cfg.scale),
230
+ // mlx-bun's `scale` is the numerator before rsLoRA's √rank division.
231
+ // Ordinary PEFT needs alpha/r = scale; rsLoRA needs alpha/√r = scale/√r.
232
+ lora_alpha: cfg.rsLora ? cfg.scale : Math.round(cfg.rank * cfg.scale),
227
233
  lora_dropout: 0.0,
228
234
  peft_type: "LORA",
229
235
  r: cfg.rank,
230
236
  target_modules: cfg.targetModules,
231
237
  task_type: "CAUSAL_LM",
238
+ use_rslora: cfg.rsLora ?? false,
232
239
  optiq: { rank_scaling: cfg.rankScaling, applied_ranks: appliedRanks },
233
240
  };
234
- Bun.write(`${dir}/adapter_config.json`, JSON.stringify(peftCfg, null, 2) + "\n");
241
+ const writeFile = options.writeFile ?? ((path: string, data: string) => Bun.write(path, data));
242
+ await Promise.all([
243
+ writeFile(`${dir}/optiq_lora_config.json`, JSON.stringify(optiqCfg, null, 2) + "\n"),
244
+ writeFile(`${dir}/adapter_config.json`, JSON.stringify(peftCfg, null, 2) + "\n"),
245
+ ]);
235
246
  }
236
247
 
237
248
  /** Dispose all trainable leaves (frees A/B). */
@@ -202,6 +202,51 @@ export const DEFAULT_TRAIN_CONFIG: TrainConfig = {
202
202
  baseModel: "",
203
203
  };
204
204
 
205
+ /** Build the per-leaf LoRA+ LR multipliers for flatParams' stable
206
+ * `[...A, ...B]` order. `undefined` keeps AdamW's uniform-LR fast path. */
207
+ export function loraPlusLrScale(
208
+ paramCount: number,
209
+ targetCount: number,
210
+ ratio: number,
211
+ ): number[] | undefined {
212
+ if (!Number.isFinite(ratio) || ratio < 1)
213
+ throw new RangeError(`loraPlusRatio must be a finite number >= 1 (got ${ratio})`);
214
+ if (paramCount !== targetCount * 2)
215
+ throw new Error(
216
+ `LoRA+ expected ${targetCount * 2} A/B leaves for ${targetCount} targets, got ${paramCount}`,
217
+ );
218
+ if (ratio === 1) return undefined;
219
+ return Array.from({ length: paramCount }, (_, i) => (i < targetCount ? 1 : ratio));
220
+ }
221
+
222
+ /** One optimizer policy for SFT, DPO, and ORPO. Keeping LoRA+ here prevents a
223
+ * method-specific loop from silently accepting the knob without applying it. */
224
+ function createLoraOptimizer(
225
+ params: MlxArray[],
226
+ lora: TrainableLora,
227
+ cfg: TrainConfig,
228
+ emit: Emit,
229
+ ): AdamW {
230
+ const lrScale = loraPlusLrScale(params.length, lora.targets.length, cfg.loraPlusRatio);
231
+ if (lrScale) emit({
232
+ type: "stage",
233
+ stage: "setup",
234
+ progress: 0.04,
235
+ message: `LoRA+ : B leaves at ${cfg.loraPlusRatio}× LR`,
236
+ });
237
+ return new AdamW(
238
+ params,
239
+ {
240
+ lr: cfg.learningRate,
241
+ betas: cfg.betas,
242
+ eps: 1e-8,
243
+ weightDecay: cfg.weightDecay,
244
+ lrScale,
245
+ },
246
+ (i, p) => writeParam(lora, i, p),
247
+ );
248
+ }
249
+
205
250
  /** Run a LoRA fine-tune. Emits per-step train metrics, periodic val metrics,
206
251
  * and a final stage:done with the adapter path + applied ranks. Saves the
207
252
  * adapter (last + best-on-val) and returns where it landed. */
@@ -286,6 +331,7 @@ export async function trainLora(
286
331
  // weak proxy. Capture them when the loop emits them so metrics.json lets you
287
332
  // pick the best-MARGIN checkpoint (overfit shows as train↑ / val-margin flat).
288
333
  const valHistory: { step: number; loss: number; margin?: number; accuracy?: number; checkpoint: string | null }[] = [];
334
+ const checkpointSaves: Promise<void>[] = [];
289
335
  const startedAt = Date.now();
290
336
 
291
337
  // Live, append-only metrics stream (the source `mlx-bun train-watch` tails).
@@ -319,7 +365,11 @@ export async function trainLora(
319
365
  if (cfg.saveCheckpoints) {
320
366
  const tag = `step-${String(e.step).padStart(5, "0")}-val${e.loss.toFixed(4)}`;
321
367
  checkpoint = `${cfg.adapterPath}/checkpoints/${tag}`;
322
- saveAdapter(lora, checkpoint, saveCfg, appliedRanks);
368
+ const save = saveAdapter(lora, checkpoint, saveCfg, appliedRanks);
369
+ checkpointSaves.push(save);
370
+ // Keep a handler attached while training continues. Promise.all below
371
+ // still surfaces the original rejection before the run can succeed.
372
+ void save.catch(() => {});
323
373
  emit({ type: "stage", stage: "checkpoint", progress: e.step / cfg.iters,
324
374
  message: `checkpoint ${tag}` });
325
375
  }
@@ -337,9 +387,11 @@ export async function trainLora(
337
387
  ? await orpoLoop(model, tok, tmpl, dataDir, cfg, lora, collect)
338
388
  : await sftLoop(model, tok, tmpl, dataDir, cfg, lora, collect);
339
389
 
390
+ await Promise.all(checkpointSaves);
391
+
340
392
  // Final save (last adapter).
341
393
  detachTraining(model, lora);
342
- saveAdapter(lora, cfg.adapterPath, saveCfg, appliedRanks);
394
+ await saveAdapter(lora, cfg.adapterPath, saveCfg, appliedRanks);
343
395
 
344
396
  // Durable, structured run record alongside the adapter (only when we kept
345
397
  // checkpoints). Pick the best-val one for convenience, but ship ALL of them
@@ -415,11 +467,7 @@ async function sftLoop(
415
467
  : [];
416
468
 
417
469
  const params = flatParams(lora);
418
- const opt = new AdamW(
419
- params,
420
- { lr: cfg.learningRate, betas: cfg.betas, eps: 1e-8, weightDecay: cfg.weightDecay },
421
- (i, p) => writeParam(lora, i, p),
422
- );
470
+ const opt = createLoraOptimizer(params, lora, cfg, emit);
423
471
 
424
472
  // Segmented backward: stream the SFT backward segment-by-segment (only one
425
473
  // segment's activations live at a time). Phase A is MiniCPM5 SFT B=1 only;
@@ -658,11 +706,7 @@ async function dpoLoop(
658
706
  : [];
659
707
 
660
708
  const params = flatParams(lora);
661
- const opt = new AdamW(
662
- params,
663
- { lr: cfg.learningRate, betas: cfg.betas, eps: 1e-8, weightDecay: cfg.weightDecay },
664
- (i, p) => writeParam(lora, i, p),
665
- );
709
+ const opt = createLoraOptimizer(params, lora, cfg, emit);
666
710
 
667
711
  const schedule =
668
712
  cfg.dpoLrSchedule === "cosine" || cfg.dpoWarmupIters > 0
@@ -789,18 +833,7 @@ async function orpoLoop(
789
833
  : [];
790
834
 
791
835
  const params = flatParams(lora);
792
- // LoRA+: B leaves (the second half of flatParams) get a higher LR than A.
793
- const nT = lora.targets.length;
794
- const lrScale = cfg.loraPlusRatio !== 1
795
- ? params.map((_, i) => (i < nT ? 1 : cfg.loraPlusRatio))
796
- : undefined;
797
- if (lrScale) emit({ type: "stage", stage: "setup", progress: 0.04,
798
- message: `LoRA+ : B leaves at ${cfg.loraPlusRatio}× LR` });
799
- const opt = new AdamW(
800
- params,
801
- { lr: cfg.learningRate, betas: cfg.betas, eps: 1e-8, weightDecay: cfg.weightDecay, lrScale },
802
- (i, p) => writeParam(lora, i, p),
803
- );
836
+ const opt = createLoraOptimizer(params, lora, cfg, emit);
804
837
 
805
838
  const schedule =
806
839
  cfg.orpoLrSchedule === "cosine" || cfg.orpoWarmupIters > 0
@@ -1172,11 +1205,11 @@ function clipGradsByNorm(grads: MlxArray[], normVal: number, maxNorm: number): v
1172
1205
 
1173
1206
  /** Supervised (response) tokens across ALL rows of the batch — the count
1174
1207
  * tokens/sec is reported over (mlx-lm's n_tokens counts every row too). */
1175
- function countResponseTokens(batch: SftBatch): number {
1208
+ export function countResponseTokens(batch: SftBatch): number {
1176
1209
  let total = 0;
1177
1210
  for (let r = 0; r < batch.ids.length; r++) {
1178
1211
  const promptLen = batch.promptLens[r]!;
1179
- const len = batch.ids[r]!.length;
1212
+ const len = rowLength(batch, r);
1180
1213
  total += Math.max(0, len - Math.max(promptLen, 1));
1181
1214
  }
1182
1215
  return total;
package/src/web/app.html CHANGED
@@ -2027,6 +2027,14 @@
2027
2027
  <div class="kv"><b>mode</b><span id="st-kv-mode">—</span></div>
2028
2028
  <div id="st-kv-layers"></div>
2029
2029
  </div>
2030
+ <div class="card" id="st-glm-expert-card" style="display:none">
2031
+ <h3>GLM expert tiers · last turn</h3>
2032
+ <div class="kv"><b>main cache hit</b><span class="num" id="st-glm-main-hit">—</span></div>
2033
+ <div class="kv"><b>main SSD read</b><span class="num" id="st-glm-main-read">—</span></div>
2034
+ <div class="kv"><b>MTP cache hit</b><span class="num" id="st-glm-mtp-hit">—</span></div>
2035
+ <div class="kv"><b>MTP SSD read</b><span class="num" id="st-glm-mtp-read">—</span></div>
2036
+ <div class="cap" id="st-glm-residency">waiting for the first completed turn</div>
2037
+ </div>
2030
2038
  <div class="card" id="st-dl-card" style="grid-column:1 / -1;display:none">
2031
2039
  <h3>Downloads</h3>
2032
2040
  <div id="st-dl-list"></div>
@@ -2057,9 +2065,12 @@
2057
2065
  </div>
2058
2066
  <div class="card">
2059
2067
  <h3>At max context · <span class="num" id="st-fit-ctx">—</span> tokens</h3>
2060
- <div class="kv"><b>weights</b><span class="num" id="st-f-weights">—</span></div>
2068
+ <div class="kv" data-glm-fit style="display:none"><b>artifact on disk</b><span class="num" id="st-f-artifact">—</span></div>
2069
+ <div class="kv"><b id="st-f-weights-label">weights</b><span class="num" id="st-f-weights">—</span></div>
2070
+ <div class="kv" data-glm-fit style="display:none"><b>main expert slab</b><span class="num" id="st-f-main-slab">—</span></div>
2071
+ <div class="kv" data-glm-fit style="display:none"><b>MTP expert slab</b><span class="num" id="st-f-mtp-slab">—</span></div>
2061
2072
  <div class="kv"><b>kv cache</b><span class="num" id="st-f-kv">—</span></div>
2062
- <div class="kv"><b>transient</b><span class="num" id="st-f-transient">—</span></div>
2073
+ <div class="kv"><b id="st-f-transient-label">transient</b><span class="num" id="st-f-transient">—</span></div>
2063
2074
  <div class="kv"><b>total / usable</b><span class="num" id="st-f-total">—</span></div>
2064
2075
  <div class="meter"><i id="st-fit-bar"></i></div>
2065
2076
  </div>
package/src/web/app.js CHANGED
@@ -4791,6 +4791,24 @@ function createStatusController() {
4791
4791
  $("st-kv-mode").innerHTML = "<code>" + esc(kv.mode || "—") + "</code>";
4792
4792
  const att = kv.attention;
4793
4793
  $("st-kv-layers").innerHTML = Object.entries(kv.layers || {}).map(([k, v]) => '<div class="kv"><b>' + esc(k) + ' layers</b><span class="num">' + esc(v) + "</span></div>").join("") + (att ? '<div class="kv"><b>attention</b><span class="num">' + esc(att.global) + " global · " + esc(att.sliding_window) + " sliding</span></div>" : "");
4794
+ const glm = stats.glm52;
4795
+ const expert = glm && glm.expert_runtime;
4796
+ const expertCard = $("st-glm-expert-card");
4797
+ expertCard.style.display = glm ? "" : "none";
4798
+ if (glm) {
4799
+ const rate = (d) => {
4800
+ const total = (d?.hits || 0) + (d?.misses || 0);
4801
+ return total ? (100 * (d?.hits || 0) / total).toFixed(1) + "%" : "—";
4802
+ };
4803
+ const turn = expert && expert.last_turn;
4804
+ $("st-glm-main-hit").textContent = rate(turn?.main?.demand);
4805
+ $("st-glm-main-read").textContent = turn?.main?.demand ? gb(turn.main.demand.readBytes) : "—";
4806
+ $("st-glm-mtp-hit").textContent = rate(turn?.mtp?.demand);
4807
+ $("st-glm-mtp-read").textContent = turn?.mtp?.demand ? gb(turn.mtp.demand.readBytes) : "—";
4808
+ const main = expert?.main_residency;
4809
+ const mtp = expert?.mtp_residency;
4810
+ $("st-glm-residency").textContent = main ? "resident slots main " + num(main.resident + main.pinned) + " / " + num(main.resident + main.pinned + main.working) + (mtp ? " · MTP " + num(mtp.resident + mtp.pinned) + " / " + num(mtp.resident + mtp.pinned + mtp.working) : "") : "expert runtime telemetry unavailable";
4811
+ }
4794
4812
  await loadDownloads();
4795
4813
  if (!fitLoaded) {
4796
4814
  await loadFit();
@@ -4872,10 +4890,11 @@ function createStatusController() {
4872
4890
  }
4873
4891
  fitLoaded = true;
4874
4892
  const r = f.report || {};
4893
+ const tps = (value) => value < 1 ? value.toFixed(2) : value.toFixed(1);
4875
4894
  if (f.measured_decode_tps) {
4876
- $("st-tps").textContent = f.measured_decode_tps.toFixed(1);
4895
+ $("st-tps").textContent = tps(f.measured_decode_tps);
4877
4896
  $("st-tps").closest(".card").querySelector("h3").textContent = "Decode · measured";
4878
- $("st-tps-cap").textContent = "mlx-bun benchmark on this machine · predicted " + (f.typical_decode_tps || 0).toFixed(1) + " at " + num(f.typical_context_tokens) + " ctx";
4897
+ $("st-tps-cap").textContent = f.glm52 ? "quality-preserving warm · direct oracle " + tps(f.glm52.direct_oracle_warm_decode_tps || 0) + " · aspiration " + tps(f.glm52.aspirational_decode_tps || 0) : "mlx-bun benchmark on this machine · predicted " + tps(f.typical_decode_tps || 0) + " at " + num(f.typical_context_tokens) + " ctx";
4879
4898
  } else if (f.typical_decode_tps) {
4880
4899
  $("st-tps").textContent = f.typical_decode_tps.toFixed(1);
4881
4900
  $("st-tps-cap").textContent = "at " + num(f.typical_context_tokens) + " context · " + (r.predicted_decode_tps || 0).toFixed(1) + " tok/s at the " + num(f.context_tokens) + " max (full-KV reads)";
@@ -4886,6 +4905,14 @@ function createStatusController() {
4886
4905
  $("st-m-bw").textContent = (f.machine && f.machine.bandwidth_gbs || "—") + " GB/s";
4887
4906
  $("st-m-fits").innerHTML = r.fits ? '<span style="color:var(--green);font-weight:700">FITS</span>' : '<span style="color:var(--red);font-weight:700">DOES NOT FIT</span>';
4888
4907
  $("st-fit-ctx").textContent = num(f.context_tokens);
4908
+ document.querySelectorAll("[data-glm-fit]").forEach((row) => {
4909
+ row.style.display = f.glm52 ? "" : "none";
4910
+ });
4911
+ $("st-f-weights-label").textContent = f.glm52 ? "resident weights" : "weights";
4912
+ $("st-f-transient-label").textContent = f.glm52 ? "other reserves" : "transient";
4913
+ $("st-f-artifact").textContent = f.glm52?.artifact_disk_bytes ? gb(f.glm52.artifact_disk_bytes) : "registry unavailable";
4914
+ $("st-f-main-slab").textContent = gb(f.glm52?.main_expert_slab_bytes);
4915
+ $("st-f-mtp-slab").textContent = gb(f.glm52?.mtp_expert_slab_bytes);
4889
4916
  $("st-f-weights").textContent = gb(r.weights_bytes);
4890
4917
  $("st-f-kv").textContent = gb(r.kv_bytes);
4891
4918
  $("st-f-transient").textContent = gb(r.transient_bytes);
@@ -4910,7 +4937,7 @@ function createStatusController() {
4910
4937
  $("st-sku-body").innerHTML = rows.map((row, i) => {
4911
4938
  const you = i === youIdx;
4912
4939
  const dimCls = row.fits ? "num" : "num fit-no";
4913
- return "<tr" + (you ? ' class="you"' : "") + "><td>" + esc(row.sku) + (you ? " · <b>this machine</b>" : "") + '</td><td class="num">' + row.ram_gb + " GB</td>" + '<td class="' + (row.fits ? "fit-yes" : "fit-no") + '">' + (row.fits ? "fits" : "no") + "</td>" + '<td class="' + dimCls + '">' + (row.max_context > 0 ? num(row.max_context) : "0 — weights alone don’t fit") + "</td>" + '<td class="' + dimCls + '">' + (row.decode_tps > 0 ? "~" + row.decode_tps.toFixed(0) + " tok/s" : "—") + "</td></tr>";
4940
+ return "<tr" + (you ? ' class="you"' : "") + "><td>" + esc(row.sku) + (you ? " · <b>this machine</b>" : "") + '</td><td class="num">' + row.ram_gb + " GB</td>" + '<td class="' + (row.fits ? "fit-yes" : "fit-no") + '">' + (row.fits ? "fits" : "no") + "</td>" + '<td class="' + dimCls + '">' + (row.max_context > 0 ? num(row.max_context) : "0 — weights alone don’t fit") + "</td>" + '<td class="' + dimCls + '">' + (row.decode_tps > 0 ? "~" + tps(row.decode_tps) + " tok/s" : "—") + "</td></tr>";
4914
4941
  }).join("");
4915
4942
  }
4916
4943
  return {
@@ -90,6 +90,32 @@ export function createStatusController() {
90
90
  .map(([k, v]) => '<div class="kv"><b>' + esc(k) + ' layers</b><span class="num">' + esc(v) + "</span></div>").join("") +
91
91
  (att ? '<div class="kv"><b>attention</b><span class="num">' + esc(att.global) + " global · " + esc(att.sliding_window) + " sliding</span></div>" : "");
92
92
 
93
+ const glm = stats.glm52;
94
+ const expert = glm && glm.expert_runtime;
95
+ const expertCard = $("st-glm-expert-card");
96
+ expertCard.style.display = glm ? "" : "none";
97
+ if (glm) {
98
+ const rate = (d: { hits?: number; misses?: number } | null | undefined): string => {
99
+ const total = (d?.hits || 0) + (d?.misses || 0);
100
+ return total ? (100 * (d?.hits || 0) / total).toFixed(1) + "%" : "—";
101
+ };
102
+ const turn = expert && expert.last_turn;
103
+ $("st-glm-main-hit").textContent = rate(turn?.main?.demand);
104
+ $("st-glm-main-read").textContent = turn?.main?.demand
105
+ ? gb(turn.main.demand.readBytes) : "—";
106
+ $("st-glm-mtp-hit").textContent = rate(turn?.mtp?.demand);
107
+ $("st-glm-mtp-read").textContent = turn?.mtp?.demand
108
+ ? gb(turn.mtp.demand.readBytes) : "—";
109
+ const main = expert?.main_residency;
110
+ const mtp = expert?.mtp_residency;
111
+ $("st-glm-residency").textContent = main
112
+ ? "resident slots main " + num(main.resident + main.pinned) +
113
+ " / " + num(main.resident + main.pinned + main.working) +
114
+ (mtp ? " · MTP " + num(mtp.resident + mtp.pinned) +
115
+ " / " + num(mtp.resident + mtp.pinned + mtp.working) : "")
116
+ : "expert runtime telemetry unavailable";
117
+ }
118
+
93
119
  await loadDownloads();
94
120
  if (!fitLoaded) { await loadFit(); await loadLibrary(); }
95
121
  if (stats.server && stats.server.started_at) {
@@ -179,15 +205,28 @@ export function createStatusController() {
179
205
  machine?: { ram_bytes?: number; bandwidth_gbs?: number; chip?: string };
180
206
  sku_matrix_ctx?: number;
181
207
  sku_matrix?: SkuRow[];
208
+ glm52?: {
209
+ artifact_disk_bytes?: number | null;
210
+ main_expert_slab_bytes?: number;
211
+ mtp_expert_slab_bytes?: number;
212
+ direct_oracle_warm_decode_tps?: number;
213
+ aspirational_decode_tps?: number;
214
+ };
182
215
  }
183
216
  let f: FitResponse;
184
217
  try { f = await fetch("/fit").then((r) => r.json()); } catch { return; }
185
218
  fitLoaded = true;
186
219
  const r = f.report || {};
220
+ const tps = (value: number): string => value < 1 ? value.toFixed(2) : value.toFixed(1);
187
221
  if (f.measured_decode_tps) {
188
- $("st-tps").textContent = f.measured_decode_tps.toFixed(1);
222
+ $("st-tps").textContent = tps(f.measured_decode_tps);
189
223
  $("st-tps").closest(".card")!.querySelector("h3")!.textContent = "Decode · measured";
190
- $("st-tps-cap").textContent = "mlx-bun benchmark on this machine · predicted " + (f.typical_decode_tps || 0).toFixed(1) + " at " + num(f.typical_context_tokens) + " ctx";
224
+ $("st-tps-cap").textContent = f.glm52
225
+ ? "quality-preserving warm · direct oracle " +
226
+ tps(f.glm52.direct_oracle_warm_decode_tps || 0) +
227
+ " · aspiration " + tps(f.glm52.aspirational_decode_tps || 0)
228
+ : "mlx-bun benchmark on this machine · predicted " +
229
+ tps(f.typical_decode_tps || 0) + " at " + num(f.typical_context_tokens) + " ctx";
191
230
  } else if (f.typical_decode_tps) {
192
231
  $("st-tps").textContent = f.typical_decode_tps.toFixed(1);
193
232
  $("st-tps-cap").textContent = "at " + num(f.typical_context_tokens) + " context · " + (r.predicted_decode_tps || 0).toFixed(1) + " tok/s at the " + num(f.context_tokens) + " max (full-KV reads)";
@@ -198,6 +237,15 @@ export function createStatusController() {
198
237
  $("st-m-bw").textContent = (f.machine && f.machine.bandwidth_gbs || "—") + " GB/s";
199
238
  $("st-m-fits").innerHTML = r.fits ? '<span style="color:var(--green);font-weight:700">FITS</span>' : '<span style="color:var(--red);font-weight:700">DOES NOT FIT</span>';
200
239
  $("st-fit-ctx").textContent = num(f.context_tokens);
240
+ document.querySelectorAll<HTMLElement>("[data-glm-fit]").forEach((row) => {
241
+ row.style.display = f.glm52 ? "" : "none";
242
+ });
243
+ $("st-f-weights-label").textContent = f.glm52 ? "resident weights" : "weights";
244
+ $("st-f-transient-label").textContent = f.glm52 ? "other reserves" : "transient";
245
+ $("st-f-artifact").textContent = f.glm52?.artifact_disk_bytes
246
+ ? gb(f.glm52.artifact_disk_bytes) : "registry unavailable";
247
+ $("st-f-main-slab").textContent = gb(f.glm52?.main_expert_slab_bytes);
248
+ $("st-f-mtp-slab").textContent = gb(f.glm52?.mtp_expert_slab_bytes);
201
249
  $("st-f-weights").textContent = gb(r.weights_bytes);
202
250
  $("st-f-kv").textContent = gb(r.kv_bytes);
203
251
  $("st-f-transient").textContent = gb(r.transient_bytes);
@@ -221,7 +269,7 @@ export function createStatusController() {
221
269
  '</td><td class="num">' + row.ram_gb + " GB</td>" +
222
270
  '<td class="' + (row.fits ? "fit-yes" : "fit-no") + '">' + (row.fits ? "fits" : "no") + "</td>" +
223
271
  '<td class="' + dimCls + '">' + (row.max_context > 0 ? num(row.max_context) : "0 — weights alone don’t fit") + "</td>" +
224
- '<td class="' + dimCls + '">' + (row.decode_tps > 0 ? "~" + row.decode_tps.toFixed(0) + " tok/s" : "—") + "</td></tr>";
272
+ '<td class="' + dimCls + '">' + (row.decode_tps > 0 ? "~" + tps(row.decode_tps) + " tok/s" : "—") + "</td></tr>";
225
273
  }).join("");
226
274
  }
227
275
 
package/src/web-tools.ts CHANGED
@@ -21,6 +21,11 @@
21
21
 
22
22
  import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent";
23
23
  import { Type } from "typebox";
24
+ import {
25
+ fetchRestrictedHttpBytes,
26
+ type MediaFetchPolicy,
27
+ type RestrictedFetchDependencies,
28
+ } from "./media-fetch";
24
29
 
25
30
  /** Tool names, exported so pi-web can add them to its tool allowlist. */
26
31
  export const WEB_TOOL_NAMES = ["web_search", "web_fetch", "weather"] as const;
@@ -31,6 +36,7 @@ const USER_AGENT =
31
36
  "(KHTML, like Gecko) Chrome/124.0 Safari/537.36";
32
37
 
33
38
  const FETCH_TIMEOUT_MS = 20_000;
39
+ const MAX_FETCH_BYTES = 1024 * 1024;
34
40
  /** Cap on text returned to the model from a fetched page. */
35
41
  const MAX_FETCH_CHARS = 12_000;
36
42
  const DEFAULT_SEARCH_RESULTS = 5;
@@ -44,7 +50,7 @@ function withTimeout(signal: AbortSignal | undefined, ms: number): AbortSignal {
44
50
  return signal ? AbortSignal.any([signal, timeout]) : timeout;
45
51
  }
46
52
 
47
- interface FetchResult {
53
+ export interface FetchResult {
48
54
  ok: boolean;
49
55
  status: number;
50
56
  finalUrl: string;
@@ -52,6 +58,20 @@ interface FetchResult {
52
58
  text: string;
53
59
  }
54
60
 
61
+ export interface WebFetchOptions {
62
+ policy?: MediaFetchPolicy;
63
+ dependencies?: Partial<RestrictedFetchDependencies>;
64
+ }
65
+
66
+ const WEB_FETCH_POLICY: MediaFetchPolicy = {
67
+ allowPrivate: false,
68
+ privateDestinationHint:
69
+ "private/loopback destinations are never available to the model-driven web_fetch tool",
70
+ timeoutMs: FETCH_TIMEOUT_MS,
71
+ maxBytes: MAX_FETCH_BYTES,
72
+ maxRedirects: 5,
73
+ };
74
+
55
75
  /** GET/POST a URL and read the body as text, with timeout + abort wiring. */
56
76
  async function httpText(
57
77
  url: string,
@@ -69,6 +89,38 @@ async function httpText(
69
89
  return { ok: res.ok, status: res.status, finalUrl: res.url || url, contentType, text };
70
90
  }
71
91
 
92
+ /**
93
+ * Fetch a model-selected URL through the shared restricted-destination
94
+ * transport. Exported with dependency injection for deterministic,
95
+ * model-free security tests.
96
+ */
97
+ export async function fetchWebText(
98
+ url: string,
99
+ signal?: AbortSignal,
100
+ options: WebFetchOptions = {},
101
+ ): Promise<FetchResult> {
102
+ const result = await fetchRestrictedHttpBytes(
103
+ url,
104
+ "web",
105
+ options.policy ?? WEB_FETCH_POLICY,
106
+ {
107
+ signal,
108
+ dependencies: options.dependencies,
109
+ headers: {
110
+ "user-agent": USER_AGENT,
111
+ accept: "text/html,application/json,text/plain,*/*",
112
+ },
113
+ },
114
+ );
115
+ return {
116
+ ok: result.ok,
117
+ status: result.status,
118
+ finalUrl: result.finalUrl,
119
+ contentType: result.contentType,
120
+ text: new TextDecoder().decode(result.bytes),
121
+ };
122
+ }
123
+
72
124
  /** Normalize anything thrown by fetch (timeout, DNS, abort) into a message. */
73
125
  function describeFetchError(err: unknown): string {
74
126
  if (err instanceof Error) {
@@ -362,7 +414,7 @@ const webFetchTool = defineTool({
362
414
  let url = params.url.trim();
363
415
  if (!/^https?:\/\//i.test(url)) url = "https://" + url;
364
416
  try {
365
- const res = await httpText(url, { headers: { accept: "text/html,application/json,text/plain,*/*" }, signal });
417
+ const res = await fetchWebText(url, signal);
366
418
  const header = `Fetched ${res.finalUrl} (HTTP ${res.status})`;
367
419
  if (!res.ok) return textResult(`${header}\n\n${truncate(htmlToText(res.text), 2_000)}`);
368
420
  const isHtml = /text\/html|application\/xhtml/i.test(res.contentType);