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/anthropic.ts CHANGED
@@ -469,58 +469,68 @@ export function translateOpenAiSse(
469
469
  const dec = new TextDecoder();
470
470
  let buf = "";
471
471
  let finalized = false;
472
+ let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
473
+ let cancelled = false;
472
474
  return new ReadableStream<Uint8Array>({
473
- async start(controller) {
475
+ start(controller) {
474
476
  const emit = (frames: string[]) => {
475
477
  for (const f of frames) controller.enqueue(enc.encode(f));
476
478
  };
477
- const reader = upstream.getReader();
478
- try {
479
- while (true) {
480
- const { done, value } = await reader.read();
481
- if (done) break;
482
- buf += dec.decode(value, { stream: true });
483
- let sep: number;
484
- while ((sep = buf.indexOf("\n\n")) !== -1) {
485
- const frame = buf.slice(0, sep);
486
- buf = buf.slice(sep + 2);
487
- const data = frame
488
- .split("\n")
489
- .find((l) => l.startsWith("data: "))
490
- ?.slice(6);
491
- if (data == null) continue;
492
- if (data === "[DONE]" || data === '"[DONE]"') {
493
- if (!finalized) {
494
- finalized = true;
495
- emit(translator.finalize());
479
+ reader = upstream.getReader();
480
+ void (async () => {
481
+ try {
482
+ while (!cancelled) {
483
+ const { done, value } = await reader!.read();
484
+ if (done) break;
485
+ buf += dec.decode(value, { stream: true });
486
+ let sep: number;
487
+ while ((sep = buf.indexOf("\n\n")) !== -1) {
488
+ const frame = buf.slice(0, sep);
489
+ buf = buf.slice(sep + 2);
490
+ const data = frame
491
+ .split("\n")
492
+ .find((l) => l.startsWith("data: "))
493
+ ?.slice(6);
494
+ if (data == null) continue;
495
+ if (data === "[DONE]" || data === '"[DONE]"') {
496
+ if (!finalized) {
497
+ finalized = true;
498
+ emit(translator.finalize());
499
+ }
500
+ continue;
496
501
  }
497
- continue;
498
- }
499
- const parsed = JSON.parse(data);
500
- if (parsed?.error) {
501
- emit([
502
- sse("error", {
503
- type: "error",
504
- error: { type: "api_error", message: parsed.error.message ?? "generation failed" },
505
- }),
506
- ]);
507
- continue;
502
+ const parsed = JSON.parse(data);
503
+ if (parsed?.error) {
504
+ emit([
505
+ sse("error", {
506
+ type: "error",
507
+ error: { type: "api_error", message: parsed.error.message ?? "generation failed" },
508
+ }),
509
+ ]);
510
+ continue;
511
+ }
512
+ emit(translator.addChunk(parsed));
508
513
  }
509
- emit(translator.addChunk(parsed));
510
514
  }
515
+ if (!cancelled && !finalized) emit(translator.finalize());
516
+ } catch (e) {
517
+ if (!cancelled)
518
+ emit([
519
+ sse("error", {
520
+ type: "error",
521
+ error: { type: "api_error", message: (e as Error).message },
522
+ }),
523
+ ]);
524
+ } finally {
525
+ reader?.releaseLock();
526
+ reader = null;
527
+ if (!cancelled) controller.close();
511
528
  }
512
- if (!finalized) emit(translator.finalize());
513
- } catch (e) {
514
- emit([
515
- sse("error", {
516
- type: "error",
517
- error: { type: "api_error", message: (e as Error).message },
518
- }),
519
- ]);
520
- } finally {
521
- reader.releaseLock();
522
- controller.close();
523
- }
529
+ })();
530
+ },
531
+ async cancel(reason) {
532
+ cancelled = true;
533
+ await reader?.cancel(reason);
524
534
  },
525
535
  });
526
536
  }
@@ -40,6 +40,134 @@ export interface RenderOptions {
40
40
  addGenerationPrompt?: boolean;
41
41
  tools?: ToolDefinition[] | null;
42
42
  enableThinking?: boolean;
43
+ /** Template-level reasoning depth (Qwen3.8: xhigh|medium|low — the template
44
+ * RAISES on other values, so callers must map OpenAI levels first and only
45
+ * set this for templates that read it; see ChatTemplate.readsReasoningEffort). */
46
+ reasoningEffort?: "xhigh" | "medium" | "low";
47
+ /** Keep think blocks from historical assistant turns in the rendered prompt
48
+ * (Qwen3.8 `preserve_thinking`, template default true — better prompt-cache
49
+ * reuse and agent-trace continuity). Only passed when the template reads it. */
50
+ preserveThinking?: boolean;
51
+ }
52
+
53
+ type ChatRenderer = (
54
+ messages: ChatMessage[],
55
+ options: RenderOptions,
56
+ ) => string;
57
+
58
+ function glm52ContentText(content: ChatMessage["content"], label: string): string {
59
+ if (typeof content === "string") return content;
60
+ if (!Array.isArray(content))
61
+ throw new Error(`${label} must be a string or an array of text parts`);
62
+ return content.map((part, index) => {
63
+ if (
64
+ !part ||
65
+ (part.type !== "text" && part.type !== "input_text") ||
66
+ typeof part.text !== "string"
67
+ ) {
68
+ throw new Error(`${label}.${index}: GLM-5.2 supports text content only`);
69
+ }
70
+ return part.text;
71
+ }).join("");
72
+ }
73
+
74
+ /** Text/tool subset of the pinned GLM-5.2 template used by Colibri. */
75
+ export function renderGlm52Chat(
76
+ messages: ChatMessage[],
77
+ options: RenderOptions = {},
78
+ ): string {
79
+ if (!Array.isArray(messages) || messages.length === 0)
80
+ throw new Error("GLM-5.2 messages must be a non-empty array");
81
+ const {
82
+ addGenerationPrompt = true,
83
+ tools = null,
84
+ enableThinking = false,
85
+ } = options;
86
+ const prompt = ["[gMASK]<sop>"];
87
+ if (enableThinking)
88
+ prompt.push("<|system|>Reasoning Effort: Max");
89
+
90
+ const normalizedTools = normalizeToolSchemas(tools);
91
+ if (normalizedTools && normalizedTools.length > 0) {
92
+ prompt.push(
93
+ "<|system|>\n# Tools\n\nYou may call one or more functions to assist with the " +
94
+ "user query.\n\nYou are provided with function signatures within <tools></tools> " +
95
+ "XML tags:\n<tools>\n",
96
+ );
97
+ for (const tool of normalizedTools) {
98
+ const fn = { ...tool.function } as Record<string, unknown>;
99
+ delete fn.defer_loading;
100
+ delete fn.strict;
101
+ prompt.push(`${JSON.stringify(fn)}\n`);
102
+ }
103
+ prompt.push(
104
+ "</tools>\n\nFor each function call, output the function name and arguments " +
105
+ "within the following XML format:\n<tool_call>{function-name}" +
106
+ "<arg_key>{arg-key-1}</arg_key><arg_value>{arg-value-1}</arg_value>" +
107
+ "<arg_key>{arg-key-2}</arg_key><arg_value>{arg-value-2}</arg_value>...</tool_call>",
108
+ );
109
+ }
110
+
111
+ let previousWasTool = false;
112
+ for (let index = 0; index < messages.length; index++) {
113
+ const message = messages[index]!;
114
+ const role = message.role;
115
+ if (role === "system" || role === "developer") {
116
+ prompt.push(
117
+ `<|system|>${glm52ContentText(message.content, `messages.${index}.content`)}`,
118
+ );
119
+ } else if (role === "user") {
120
+ prompt.push(
121
+ `<|user|>${glm52ContentText(message.content, `messages.${index}.content`)}`,
122
+ );
123
+ } else if (role === "assistant") {
124
+ const content = message.content === null || message.content === undefined
125
+ ? ""
126
+ : glm52ContentText(message.content, `messages.${index}.content`);
127
+ prompt.push(`<|assistant|><think></think>${content.trim()}`);
128
+ for (const call of message.tool_calls ?? []) {
129
+ const raw = call.function.arguments;
130
+ let args: Record<string, unknown> = {};
131
+ if (typeof raw === "string") {
132
+ try {
133
+ const parsed = JSON.parse(raw);
134
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
135
+ args = parsed as Record<string, unknown>;
136
+ } catch {
137
+ // Match the pinned template path: malformed historical arguments
138
+ // contribute an empty call body rather than changing the prompt.
139
+ }
140
+ } else {
141
+ args = raw;
142
+ }
143
+ prompt.push(`<tool_call>${call.function.name}`);
144
+ for (const [key, value] of Object.entries(args)) {
145
+ prompt.push(
146
+ `<arg_key>${key}</arg_key><arg_value>` +
147
+ `${typeof value === "string" ? value : JSON.stringify(value)}</arg_value>`,
148
+ );
149
+ }
150
+ prompt.push("</tool_call>");
151
+ }
152
+ } else if (role === "tool") {
153
+ if (!previousWasTool) prompt.push("<|observation|>");
154
+ prompt.push(
155
+ `<tool_response>${glm52ContentText(message.content, `messages.${index}.content`)}` +
156
+ "</tool_response>",
157
+ );
158
+ } else {
159
+ throw new Error(`GLM-5.2 unsupported message role ${JSON.stringify(role)}`);
160
+ }
161
+ previousWasTool = role === "tool";
162
+ }
163
+ if (addGenerationPrompt) {
164
+ prompt.push(
165
+ enableThinking
166
+ ? "<|assistant|><think>"
167
+ : "<|assistant|><think></think>",
168
+ );
169
+ }
170
+ return prompt.join("");
43
171
  }
44
172
 
45
173
  /** JSON-schema type implied by a literal value. */
@@ -116,9 +244,18 @@ export function normalizeToolSchemas(tools: ToolDefinition[] | null): ToolDefini
116
244
  }
117
245
 
118
246
  export class ChatTemplate {
119
- readonly #template: Template;
247
+ readonly #template: Template | null;
248
+ readonly #renderer: ChatRenderer | null;
120
249
  readonly #bosToken: string | null;
121
250
  readonly #eosToken: string | null;
251
+ /** True when the template reads `reasoning_effort` (Qwen3.8's xhigh/medium/
252
+ * low depth control). Callers must not pass the variable otherwise — and
253
+ * must never pass unmapped OpenAI level names; this template family raises
254
+ * on values outside its supported set. */
255
+ readonly readsReasoningEffort: boolean;
256
+ /** True when the template reads `preserve_thinking` (Qwen3.8: keep think
257
+ * blocks from historical turns; template default true). */
258
+ readonly readsPreserveThinking: boolean;
122
259
  /** Which reasoning format the template's `enable_thinking` channel uses, or
123
260
  * null if the model has no switchable reasoning:
124
261
  * - "think-tag": `<think>…</think>` text markers (Qwen3.5, MiniCPM5) — split
@@ -132,15 +269,22 @@ export class ChatTemplate {
132
269
  readonly supportsThinking: boolean;
133
270
 
134
271
  private constructor(
135
- source: string,
272
+ source: string | null,
136
273
  bosToken: string | null,
137
274
  eosToken: string | null,
138
275
  forceNoThinking = false,
276
+ renderer: ChatRenderer | null = null,
277
+ explicitThinkingFormat: "think-tag" | "gemma-channel" | null = null,
139
278
  ) {
140
- this.#template = new Template(source);
279
+ if (source === null && renderer === null)
280
+ throw new Error("ChatTemplate needs a Jinja source or renderer");
281
+ this.#template = source === null ? null : new Template(source);
282
+ this.#renderer = renderer;
141
283
  this.#bosToken = bosToken;
142
284
  this.#eosToken = eosToken;
143
- const gatesThinking = source.includes("enable_thinking");
285
+ const gatesThinking = source?.includes("enable_thinking") ?? false;
286
+ this.readsReasoningEffort = source?.includes("reasoning_effort") ?? false;
287
+ this.readsPreserveThinking = source?.includes("preserve_thinking") ?? false;
144
288
  // `forceNoThinking` suppresses the switchable channel even when the template
145
289
  // carries the gemma markers. DiffusionGemma ships the shared gemma-family
146
290
  // template (with the `<|channel>thought…<channel|>` reasoning channel), but
@@ -149,13 +293,13 @@ export class ChatTemplate {
149
293
  // reasoning, no answer). The OptiQ reference never enables thinking for this
150
294
  // model — it always renders the template's `default(false)` pre-closed empty
151
295
  // channel and decodes the whole canvas as plain text. We match that.
152
- this.thinkingFormat = forceNoThinking
296
+ this.thinkingFormat = explicitThinkingFormat ?? (forceNoThinking
153
297
  ? null
154
- : gatesThinking && source.includes("<think>")
298
+ : gatesThinking && source!.includes("<think>")
155
299
  ? "think-tag"
156
- : gatesThinking && source.includes("<|channel>")
300
+ : gatesThinking && source!.includes("<|channel>")
157
301
  ? "gemma-channel"
158
- : null;
302
+ : null);
159
303
  this.supportsThinking = this.thinkingFormat !== null;
160
304
  }
161
305
 
@@ -169,7 +313,25 @@ export class ChatTemplate {
169
313
  const jinjaFile = Bun.file(`${modelDir}/chat_template.jinja`);
170
314
  if (await jinjaFile.exists()) source = await jinjaFile.text();
171
315
  }
172
- if (!source) throw new Error(`${modelDir}: no chat template found`);
316
+ if (!source) {
317
+ const modelConfigFile = Bun.file(`${modelDir}/config.json`);
318
+ const modelConfig = await modelConfigFile.exists()
319
+ ? await modelConfigFile.json() as Record<string, unknown>
320
+ : null;
321
+ if (modelConfig?.model_type === "glm_moe_dsa") {
322
+ const tokenText = (t: unknown): string | null =>
323
+ t == null ? null : typeof t === "string" ? t : (t as any).content;
324
+ return new ChatTemplate(
325
+ null,
326
+ tokenText(config.bos_token),
327
+ tokenText(config.eos_token),
328
+ false,
329
+ renderGlm52Chat,
330
+ "think-tag",
331
+ );
332
+ }
333
+ throw new Error(`${modelDir}: no chat template found`);
334
+ }
173
335
  // @huggingface/jinja lacks the `min`/`max` array filters that real
174
336
  // Jinja2 has. MiniCPM5's template uses `[a, b]|min` in its assistant
175
337
  // tool-call history branch, so without this rewrite every multi-turn
@@ -189,14 +351,30 @@ export class ChatTemplate {
189
351
  }
190
352
 
191
353
  render(messages: ChatMessage[], options: RenderOptions = {}): string {
192
- const { addGenerationPrompt = true, tools = null, enableThinking } = options;
193
- return this.#template.render({
354
+ if (this.#renderer) return this.#renderer(messages, options);
355
+ const {
356
+ addGenerationPrompt = true,
357
+ tools = null,
358
+ enableThinking,
359
+ reasoningEffort,
360
+ preserveThinking,
361
+ } = options;
362
+ return this.#template!.render({
194
363
  messages,
195
364
  add_generation_prompt: addGenerationPrompt,
196
365
  // Guarantee every tool param schema has a `type` so templates that do
197
366
  // `value['type'] | upper` (Gemma) never see UndefinedValue.
198
367
  tools: normalizeToolSchemas(tools),
199
368
  ...(enableThinking !== undefined ? { enable_thinking: enableThinking } : {}),
369
+ // Only set for templates that read them: reasoning_effort values are
370
+ // template-validated (Qwen3.8 raises on unknown levels), and an unread
371
+ // variable in other templates is dead context at best.
372
+ ...(reasoningEffort !== undefined && this.readsReasoningEffort
373
+ ? { reasoning_effort: reasoningEffort }
374
+ : {}),
375
+ ...(preserveThinking !== undefined && this.readsPreserveThinking
376
+ ? { preserve_thinking: preserveThinking }
377
+ : {}),
200
378
  bos_token: this.#bosToken,
201
379
  eos_token: this.#eosToken,
202
380
  });
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;