mlx-bun 0.0.11 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +43 -13
  2. package/THIRD_PARTY_LICENSES.md +9 -0
  3. package/docs/design/colibri-glm52-port.md +1479 -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/reference/cli.md +28 -2
  9. package/docs/reference/distribution.md +25 -1
  10. package/docs/reference/embedding.md +5 -2
  11. package/docs/reference/features-matrix.md +4 -2
  12. package/docs/reference/library-api.md +41 -13
  13. package/docs/reference/models.md +49 -2
  14. package/docs/reference/server-api.md +90 -9
  15. package/docs/reference/server-config.md +21 -14
  16. package/docs/reference/training.md +10 -1
  17. package/package.json +1 -1
  18. package/src/anthropic.ts +54 -44
  19. package/src/chat-template.ts +155 -10
  20. package/src/cli.ts +127 -8
  21. package/src/config.ts +71 -26
  22. package/src/diffusion/diffusion-generate.ts +14 -8
  23. package/src/download.ts +103 -12
  24. package/src/eval/ifeval.ts +63 -120
  25. package/src/eval/runner.ts +14 -5
  26. package/src/eval/tasks/ifeval.ts +167 -31
  27. package/src/expert-io.ts +391 -0
  28. package/src/expert-residency.ts +1134 -0
  29. package/src/expert-usage.ts +415 -0
  30. package/src/generate.ts +114 -17
  31. package/src/hf-push.ts +1 -1
  32. package/src/index.ts +14 -2
  33. package/src/jobs/runner.ts +37 -18
  34. package/src/kv-store.ts +147 -8
  35. package/src/lora.ts +77 -14
  36. package/src/media-fetch.ts +198 -56
  37. package/src/mlx/array.ts +43 -0
  38. package/src/mlx/autograd.ts +51 -14
  39. package/src/mlx/checkpoint.ts +46 -13
  40. package/src/mlx/custom-vjp.ts +83 -35
  41. package/src/model/factory.ts +96 -5
  42. package/src/model/gemma4-base.ts +32 -0
  43. package/src/model/glm52-atlas.ts +511 -0
  44. package/src/model/glm52-cache.ts +706 -0
  45. package/src/model/glm52-config.ts +262 -0
  46. package/src/model/glm52-container.ts +305 -0
  47. package/src/model/glm52-coupling.ts +388 -0
  48. package/src/model/glm52-dsa.ts +602 -0
  49. package/src/model/glm52-expert-layout.ts +253 -0
  50. package/src/model/glm52-layout.ts +152 -0
  51. package/src/model/glm52-memory.ts +473 -0
  52. package/src/model/glm52-mla.ts +786 -0
  53. package/src/model/glm52-moe.ts +223 -0
  54. package/src/model/glm52-pilot.ts +537 -0
  55. package/src/model/glm52-reference.ts +445 -0
  56. package/src/model/glm52-residency.ts +557 -0
  57. package/src/model/glm52-resident-weights.ts +135 -0
  58. package/src/model/glm52-streamed-experts.ts +453 -0
  59. package/src/model/glm52-streamed-metal.ts +610 -0
  60. package/src/model/glm52-weights.ts +503 -0
  61. package/src/model/glm52.ts +1153 -0
  62. package/src/model/support.ts +8 -1
  63. package/src/native/expert_io.c +523 -0
  64. package/src/native-pack.ts +6 -4
  65. package/src/responses.ts +62 -49
  66. package/src/serve/batch-scheduler.ts +95 -16
  67. package/src/serve/generation-gateway.ts +41 -16
  68. package/src/server.ts +362 -81
  69. package/src/spec/glm52-mtp-source.ts +392 -0
  70. package/src/spec/serve-loop.ts +99 -18
  71. package/src/spec/source.ts +31 -6
  72. package/src/ssd-cache.ts +6 -2
  73. package/src/tool-call.ts +30 -2
  74. package/src/train/job.ts +0 -10
  75. package/src/train/lora-params.ts +17 -6
  76. package/src/train/trainer.ts +59 -26
  77. package/src/web/app.html +13 -2
  78. package/src/web/app.js +30 -3
  79. package/src/web/src/status.ts +51 -3
  80. package/src/web-tools.ts +54 -2
  81. package/src/weights.ts +82 -19
@@ -42,6 +42,126 @@ export interface RenderOptions {
42
42
  enableThinking?: boolean;
43
43
  }
44
44
 
45
+ type ChatRenderer = (
46
+ messages: ChatMessage[],
47
+ options: RenderOptions,
48
+ ) => string;
49
+
50
+ function glm52ContentText(content: ChatMessage["content"], label: string): string {
51
+ if (typeof content === "string") return content;
52
+ if (!Array.isArray(content))
53
+ throw new Error(`${label} must be a string or an array of text parts`);
54
+ return content.map((part, index) => {
55
+ if (
56
+ !part ||
57
+ (part.type !== "text" && part.type !== "input_text") ||
58
+ typeof part.text !== "string"
59
+ ) {
60
+ throw new Error(`${label}.${index}: GLM-5.2 supports text content only`);
61
+ }
62
+ return part.text;
63
+ }).join("");
64
+ }
65
+
66
+ /** Text/tool subset of the pinned GLM-5.2 template used by Colibri. */
67
+ export function renderGlm52Chat(
68
+ messages: ChatMessage[],
69
+ options: RenderOptions = {},
70
+ ): string {
71
+ if (!Array.isArray(messages) || messages.length === 0)
72
+ throw new Error("GLM-5.2 messages must be a non-empty array");
73
+ const {
74
+ addGenerationPrompt = true,
75
+ tools = null,
76
+ enableThinking = false,
77
+ } = options;
78
+ const prompt = ["[gMASK]<sop>"];
79
+ if (enableThinking)
80
+ prompt.push("<|system|>Reasoning Effort: Max");
81
+
82
+ const normalizedTools = normalizeToolSchemas(tools);
83
+ if (normalizedTools && normalizedTools.length > 0) {
84
+ prompt.push(
85
+ "<|system|>\n# Tools\n\nYou may call one or more functions to assist with the " +
86
+ "user query.\n\nYou are provided with function signatures within <tools></tools> " +
87
+ "XML tags:\n<tools>\n",
88
+ );
89
+ for (const tool of normalizedTools) {
90
+ const fn = { ...tool.function } as Record<string, unknown>;
91
+ delete fn.defer_loading;
92
+ delete fn.strict;
93
+ prompt.push(`${JSON.stringify(fn)}\n`);
94
+ }
95
+ prompt.push(
96
+ "</tools>\n\nFor each function call, output the function name and arguments " +
97
+ "within the following XML format:\n<tool_call>{function-name}" +
98
+ "<arg_key>{arg-key-1}</arg_key><arg_value>{arg-value-1}</arg_value>" +
99
+ "<arg_key>{arg-key-2}</arg_key><arg_value>{arg-value-2}</arg_value>...</tool_call>",
100
+ );
101
+ }
102
+
103
+ let previousWasTool = false;
104
+ for (let index = 0; index < messages.length; index++) {
105
+ const message = messages[index]!;
106
+ const role = message.role;
107
+ if (role === "system" || role === "developer") {
108
+ prompt.push(
109
+ `<|system|>${glm52ContentText(message.content, `messages.${index}.content`)}`,
110
+ );
111
+ } else if (role === "user") {
112
+ prompt.push(
113
+ `<|user|>${glm52ContentText(message.content, `messages.${index}.content`)}`,
114
+ );
115
+ } else if (role === "assistant") {
116
+ const content = message.content === null || message.content === undefined
117
+ ? ""
118
+ : glm52ContentText(message.content, `messages.${index}.content`);
119
+ prompt.push(`<|assistant|><think></think>${content.trim()}`);
120
+ for (const call of message.tool_calls ?? []) {
121
+ const raw = call.function.arguments;
122
+ let args: Record<string, unknown> = {};
123
+ if (typeof raw === "string") {
124
+ try {
125
+ const parsed = JSON.parse(raw);
126
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
127
+ args = parsed as Record<string, unknown>;
128
+ } catch {
129
+ // Match the pinned template path: malformed historical arguments
130
+ // contribute an empty call body rather than changing the prompt.
131
+ }
132
+ } else {
133
+ args = raw;
134
+ }
135
+ prompt.push(`<tool_call>${call.function.name}`);
136
+ for (const [key, value] of Object.entries(args)) {
137
+ prompt.push(
138
+ `<arg_key>${key}</arg_key><arg_value>` +
139
+ `${typeof value === "string" ? value : JSON.stringify(value)}</arg_value>`,
140
+ );
141
+ }
142
+ prompt.push("</tool_call>");
143
+ }
144
+ } else if (role === "tool") {
145
+ if (!previousWasTool) prompt.push("<|observation|>");
146
+ prompt.push(
147
+ `<tool_response>${glm52ContentText(message.content, `messages.${index}.content`)}` +
148
+ "</tool_response>",
149
+ );
150
+ } else {
151
+ throw new Error(`GLM-5.2 unsupported message role ${JSON.stringify(role)}`);
152
+ }
153
+ previousWasTool = role === "tool";
154
+ }
155
+ if (addGenerationPrompt) {
156
+ prompt.push(
157
+ enableThinking
158
+ ? "<|assistant|><think>"
159
+ : "<|assistant|><think></think>",
160
+ );
161
+ }
162
+ return prompt.join("");
163
+ }
164
+
45
165
  /** JSON-schema type implied by a literal value. */
46
166
  function jsonTypeOf(v: unknown): string {
47
167
  if (typeof v === "number") return Number.isInteger(v) ? "integer" : "number";
@@ -116,7 +236,8 @@ export function normalizeToolSchemas(tools: ToolDefinition[] | null): ToolDefini
116
236
  }
117
237
 
118
238
  export class ChatTemplate {
119
- readonly #template: Template;
239
+ readonly #template: Template | null;
240
+ readonly #renderer: ChatRenderer | null;
120
241
  readonly #bosToken: string | null;
121
242
  readonly #eosToken: string | null;
122
243
  /** Which reasoning format the template's `enable_thinking` channel uses, or
@@ -132,15 +253,20 @@ export class ChatTemplate {
132
253
  readonly supportsThinking: boolean;
133
254
 
134
255
  private constructor(
135
- source: string,
256
+ source: string | null,
136
257
  bosToken: string | null,
137
258
  eosToken: string | null,
138
259
  forceNoThinking = false,
260
+ renderer: ChatRenderer | null = null,
261
+ explicitThinkingFormat: "think-tag" | "gemma-channel" | null = null,
139
262
  ) {
140
- this.#template = new Template(source);
263
+ if (source === null && renderer === null)
264
+ throw new Error("ChatTemplate needs a Jinja source or renderer");
265
+ this.#template = source === null ? null : new Template(source);
266
+ this.#renderer = renderer;
141
267
  this.#bosToken = bosToken;
142
268
  this.#eosToken = eosToken;
143
- const gatesThinking = source.includes("enable_thinking");
269
+ const gatesThinking = source?.includes("enable_thinking") ?? false;
144
270
  // `forceNoThinking` suppresses the switchable channel even when the template
145
271
  // carries the gemma markers. DiffusionGemma ships the shared gemma-family
146
272
  // template (with the `<|channel>thought…<channel|>` reasoning channel), but
@@ -149,13 +275,13 @@ export class ChatTemplate {
149
275
  // reasoning, no answer). The OptiQ reference never enables thinking for this
150
276
  // model — it always renders the template's `default(false)` pre-closed empty
151
277
  // channel and decodes the whole canvas as plain text. We match that.
152
- this.thinkingFormat = forceNoThinking
278
+ this.thinkingFormat = explicitThinkingFormat ?? (forceNoThinking
153
279
  ? null
154
- : gatesThinking && source.includes("<think>")
280
+ : gatesThinking && source!.includes("<think>")
155
281
  ? "think-tag"
156
- : gatesThinking && source.includes("<|channel>")
282
+ : gatesThinking && source!.includes("<|channel>")
157
283
  ? "gemma-channel"
158
- : null;
284
+ : null);
159
285
  this.supportsThinking = this.thinkingFormat !== null;
160
286
  }
161
287
 
@@ -169,7 +295,25 @@ export class ChatTemplate {
169
295
  const jinjaFile = Bun.file(`${modelDir}/chat_template.jinja`);
170
296
  if (await jinjaFile.exists()) source = await jinjaFile.text();
171
297
  }
172
- if (!source) throw new Error(`${modelDir}: no chat template found`);
298
+ if (!source) {
299
+ const modelConfigFile = Bun.file(`${modelDir}/config.json`);
300
+ const modelConfig = await modelConfigFile.exists()
301
+ ? await modelConfigFile.json() as Record<string, unknown>
302
+ : null;
303
+ if (modelConfig?.model_type === "glm_moe_dsa") {
304
+ const tokenText = (t: unknown): string | null =>
305
+ t == null ? null : typeof t === "string" ? t : (t as any).content;
306
+ return new ChatTemplate(
307
+ null,
308
+ tokenText(config.bos_token),
309
+ tokenText(config.eos_token),
310
+ false,
311
+ renderGlm52Chat,
312
+ "think-tag",
313
+ );
314
+ }
315
+ throw new Error(`${modelDir}: no chat template found`);
316
+ }
173
317
  // @huggingface/jinja lacks the `min`/`max` array filters that real
174
318
  // Jinja2 has. MiniCPM5's template uses `[a, b]|min` in its assistant
175
319
  // tool-call history branch, so without this rewrite every multi-turn
@@ -189,8 +333,9 @@ export class ChatTemplate {
189
333
  }
190
334
 
191
335
  render(messages: ChatMessage[], options: RenderOptions = {}): string {
336
+ if (this.#renderer) return this.#renderer(messages, options);
192
337
  const { addGenerationPrompt = true, tools = null, enableThinking } = options;
193
- return this.#template.render({
338
+ return this.#template!.render({
194
339
  messages,
195
340
  add_generation_prompt: addGenerationPrompt,
196
341
  // Guarantee every tool param schema has a `type` so templates that do
package/src/cli.ts CHANGED
@@ -17,7 +17,15 @@ import { fit, skuMatrix, thisMachine } from "./fit";
17
17
  import { EvalDB } from "./evaldb";
18
18
  import pkg from "../package.json" with { type: "json" };
19
19
  import { renderHelp } from "./tui";
20
- import { isSupportedModelRecord } from "./model/support";
20
+ import { isGlm52Config, isSupportedModelRecord } from "./model/support";
21
+ import {
22
+ GLM52_G5_ASPIRATIONAL_DECODE_TPS,
23
+ GLM52_G5_DEFAULT_MAX_GENERATION_TOKENS,
24
+ GLM52_G5_DEFAULT_PROCESS_LIMIT_BYTES,
25
+ GLM52_G5_DIRECT_ORACLE_WARM_DECODE_TPS,
26
+ GLM52_G5_MEASURED_WARM_DECODE_TPS,
27
+ planGlm52MemoryForArtifact,
28
+ } from "./model/glm52-memory";
21
29
  import type { JobEvent } from "./jobs/types";
22
30
 
23
31
  const argv = process.argv.slice(2);
@@ -153,6 +161,11 @@ Model & quality:
153
161
  DSpark pins to its trained block width]
154
162
  --ngram-max <k> Prompt-lookup longest match window [default: 3]
155
163
  --ngram-min <k> Prompt-lookup shortest match window [default: 1]
164
+ --mtp <on|off> GLM-5.2 checkpoint-native MTP. On by default;
165
+ routes requests through the exact serial verify
166
+ lane. off enables ordinary continuous batching.
167
+ --context-length <n> GLM-5.2 resource-plan context reservation
168
+ [default: 4096]
156
169
  --kv-quant <mode> KV cache quantization: config (per-layer
157
170
  kv_config.json when the model ships one), off
158
171
  (bf16), 4 / 8 (uniform bits), or turbo[:k<bits>v
@@ -190,7 +203,8 @@ Model & quality:
190
203
  [default: the model's generation_config.json]
191
204
  --max-tokens <n> Completion cap when a request omits max_tokens
192
205
  (mlx_lm.server flag; its default is 512 there)
193
- [default: 65536 chat / 512 raw completion]
206
+ [default: 128 GLM-5.2; otherwise 65536 chat /
207
+ 512 raw completion]
194
208
  --hlg-sampling on|off Piecewise tone-curve sampling (HLG): rolls off the
195
209
  top, boosts the mids, gentles the tail. Gain folds
196
210
  from --temperature. [default: off]
@@ -285,7 +299,9 @@ Options:
285
299
 
286
300
  Resumable (Range requests against partial blobs) and verified (sha256
287
301
  for LFS blobs). Re-running after an interruption continues where it
288
- stopped. Uses HF_TOKEN / hf auth login credentials when present.
302
+ stopped. Before payload transfer, complete blobs and partial prefixes are
303
+ credited and the target volume must fit the remainder plus a 1 GiB reserve.
304
+ Uses HF_TOKEN / hf auth login credentials when present.
289
305
  When upstream pushed a new revision, the previous snapshot stays on
290
306
  disk — \`mlx-bun gc\` reclaims it.`,
291
307
 
@@ -1007,6 +1023,39 @@ function serverRuntimeFlags(): { port: number; serverOptions: import("./server")
1007
1023
  return { port: Number(opt("port", "8080")), serverOptions };
1008
1024
  }
1009
1025
 
1026
+ /** Direct Colibri runtime defaults shared by `serve` and the embedded `pi`
1027
+ * server. Other model families ignore this load-only option block. */
1028
+ function glm52RuntimeFlags(
1029
+ serverOptions: import("./server").ServerOptions,
1030
+ ): import("./model/factory").Glm52RuntimeOpenOptions {
1031
+ const mtpRaw = opt("mtp");
1032
+ let enableMtp: boolean | undefined;
1033
+ if (mtpRaw !== null) {
1034
+ if (["on", "1", "true"].includes(mtpRaw)) enableMtp = true;
1035
+ else if (["off", "0", "false"].includes(mtpRaw)) enableMtp = false;
1036
+ else {
1037
+ console.error(`--mtp expects on|off (got "${mtpRaw}")`);
1038
+ process.exit(1);
1039
+ }
1040
+ }
1041
+ const contextRaw = opt("context-length");
1042
+ let contextTokens: number | undefined;
1043
+ if (contextRaw !== null) {
1044
+ contextTokens = Number(contextRaw);
1045
+ if (!Number.isSafeInteger(contextTokens) || contextTokens < 1) {
1046
+ console.error(`--context-length expects an integer >= 1 (got "${contextRaw}")`);
1047
+ process.exit(1);
1048
+ }
1049
+ }
1050
+ return {
1051
+ memoryBudgetBytes: serverOptions.memoryBudgetBytes,
1052
+ batchSize: serverOptions.batch ?? 8,
1053
+ maxGenerationTokens: serverOptions.defaultMaxTokens ?? 128,
1054
+ ...(contextTokens !== undefined ? { contextTokens } : {}),
1055
+ ...(enableMtp !== undefined ? { enableMtp } : {}),
1056
+ };
1057
+ }
1058
+
1010
1059
  /** `--adapter <dir>` (alias `--adapter-path`, mlx_lm.server's spelling):
1011
1060
  * mount a LoRA adapter at startup — the same machinery as POST /v1/adapters —
1012
1061
  * and make it the default for requests that don't select one (a request's
@@ -1332,9 +1381,69 @@ switch (cmd) {
1332
1381
  if (reg.list().length === 0) await reg.scan();
1333
1382
  const m = reg.resolve(query);
1334
1383
  const config = await loadModelConfig(m.path);
1335
- const ctx = Number(opt("ctx", "8192"));
1336
- const r = fit(config, m.sizeBytes, ctx, thisMachine(), undefined, m.expertsBytes);
1337
1384
  const { box, table, style, h1, gradient } = await import("./tui");
1385
+ const glm = isGlm52Config(config);
1386
+ const ctx = Number(opt("ctx", glm ? "4096" : "8192"));
1387
+ if (glm) {
1388
+ const machine = thisMachine();
1389
+ let plan;
1390
+ try {
1391
+ plan = await planGlm52MemoryForArtifact(m.path, {
1392
+ machineBytes: machine.ramBytes,
1393
+ processLimitBytes: Math.min(
1394
+ GLM52_G5_DEFAULT_PROCESS_LIMIT_BYTES,
1395
+ machine.ramBytes,
1396
+ ),
1397
+ contextTokens: ctx,
1398
+ maxGenerationTokens: Math.min(
1399
+ ctx,
1400
+ GLM52_G5_DEFAULT_MAX_GENERATION_TOKENS,
1401
+ ),
1402
+ batchSize: 1,
1403
+ enableMtp: true,
1404
+ });
1405
+ } catch (error) {
1406
+ console.error(
1407
+ `GLM-5.2 resource plan refused: ` +
1408
+ `${error instanceof Error ? error.message : String(error)}`,
1409
+ );
1410
+ process.exitCode = 1;
1411
+ break;
1412
+ }
1413
+ const gib = (bytes: number): string => `${(bytes / 2 ** 30).toFixed(2)} GiB`;
1414
+ const li = plan.lineItems;
1415
+ const kvBytes = li.targetKvBytes + li.mtpKvBytes;
1416
+ const otherBytes = plan.plannedProcessBytes - li.residentWeightsBytes -
1417
+ li.mainExpertSlabBytes - li.mtpExpertSlabBytes - kvBytes;
1418
+ h1("will it fit?");
1419
+ console.log(` ${style.bold(m.repoId)} ${style.dim(`@ ${ctx.toLocaleString()} context · streamed GLM plan`)}`);
1420
+ console.log();
1421
+ box([
1422
+ `artifact on disk ${gib(m.sizeBytes).padStart(10)} ${style.dim("(streamed, not resident)")}`,
1423
+ `resident weights ${gib(li.residentWeightsBytes).padStart(10)}`,
1424
+ `main expert slab ${gib(li.mainExpertSlabBytes).padStart(10)}`,
1425
+ `MTP expert slab ${gib(li.mtpExpertSlabBytes).padStart(10)}`,
1426
+ `target + MTP KV ${gib(kvBytes).padStart(10)}`,
1427
+ `runtime reserves ${gib(otherBytes).padStart(10)}`,
1428
+ `process plan ${gib(plan.plannedProcessBytes).padStart(10)} ${style.dim(`of ${gib(plan.processLimitBytes)}`)} ${style.green(style.bold("FITS"))}`,
1429
+ `macOS headroom ${gib(plan.machineHeadroomBytes).padStart(10)}`,
1430
+ "",
1431
+ `max safe context ${style.bold(plan.contextTokens.toLocaleString())} tokens`,
1432
+ `max generation ${style.bold(plan.maxGenerationTokens.toLocaleString())} tokens`,
1433
+ `measured warm ${gradient(`${GLM52_G5_MEASURED_WARM_DECODE_TPS.toFixed(3)} tok/s`)} ${style.dim("(quality-preserving, MTP on)")}`,
1434
+ `direct oracle ${GLM52_G5_DIRECT_ORACLE_WARM_DECODE_TPS.toFixed(2)} tok/s`,
1435
+ `aspirational ${GLM52_G5_ASPIRATIONAL_DECODE_TPS.toFixed(1)} tok/s ${style.dim("(not a fit prediction or release gate)")}`,
1436
+ ]);
1437
+ if (flag("skus")) {
1438
+ console.log(style.dim(
1439
+ " GLM-5.2's direct-container preset is validated on the M1 Max 32 GB; " +
1440
+ "the generic resident-weight SKU projection does not apply.",
1441
+ ));
1442
+ }
1443
+ console.log();
1444
+ break;
1445
+ }
1446
+ const r = fit(config, m.sizeBytes, ctx, thisMachine(), undefined, m.expertsBytes);
1338
1447
  h1("will it fit?");
1339
1448
  console.log(` ${style.bold(m.repoId)} ${style.dim(`@ ${ctx.toLocaleString()} context · this machine`)}`);
1340
1449
  console.log();
@@ -1529,6 +1638,7 @@ switch (cmd) {
1529
1638
  const t0 = performance.now();
1530
1639
  const ctx = await loadContext(m.path, m.repoId, {
1531
1640
  memoryBudgetBytes: rt.serverOptions.memoryBudgetBytes,
1641
+ glm: glm52RuntimeFlags(rt.serverOptions),
1532
1642
  ...(draftModelDir || draftKind === "ngram"
1533
1643
  ? {
1534
1644
  ...(draftModelDir ? { draftModelDir } : {}),
@@ -1585,7 +1695,12 @@ switch (cmd) {
1585
1695
  process.exit(1);
1586
1696
  }
1587
1697
  const { loadTaskModel, generateText } = await import("./eval/runner");
1588
- const tm = await loadTaskModel(positional(0) ?? opt("query") ?? "");
1698
+ const maxTokens = Number(opt("max-tokens", "256"));
1699
+ const tm = await loadTaskModel(
1700
+ positional(0) ?? opt("query") ?? "",
1701
+ undefined,
1702
+ { maxGenerationTokens: maxTokens, enableMtp: false },
1703
+ );
1589
1704
  // Resolve the tier/--kv-quant KV scheme exactly the way serve does
1590
1705
  // (server.ts kvScheme): "config" = the model's kv_config.json (absent →
1591
1706
  // bf16, same silent fallback as serve), N = uniform bits from decode
@@ -1601,7 +1716,7 @@ switch (cmd) {
1601
1716
  : undefined;
1602
1717
  const num = (n: string): number | undefined => { const v = opt(n); return v == null ? undefined : Number(v); };
1603
1718
  const text = await generateText(tm, prompt, {
1604
- maxTokens: Number(opt("max-tokens", "256")),
1719
+ maxTokens,
1605
1720
  useChat: !flag("raw"),
1606
1721
  ...(kvScheme !== undefined ? { kvScheme } : {}),
1607
1722
  sampler: { temperature: num("temperature") ?? num("temp"), topP: num("top-p"), topK: num("top-k"), seed: num("seed") },
@@ -1883,6 +1998,7 @@ switch (cmd) {
1883
1998
  "--draft-model", "--num-draft-tokens", "--draft-kind", "--ngram-max", "--ngram-min",
1884
1999
  "--compiled-decode", "--compiled-activations", "--fused-sdpa", "--thinking",
1885
2000
  "--temperature", "--temp", "--top-p", "--top-k", "--max-tokens",
2001
+ "--mtp", "--context-length",
1886
2002
  "--hlg-sampling", "--hlg-width", "--hlg-shoulder", "--hlg-toe", "--hlg-pivot-offset",
1887
2003
  "--paged-kv-block-size",
1888
2004
  ]);
@@ -1948,7 +2064,10 @@ switch (cmd) {
1948
2064
  const { createServer, loadContext } = await import("./server");
1949
2065
  const sLoad = step(`loading ${m.repoId}`);
1950
2066
  const t0 = performance.now();
1951
- const ctx = await loadContext(m.path, m.repoId, { memoryBudgetBytes: rt.serverOptions.memoryBudgetBytes });
2067
+ const ctx = await loadContext(m.path, m.repoId, {
2068
+ memoryBudgetBytes: rt.serverOptions.memoryBudgetBytes,
2069
+ glm: glm52RuntimeFlags(rt.serverOptions),
2070
+ });
1952
2071
  await mountStartupAdapter(ctx, rt.serverOptions);
1953
2072
  createServer(ctx, port, { ...rt.serverOptions, owner: "pi-session" });
1954
2073
  startedServer = true;
package/src/config.ts CHANGED
@@ -2,6 +2,11 @@
2
2
  // Parses HF config.json (Gemma 4 unified layout: text_config nested),
3
3
  // the OptiQ per-layer quantization map, and kv_config.json when present.
4
4
 
5
+ import {
6
+ parseGlm52Config,
7
+ type Glm52Config,
8
+ } from "./model/glm52-config";
9
+
5
10
  export interface RopeParams {
6
11
  ropeTheta: number;
7
12
  ropeType: string;
@@ -223,6 +228,18 @@ export async function loadModelConfig(modelDir: string): Promise<ModelConfig> {
223
228
  const raw = (await Bun.file(`${modelDir}/config.json`).json()) as Record<string, any>;
224
229
  const modelType = raw.model_type;
225
230
  const isDiffusion = modelType === "diffusion_gemma";
231
+ const isGlm52 = modelType === "glm_moe_dsa";
232
+ // The converted Colibri artifact keeps stop metadata split across the two
233
+ // HF files. Preserve the old config-only behavior for every other family;
234
+ // GLM-5.2 explicitly unions both lists through its dedicated parser.
235
+ let glm52: Glm52Config | null = null;
236
+ if (isGlm52) {
237
+ const generationFile = Bun.file(`${modelDir}/generation_config.json`);
238
+ const generation = await generationFile.exists()
239
+ ? await generationFile.json() as Record<string, any>
240
+ : null;
241
+ glm52 = parseGlm52Config(modelDir, raw, generation);
242
+ }
226
243
  // Gemma 4 unified nests the LM config; plain text models keep it at top level.
227
244
  // DiffusionGemma keeps it flat too, but omits all dims — backfill from defaults.
228
245
  const baseT = (raw.text_config ?? raw) as Record<string, any>;
@@ -238,32 +255,44 @@ export async function loadModelConfig(modelDir: string): Promise<ModelConfig> {
238
255
  const qwenRope = (t.rope_parameters ?? {}) as Record<string, any>;
239
256
 
240
257
  const text: TextConfig = {
241
- hiddenSize: t.hidden_size,
242
- numHiddenLayers: t.num_hidden_layers,
243
- numAttentionHeads: t.num_attention_heads,
244
- numKeyValueHeads: t.num_key_value_heads,
245
- headDim: t.head_dim ?? Math.floor(t.hidden_size / t.num_attention_heads),
246
- numGlobalKeyValueHeads: t.num_global_key_value_heads ?? t.num_key_value_heads,
247
- globalHeadDim: t.global_head_dim ?? t.head_dim ?? Math.floor(t.hidden_size / t.num_attention_heads),
258
+ hiddenSize: glm52?.hiddenSize ?? t.hidden_size,
259
+ numHiddenLayers: glm52?.numHiddenLayers ?? t.num_hidden_layers,
260
+ numAttentionHeads: glm52?.numAttentionHeads ?? t.num_attention_heads,
261
+ numKeyValueHeads: glm52?.numKeyValueHeads ?? t.num_key_value_heads,
262
+ headDim: glm52?.qkHeadDim ?? t.head_dim ?? Math.floor(t.hidden_size / t.num_attention_heads),
263
+ numGlobalKeyValueHeads:
264
+ glm52?.numKeyValueHeads ?? t.num_global_key_value_heads ?? t.num_key_value_heads,
265
+ globalHeadDim:
266
+ glm52?.qkHeadDim ??
267
+ t.global_head_dim ??
268
+ t.head_dim ??
269
+ Math.floor(t.hidden_size / t.num_attention_heads),
248
270
  attentionKEqV: t.attention_k_eq_v ?? false,
249
- intermediateSize: t.intermediate_size,
271
+ intermediateSize: glm52?.intermediateSize ?? t.intermediate_size,
250
272
  hiddenActivation: t.hidden_activation ?? t.hidden_act ?? "gelu_pytorch_tanh",
251
- rmsNormEps: t.rms_norm_eps,
252
- vocabSize: t.vocab_size,
253
- maxPositionEmbeddings: t.max_position_embeddings,
273
+ rmsNormEps: glm52?.rmsNormEps ?? t.rms_norm_eps,
274
+ vocabSize: glm52?.vocabSize ?? t.vocab_size,
275
+ maxPositionEmbeddings:
276
+ glm52?.maxPositionEmbeddings ?? t.max_position_embeddings,
254
277
  slidingWindow: t.sliding_window ?? 0,
255
- layerTypes: t.layer_types ?? (isLlama ? Array(t.num_hidden_layers).fill("full_attention") : []),
278
+ layerTypes: glm52
279
+ ? Array(glm52.numHiddenLayers).fill("full_attention")
280
+ : t.layer_types ?? (isLlama ? Array(t.num_hidden_layers).fill("full_attention") : []),
256
281
  hiddenSizePerLayerInput: t.hidden_size_per_layer_input ?? 0,
257
- vocabSizePerLayerInput: t.vocab_size_per_layer_input ?? t.vocab_size,
282
+ vocabSizePerLayerInput:
283
+ t.vocab_size_per_layer_input ?? glm52?.vocabSize ?? t.vocab_size,
258
284
  numKvSharedLayers: t.num_kv_shared_layers ?? 0,
259
- enableMoeBlock: t.enable_moe_block ?? false,
260
- numExperts: t.num_experts ?? 0,
285
+ enableMoeBlock: glm52 ? true : t.enable_moe_block ?? false,
286
+ numExperts: glm52?.numRoutedExperts ?? t.num_experts ?? 0,
261
287
  // gemma4 uses `top_k_experts`; qwen3_moe uses `num_experts_per_tok`.
262
- topKExperts: t.top_k_experts ?? t.num_experts_per_tok ?? 0,
263
- moeIntermediateSize: t.moe_intermediate_size ?? 0,
264
- decoderSparseStep: t.decoder_sparse_step ?? 1,
265
- mlpOnlyLayers: t.mlp_only_layers ?? [],
266
- normTopkProb: t.norm_topk_prob ?? false,
288
+ topKExperts:
289
+ glm52?.numExpertsPerToken ?? t.top_k_experts ?? t.num_experts_per_tok ?? 0,
290
+ moeIntermediateSize: glm52?.moeIntermediateSize ?? t.moe_intermediate_size ?? 0,
291
+ decoderSparseStep: glm52 ? 1 : t.decoder_sparse_step ?? 1,
292
+ mlpOnlyLayers: glm52
293
+ ? Array.from({ length: glm52.firstKDenseReplace }, (_, layer) => layer)
294
+ : t.mlp_only_layers ?? [],
295
+ normTopkProb: glm52?.normTopkProb ?? t.norm_topk_prob ?? false,
267
296
  linearNumValueHeads: t.linear_num_value_heads ?? 0,
268
297
  linearNumKeyHeads: t.linear_num_key_heads ?? 0,
269
298
  linearKeyHeadDim: t.linear_key_head_dim ?? 0,
@@ -271,9 +300,19 @@ export async function loadModelConfig(modelDir: string): Promise<ModelConfig> {
271
300
  linearConvKernelDim: t.linear_conv_kernel_dim ?? 0,
272
301
  fullAttentionInterval: t.full_attention_interval ?? 0,
273
302
  attnOutputGate: t.attn_output_gate ?? false,
274
- partialRotaryFactor:
275
- qwenRope.partial_rotary_factor ?? t.partial_rotary_factor ?? 1.0,
276
- ropeParameters: isQwen35
303
+ partialRotaryFactor: glm52
304
+ ? glm52.qkRopeHeadDim / glm52.qkHeadDim
305
+ : qwenRope.partial_rotary_factor ?? t.partial_rotary_factor ?? 1.0,
306
+ ropeParameters: glm52
307
+ ? {
308
+ full_attention: {
309
+ ropeTheta: glm52.ropeTheta,
310
+ ropeType: "default",
311
+ partialRotaryFactor: glm52.qkRopeHeadDim / glm52.qkHeadDim,
312
+ factor: 1.0,
313
+ },
314
+ }
315
+ : isQwen35
277
316
  ? {
278
317
  full_attention: {
279
318
  ropeTheta: qwenRope.rope_theta ?? t.rope_theta ?? 10000,
@@ -297,7 +336,7 @@ export async function loadModelConfig(modelDir: string): Promise<ModelConfig> {
297
336
  finalLogitSoftcapping: t.final_logit_softcapping ?? null,
298
337
  tieWordEmbeddings: t.tie_word_embeddings ?? raw.tie_word_embeddings ?? false,
299
338
  bosTokenId: t.bos_token_id ?? raw.bos_token_id,
300
- eosTokenId: t.eos_token_id ?? raw.eos_token_id,
339
+ eosTokenId: glm52?.eosTokenIds ?? t.eos_token_id ?? raw.eos_token_id,
301
340
  canvasLength: raw.canvas_length ?? t.canvas_length,
302
341
  };
303
342
 
@@ -311,14 +350,20 @@ export async function loadModelConfig(modelDir: string): Promise<ModelConfig> {
311
350
  }));
312
351
  }
313
352
 
314
- const eos = raw.eos_token_id ?? text.eosTokenId;
353
+ const eos = glm52?.eosTokenIds ?? raw.eos_token_id ?? text.eosTokenId;
315
354
  return {
316
355
  modelDir,
317
356
  modelType,
318
357
  architectures: raw.architectures ?? [],
319
358
  dtype: raw.dtype ?? "bfloat16",
320
359
  text,
321
- quantization: parseQuantization(raw.quantization ?? raw.quantization_config),
360
+ // Colibri's config retains the SOURCE FP8 quantization_config, while the
361
+ // serving shards are converted U8 + `.qs` tensors whose int4/int8/group
362
+ // geometry is inferred and validated by Glm52Container. Advertising the
363
+ // source FP8 block as mlx affine quantization would be actively wrong.
364
+ quantization: glm52
365
+ ? null
366
+ : parseQuantization(raw.quantization ?? raw.quantization_config),
322
367
  kvQuant,
323
368
  hasVisionSidecar: await Bun.file(`${modelDir}/optiq_vision.safetensors`).exists(),
324
369
  eosTokenIds: Array.isArray(eos) ? eos : [eos],
@@ -245,9 +245,7 @@ export function diffusionGenerate(
245
245
 
246
246
  let currentCanvas = ops.randint(0, vocab, [1, canvasLength], I32); // RNG: init
247
247
  let draftReveal = ops.zeros([1, canvasLength], Dtype.bool);
248
- let draftCanvas: MlxArray = ops.logicalNot(ops.logicalNot(draftReveal)); // placeholder, replaced below
249
- draftCanvas.dispose();
250
- draftCanvas = ops.reshape(currentCanvas, [1, canvasLength]); // alias copy
248
+ let draftCanvas = ops.reshape(currentCanvas, [1, canvasLength]); // alias copy
251
249
  let argmaxCanvas = ops.reshape(currentCanvas, [1, canvasLength]);
252
250
  let scEmbeddings: MlxArray | null = null;
253
251
  const history: MlxArray[] = [];
@@ -266,7 +264,9 @@ export function diffusionGenerate(
266
264
  logits.dispose();
267
265
 
268
266
  argmaxCanvas.dispose();
269
- argmaxCanvas = ops.argmaxAxis(processed, -1).astype(I32); // [1, L]
267
+ const argmax = ops.argmaxAxis(processed, -1);
268
+ argmaxCanvas = argmax.astype(I32); // [1, L]
269
+ argmax.dispose();
270
270
 
271
271
  if (curStep === 1) {
272
272
  processed.dispose();
@@ -296,7 +296,9 @@ export function diffusionGenerate(
296
296
  currentCanvas.dispose();
297
297
  currentCanvas = newCurrent;
298
298
  draftReveal.dispose();
299
- draftReveal = ops.logicalNot(ops.logicalNot(acceptance)); // = acceptance
299
+ const notAcceptance = ops.logicalNot(acceptance);
300
+ draftReveal = ops.logicalNot(notAcceptance); // = acceptance
301
+ notAcceptance.dispose();
300
302
  draftCanvas.dispose();
301
303
  draftCanvas = ops.reshape(argmaxCanvas, [1, canvasLength]);
302
304
  accepted.dispose();
@@ -341,6 +343,10 @@ export function diffusionGenerate(
341
343
  stableStop &&
342
344
  stableAndConfident(argmaxCanvas, processed, history, stabilityThreshold, stopConfidence)
343
345
  ) {
346
+ // Entropy sampling precomputes the next step's self-conditioning
347
+ // tensor before evaluating this stop. The break means ownership is
348
+ // never transferred to scEmbeddings, so release the pending value.
349
+ nextSc?.dispose();
344
350
  processed.dispose();
345
351
  break;
346
352
  }
@@ -361,9 +367,9 @@ export function diffusionGenerate(
361
367
  draftCanvas.dispose();
362
368
 
363
369
  // Emit from the final argmax canvas.
364
- const blockTokens = [...argmaxCanvas.astype(Dtype.float32).toFloat32()].map((x) =>
365
- Math.round(x),
366
- );
370
+ const argmaxFloat = argmaxCanvas.astype(Dtype.float32);
371
+ const blockTokens = [...argmaxFloat.toFloat32()].map((x) => Math.round(x));
372
+ argmaxFloat.dispose();
367
373
  argmaxCanvas.dispose();
368
374
  blocks.push(blockTokens);
369
375
  totalSteps += stepsThisCanvas;