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
package/src/server.ts CHANGED
@@ -53,9 +53,29 @@ import { Weights } from "./weights";
53
53
  import { Gemma4Model } from "./model/gemma4";
54
54
  import { DiffusionGemmaModel } from "./model/diffusion-gemma";
55
55
  import { spliceImageTokens } from "./vision/diffusion-vision";
56
- import { createModel, type RuntimeModel } from "./model/factory";
57
- import { isMiniCPM5Config } from "./model/support";
58
- import { generate, type GenerateOptions, type TokenLogprobs } from "./generate";
56
+ import {
57
+ createModel,
58
+ openGlm52RuntimeModel,
59
+ type Glm52RuntimeOpenOptions,
60
+ type RuntimeModel,
61
+ } from "./model/factory";
62
+ import { Glm52Model } from "./model/glm52";
63
+ import {
64
+ GLM52_G5_ASPIRATIONAL_DECODE_TPS,
65
+ GLM52_G5_DIRECT_ORACLE_WARM_DECODE_TPS,
66
+ GLM52_G5_MEASURED_AT,
67
+ GLM52_G5_MEASURED_WARM_DECODE_TPS,
68
+ type Glm52MemoryPlan,
69
+ } from "./model/glm52-memory";
70
+ import { isGlm52Config, isMiniCPM5Config } from "./model/support";
71
+ import { Glm52NativeMtpProvider } from "./spec/glm52-mtp-source";
72
+ import {
73
+ generate,
74
+ type GenerateOptions,
75
+ type TokenLogprobs,
76
+ withModelUsageFlush,
77
+ withModelWiredLimit,
78
+ } from "./generate";
59
79
  import { cloneKvCaches, SpillQueue } from "./kv-store";
60
80
  import { TURBOQUANT_HEAD_DIMS } from "./mlx/turboquant-tables";
61
81
  import {
@@ -245,6 +265,25 @@ export interface ServerContext {
245
265
  provider: import("./spec/source").DraftProvider;
246
266
  numDraftTokens: number;
247
267
  } | null;
268
+ /** Present only for the direct Colibri GLM runtime. This is the exact
269
+ * header-derived process equation used before opening resident state. */
270
+ glmMemoryPlan?: Glm52MemoryPlan | null;
271
+ }
272
+
273
+ export interface LoadContextOptions {
274
+ memoryBudgetBytes?: number;
275
+ /** Direct Colibri runtime/resource overrides. Ignored by other models. */
276
+ glm?: Glm52RuntimeOpenOptions;
277
+ /** Snapshot dir of a draft model for speculative decoding
278
+ * (`--draft-model`). Loaded alongside the target; the pair must share
279
+ * a tokenizer family. */
280
+ draftModelDir?: string;
281
+ /** Drafts per round (`--num-draft-tokens`, mlx_lm.server default 3). */
282
+ numDraftTokens?: number;
283
+ /** Draft-provider kind override (`--draft-kind`). */
284
+ draftKind?: DraftKind;
285
+ ngramMax?: number;
286
+ ngramMin?: number;
248
287
  }
249
288
 
250
289
  export interface GenSamplingDefaults {
@@ -297,33 +336,38 @@ async function loadGenSamplingDefaults(modelDir: string): Promise<GenSamplingDef
297
336
 
298
337
  export async function loadContext(
299
338
  modelDir: string, modelId?: string,
300
- opts: {
301
- memoryBudgetBytes?: number;
302
- /** Snapshot dir of a draft model for speculative decoding
303
- * (`--draft-model`). Loaded alongside the target; the pair must share
304
- * a tokenizer family (hard check below — upstream's silent-garbage
305
- * mode on mismatched tokenizers isn't worth inheriting). */
306
- draftModelDir?: string;
307
- /** Drafts per round (`--num-draft-tokens`, mlx_lm.server default 3). */
308
- numDraftTokens?: number;
309
- /** Draft-provider kind override (`--draft-kind`); auto-detected from the
310
- * draft artifact when absent (dspark.json → dspark; *_assistant config →
311
- * assistant; otherwise two-model). "ngram" mounts WITHOUT a draft dir. */
312
- draftKind?: DraftKind;
313
- /** Prompt-lookup window bounds (`--ngram-max` / `--ngram-min`), ngram
314
- * kind only. Defaults 3 / 1 (Saxena's reference values). */
315
- ngramMax?: number;
316
- ngramMin?: number;
317
- } = {},
339
+ opts: LoadContextOptions = {},
318
340
  ): Promise<ServerContext> {
319
341
  const config = await loadModelConfig(modelDir);
320
- const weights = await Weights.open(modelDir);
342
+ const glm = isGlm52Config(config);
343
+ const externalDraft = opts.draftModelDir !== undefined || opts.draftKind !== undefined;
344
+ if (glm && externalDraft && opts.glm?.enableMtp === true)
345
+ throw new Error("native GLM MTP and --draft-model/--draft-kind are mutually exclusive");
346
+ let weights: Weights | null = null;
347
+ let model!: RuntimeModel;
348
+ let glmMemoryPlan: Glm52MemoryPlan | null = null;
349
+ if (glm) {
350
+ const enableNativeMtp = !externalDraft && opts.glm?.enableMtp !== false;
351
+ const opened = await openGlm52RuntimeModel(modelDir, {
352
+ ...opts.glm,
353
+ memoryBudgetBytes: opts.memoryBudgetBytes ?? opts.glm?.memoryBudgetBytes,
354
+ // Every drafter owns the serial speculative lane. Do not reserve a
355
+ // simultaneous ordinary batch that cannot be admitted while it is live.
356
+ batchSize: enableNativeMtp || externalDraft ? 1 : opts.glm?.batchSize,
357
+ // An explicit alternate drafter owns the one speculative lane.
358
+ enableMtp: enableNativeMtp,
359
+ });
360
+ model = opened.model;
361
+ glmMemoryPlan = opened.plan;
362
+ } else {
363
+ weights = await Weights.open(modelDir);
364
+ }
321
365
  // memoryBudget enforcement at load (Phase 5): Weights.open only mmaps
322
366
  // (no GPU allocation yet), so a model whose weights can never serve
323
367
  // within the budget is refused HERE — before any unified-memory
324
368
  // commitment — with an actionable error instead of a Metal OOM later.
325
- if (opts.memoryBudgetBytes) {
326
- const weightsBytes = [...weights.shards.files.values()]
369
+ if (!glm && opts.memoryBudgetBytes) {
370
+ const weightsBytes = [...weights!.shards.files.values()]
327
371
  .reduce((a, f) => a + f.mmap.size, 0);
328
372
  const report = fit(config, weightsBytes, 1, undefined, undefined, 0, opts.memoryBudgetBytes);
329
373
  if (report.maxSafeContext < 1)
@@ -335,7 +379,7 @@ export async function loadContext(
335
379
  }
336
380
  // generated-specialization dispatch by config fingerprint (Phase C);
337
381
  // unmatched configs run the monolith — slow, never broken
338
- const model = createModel(weights, config);
382
+ if (!glm) model = createModel(weights!, config);
339
383
  const tokenizer = await loadTokenizer(modelDir);
340
384
  // Generation must stop on the tokenizer's eos_token — the chat turn
341
385
  // terminator (e.g. Qwen <|im_end|> = 248046). Some configs (Qwen3.5-4B)
@@ -433,7 +477,9 @@ export async function loadContext(
433
477
  }
434
478
  }
435
479
  if (opts.memoryBudgetBytes) {
436
- const targetBytes = [...weights.shards.files.values()].reduce((a, f) => a + f.mmap.size, 0);
480
+ const targetBytes = weights
481
+ ? [...weights.shards.files.values()].reduce((a, f) => a + f.mmap.size, 0)
482
+ : model.weightsBytes;
437
483
  // Draft weights shrink the target's envelope. Draft KV is not modeled
438
484
  // (small relative to its weights at serve contexts); admission stays
439
485
  // approximately conservative via the combined-weights term.
@@ -453,9 +499,20 @@ export async function loadContext(
453
499
  throw new Error(`--draft-kind ${opts.draftKind} requires --draft-model`);
454
500
  }
455
501
 
502
+ // GLM's checkpoint-native MTP row is the production default. It uses the
503
+ // already-planned bounded auxiliary expert tier and the same tokenizer, so
504
+ // there is no second artifact or compatibility probe to load.
505
+ if (!draft && model instanceof Glm52Model && glmMemoryPlan?.enableMtp) {
506
+ draft = {
507
+ provider: new Glm52NativeMtpProvider(model),
508
+ numDraftTokens: glmMemoryPlan.mtpDraftTokens,
509
+ };
510
+ }
511
+
456
512
  return {
457
513
  draft,
458
514
  model,
515
+ glmMemoryPlan,
459
516
  adapters: new AdapterManager(model),
460
517
  kvConfig: config.kvQuant,
461
518
  genDefaults: await loadGenSamplingDefaults(modelDir),
@@ -1167,16 +1224,21 @@ export function nextDefaultSeed(): number {
1167
1224
  * compile fails — the response_format degrades to prompt injection rather
1168
1225
  * than a 500, oMLX parity). Returns null for {type:"text"} / unset. */
1169
1226
  export function degradeJsonSystemPrompt(body: ChatRequest): string | null {
1227
+ if (body.guided_grammar) {
1228
+ return "You must respond with text matching this grammar:\n\n" + body.guided_grammar;
1229
+ }
1170
1230
  const rf = body.response_format as { type?: string; json_schema?: { name?: string; description?: string; schema?: unknown } } | undefined;
1171
- if (!rf || typeof rf !== "object") return null;
1172
- const type = rf.type ?? "text";
1173
- if (type === "text") return null;
1174
- if (type === "json_object") {
1231
+ if (rf && typeof rf === "object" && rf.type === "json_object") {
1175
1232
  return "You must respond with valid JSON only. " +
1176
1233
  "Do not include any explanation or text outside the JSON object.";
1177
1234
  }
1178
- if (type === "json_schema") {
1179
- const spec = rf.json_schema ?? {};
1235
+ const schemaSpec = rf && typeof rf === "object" && rf.type === "json_schema"
1236
+ ? rf.json_schema
1237
+ : body.structured_outputs && typeof body.structured_outputs === "object"
1238
+ ? { schema: body.structured_outputs }
1239
+ : null;
1240
+ if (schemaSpec) {
1241
+ const spec = schemaSpec;
1180
1242
  const schema = spec.schema ?? body.structured_outputs ?? {};
1181
1243
  const name = spec.name ?? "response";
1182
1244
  const description = spec.description ?? "";
@@ -1186,9 +1248,33 @@ export function degradeJsonSystemPrompt(body: ChatRequest): string | null {
1186
1248
  "Respond with only the JSON object, no additional text or explanation.";
1187
1249
  return prompt;
1188
1250
  }
1251
+ if (body.guided_regex) {
1252
+ return "You must respond with text matching this regular expression:\n\n" +
1253
+ body.guided_regex;
1254
+ }
1255
+ if (body.guided_choice?.length) {
1256
+ return "You must respond with exactly one of these choices:\n\n" +
1257
+ body.guided_choice.map((choice) => JSON.stringify(choice)).join("\n");
1258
+ }
1189
1259
  return null;
1190
1260
  }
1191
1261
 
1262
+ export function applyGrammarDegrade(
1263
+ body: ChatRequest,
1264
+ degradeHint: string,
1265
+ ): { body: ChatRequest; warning: string } {
1266
+ const content = degradeJsonSystemPrompt(body) ??
1267
+ "Follow the requested output constraint exactly.";
1268
+ return {
1269
+ warning:
1270
+ `grammar not enforced: ${degradeHint} - falling back to prompt injection`,
1271
+ body: {
1272
+ ...body,
1273
+ messages: [{ role: "system", content }, ...body.messages],
1274
+ },
1275
+ };
1276
+ }
1277
+
1192
1278
  export function validateLogprobsParams(body: {
1193
1279
  logprobs?: unknown;
1194
1280
  top_logprobs?: unknown;
@@ -1293,9 +1379,14 @@ class LogprobsCollector {
1293
1379
  *
1294
1380
  * Exported for unit tests (serve-detok mlx-lm byte parity). */
1295
1381
  export class StreamDecoder {
1382
+ static readonly #WINDOW_TOKENS = 32;
1296
1383
  #ids: number[] = [];
1297
1384
  #emitted = "";
1298
1385
  #warnedRevision = false;
1386
+ /** Token index where the bounded decode suffix begins. */
1387
+ #windowStart = 0;
1388
+ /** Exact decoded text before #windowStart, established by suffix matching. */
1389
+ #windowPrefix = "";
1299
1390
  readonly #trimLeadingSpace: boolean;
1300
1391
  readonly #bareSpaceId: number | undefined;
1301
1392
 
@@ -1308,17 +1399,50 @@ export class StreamDecoder {
1308
1399
  }
1309
1400
 
1310
1401
  #decode(ids: number[]): string {
1311
- const full = this.tokenizer.decode(ids, this.skipSpecialTokens);
1402
+ const full = this.#decodeRaw(ids);
1312
1403
  return this.#trimLeadingSpace && full.startsWith(" ") ? full.slice(1) : full;
1313
1404
  }
1314
1405
 
1406
+ #decodeRaw(ids: number[]): string {
1407
+ return this.tokenizer.decode(ids, this.skipSpecialTokens);
1408
+ }
1409
+
1410
+ /** Decode only a bounded suffix once an exact text anchor can be proven.
1411
+ *
1412
+ * At each rebase, decode a candidate suffix independently and accept it only
1413
+ * when it is literally the suffix of the exact text decoded so far. This
1414
+ * preserves ByteLevel/SPM boundary behavior while reducing steady-state
1415
+ * decode work from the whole generation to 32–64 tokens. If no suffix can be
1416
+ * anchored (an unusual/global decoder), leave #windowStart at zero and keep
1417
+ * the full-history correctness path. */
1418
+ #decodeIncremental(): string {
1419
+ const full = this.#windowStart === 0
1420
+ ? this.#decode(this.#ids)
1421
+ : this.#windowPrefix + this.#decodeRaw(this.#ids.slice(this.#windowStart));
1422
+
1423
+ if (this.#ids.length - this.#windowStart < StreamDecoder.#WINDOW_TOKENS * 2)
1424
+ return full;
1425
+
1426
+ const preferred = this.#ids.length - StreamDecoder.#WINDOW_TOKENS;
1427
+ const oldest = Math.max(this.#windowStart, preferred - StreamDecoder.#WINDOW_TOKENS);
1428
+ for (let start = preferred; start >= oldest; start--) {
1429
+ const tail = this.#decodeRaw(this.#ids.slice(start));
1430
+ if ((tail.length > 0 || full.length === 0) && full.endsWith(tail)) {
1431
+ this.#windowStart = start;
1432
+ this.#windowPrefix = full.slice(0, full.length - tail.length);
1433
+ break;
1434
+ }
1435
+ }
1436
+ return full;
1437
+ }
1438
+
1315
1439
  push(token: number): string {
1316
1440
  this.#ids.push(token);
1317
1441
  // Bare-space hold-back (mlx-lm add_token keeps "Ġ" in _unflushed): don't
1318
1442
  // advance #emitted; the held space(s) flush as part of the next
1319
1443
  // non-bare-space token's delta — consecutive bare spaces accumulate.
1320
1444
  if (token === this.#bareSpaceId) return "";
1321
- const full = this.#decode(this.#ids);
1445
+ const full = this.#decodeIncremental();
1322
1446
  // hold back a trailing replacement char (partial multi-byte sequence)
1323
1447
  const stable = full.endsWith("�") ? full.slice(0, -1) : full;
1324
1448
  if (!stable.startsWith(this.#emitted)) {
@@ -1396,6 +1520,8 @@ export function createServer(
1396
1520
  // agentic sub-agent workload. --batch 1 pins strict serial for
1397
1521
  // arrival-independent numerics. 8 = optiq's Mac-safe concurrency.
1398
1522
  const batch = Math.max(1, Math.floor(serverOptions.batch ?? 8));
1523
+ const defaultGeneratedTokens =
1524
+ serverOptions.defaultMaxTokens ?? ctx.glmMemoryPlan?.maxGenerationTokens;
1399
1525
 
1400
1526
  // KV-quant scheme, resolved once. UNSET now means bf16 (flipped
1401
1527
  // 2026-07-05 with the naked-=-L1 default): quantized KV measured 5–20%
@@ -1634,9 +1760,15 @@ export function createServer(
1634
1760
  !options.pagedKv
1635
1761
  ) {
1636
1762
  const { specServeRun } = await import("./spec/serve-loop");
1637
- return specServeRun(
1638
- ctx.model, ctx.draft.provider, ctx.draft.numDraftTokens,
1639
- promptIds, options, onToken,
1763
+ return withModelWiredLimit(
1764
+ ctx.model,
1765
+ () => withModelUsageFlush(
1766
+ ctx.model,
1767
+ () => specServeRun(
1768
+ ctx.model, ctx.draft!.provider, ctx.draft!.numDraftTokens,
1769
+ promptIds, options, onToken,
1770
+ ),
1771
+ ),
1640
1772
  );
1641
1773
  }
1642
1774
  // Cache entries are adapter-specific: KV computed under one adapter
@@ -1880,17 +2012,27 @@ export function createServer(
1880
2012
  // fit() solves max safe context from weights + KV growth + prefill
1881
2013
  // transient; the KV term assumes bf16 (a kv-quant scheme stretches the
1882
2014
  // real ceiling, never shrinks it — admission stays conservative).
1883
- const admission = fit(
2015
+ const genericAdmission = fit(
1884
2016
  ctx.model.config, ctx.model.weightsBytes, 1,
1885
2017
  undefined, undefined, 0, serverOptions.memoryBudgetBytes,
1886
2018
  );
2019
+ const admission = ctx.glmMemoryPlan
2020
+ ? {
2021
+ ...genericAdmission,
2022
+ maxSafeContext: ctx.glmMemoryPlan.contextTokens,
2023
+ usableBytes: ctx.glmMemoryPlan.processLimitBytes,
2024
+ }
2025
+ : genericAdmission;
1887
2026
  if (admission.maxSafeContext < 1)
1888
2027
  throw new Error(
1889
2028
  `memory budget ${(admission.usableBytes / 1e9).toFixed(2)} GB cannot serve ` +
1890
2029
  `${ctx.modelId} (weights ${(ctx.model.weightsBytes / 1e9).toFixed(2)} GB): ` +
1891
2030
  `no context fits — raise the budget or pick a smaller model`,
1892
2031
  );
1893
- if (serverOptions.memoryBudgetBytes) setMemoryLimit(serverOptions.memoryBudgetBytes);
2032
+ if (ctx.glmMemoryPlan)
2033
+ setMemoryLimit(ctx.glmMemoryPlan.lineItems.allocatorReserveBytes);
2034
+ else if (serverOptions.memoryBudgetBytes)
2035
+ setMemoryLimit(serverOptions.memoryBudgetBytes);
1894
2036
 
1895
2037
  // /library response cache (30 s) — registry + config reads only.
1896
2038
  let libraryCache: { at: number; rows: unknown[] } | null = null;
@@ -1964,7 +2106,8 @@ export function createServer(
1964
2106
  // truncates the visible reply. Default very generously (the model's
1965
2107
  // context is far larger); only an explicit max_tokens narrows it. The
1966
2108
  // model still stops at its eos_token well before this in normal replies.
1967
- maxTokens: req.max_completion_tokens ?? req.max_tokens ?? serverOptions.defaultMaxTokens ?? 65_536,
2109
+ maxTokens: req.max_completion_tokens ?? req.max_tokens ??
2110
+ defaultGeneratedTokens ?? 65_536,
1968
2111
  temperature: req.temperature ?? serverOptions.defaultTemperature ?? defaultTemp,
1969
2112
  topP: req.top_p ?? serverOptions.defaultTopP ?? ctx.genDefaults.topP ?? 0,
1970
2113
  topK: req.top_k ?? serverOptions.defaultTopK ?? ctx.genDefaults.topK ?? 0,
@@ -2004,7 +2147,12 @@ export function createServer(
2004
2147
  const compileGrammarForRequest = async (
2005
2148
  req: ChatRequest,
2006
2149
  ): Promise<{ controller: import("./grammar").GrammarController | null; degradeHint: string | null }> => {
2007
- if (!grammarEnabled()) return { controller: null, degradeHint: null };
2150
+ if (!grammarEnabled()) {
2151
+ return {
2152
+ controller: null,
2153
+ degradeHint: "grammar compilation disabled by MLX_BUN_GRAMMAR=0",
2154
+ };
2155
+ }
2008
2156
  const r = await compileGrammarRequest(
2009
2157
  req as GrammarRequest,
2010
2158
  ctx.tokenizer,
@@ -2383,16 +2531,65 @@ export function createServer(
2383
2531
  const machine = thisMachine();
2384
2532
  const chip = detectChip();
2385
2533
  let expertsBytes = 0;
2534
+ let artifactDiskBytes: number | null = null;
2386
2535
  let measured: { decodeTps: number; ts: number } | null = null;
2387
2536
  try {
2388
2537
  const { Registry } = await import("./registry");
2389
2538
  const rec = new Registry().list().find((r) => r.repoId === ctx.modelId);
2390
2539
  if (rec) {
2391
2540
  expertsBytes = rec.expertsBytes;
2541
+ artifactDiskBytes = rec.sizeBytes;
2392
2542
  const { EvalDB } = await import("./evaldb");
2393
2543
  measured = new EvalDB().latestFor(rec.path);
2394
2544
  }
2395
2545
  } catch {}
2546
+ if (ctx.glmMemoryPlan) {
2547
+ const plan = ctx.glmMemoryPlan;
2548
+ const li = plan.lineItems;
2549
+ const kvBytes = li.targetKvBytes + li.mtpKvBytes;
2550
+ const transientBytes = plan.plannedProcessBytes -
2551
+ li.residentWeightsBytes - li.mainExpertSlabBytes -
2552
+ li.mtpExpertSlabBytes - kvBytes;
2553
+ return Response.json({
2554
+ machine: {
2555
+ chip: chip.name,
2556
+ ram_bytes: machine.ramBytes,
2557
+ bandwidth_gbs: machine.bandwidthGBs,
2558
+ },
2559
+ context_tokens: plan.contextTokens,
2560
+ typical_context_tokens: plan.contextTokens,
2561
+ typical_decode_tps: GLM52_G5_MEASURED_WARM_DECODE_TPS,
2562
+ measured_decode_tps: GLM52_G5_MEASURED_WARM_DECODE_TPS,
2563
+ measured_at: GLM52_G5_MEASURED_AT,
2564
+ report: {
2565
+ fits: true,
2566
+ weights_bytes: li.residentWeightsBytes,
2567
+ kv_bytes: kvBytes,
2568
+ transient_bytes: transientBytes,
2569
+ total_bytes: plan.plannedProcessBytes,
2570
+ usable_bytes: plan.processLimitBytes,
2571
+ max_safe_context: plan.contextTokens,
2572
+ predicted_decode_tps: null,
2573
+ },
2574
+ glm52: {
2575
+ artifact_disk_bytes: artifactDiskBytes,
2576
+ main_expert_slab_bytes: li.mainExpertSlabBytes,
2577
+ mtp_expert_slab_bytes: li.mtpExpertSlabBytes,
2578
+ max_generation_tokens: plan.maxGenerationTokens,
2579
+ direct_oracle_warm_decode_tps:
2580
+ GLM52_G5_DIRECT_ORACLE_WARM_DECODE_TPS,
2581
+ aspirational_decode_tps: GLM52_G5_ASPIRATIONAL_DECODE_TPS,
2582
+ },
2583
+ sku_matrix_ctx: plan.contextTokens,
2584
+ sku_matrix: [{
2585
+ sku: chip.name,
2586
+ ram_gb: Math.round(machine.ramBytes / 2 ** 30),
2587
+ fits: true,
2588
+ max_context: plan.contextTokens,
2589
+ decode_tps: GLM52_G5_MEASURED_WARM_DECODE_TPS,
2590
+ }],
2591
+ });
2592
+ }
2396
2593
  const report = fit(
2397
2594
  ctx.model.config, ctx.model.weightsBytes, admission.maxSafeContext,
2398
2595
  machine, undefined, expertsBytes, serverOptions.memoryBudgetBytes,
@@ -2513,14 +2710,44 @@ export function createServer(
2513
2710
  },
2514
2711
  admission: {
2515
2712
  max_safe_context: admission.maxSafeContext,
2516
- memory_budget_bytes: serverOptions.memoryBudgetBytes ?? null,
2713
+ memory_budget_bytes:
2714
+ ctx.glmMemoryPlan?.processLimitBytes ??
2715
+ serverOptions.memoryBudgetBytes ?? null,
2517
2716
  usable_bytes: admission.usableBytes,
2518
2717
  weights_bytes: ctx.model.weightsBytes,
2519
2718
  },
2719
+ ...(ctx.glmMemoryPlan ? {
2720
+ glm52: {
2721
+ preset: ctx.glmMemoryPlan.preset,
2722
+ planned_process_bytes: ctx.glmMemoryPlan.plannedProcessBytes,
2723
+ process_limit_bytes: ctx.glmMemoryPlan.processLimitBytes,
2724
+ context_tokens: ctx.glmMemoryPlan.contextTokens,
2725
+ max_generation_tokens: ctx.glmMemoryPlan.maxGenerationTokens,
2726
+ batch_size: ctx.glmMemoryPlan.batchSize,
2727
+ dsa: ctx.model instanceof Glm52Model && ctx.model.capabilities.dsa,
2728
+ mtp: ctx.draft?.provider.id === "glm52-native-mtp",
2729
+ mtp_draft_tokens: ctx.glmMemoryPlan.mtpDraftTokens,
2730
+ resident_weight_bytes: ctx.glmMemoryPlan.lineItems.residentWeightsBytes,
2731
+ main_expert_slab_bytes: ctx.glmMemoryPlan.lineItems.mainExpertSlabBytes,
2732
+ mtp_expert_slab_bytes: ctx.glmMemoryPlan.lineItems.mtpExpertSlabBytes,
2733
+ expert_runtime: ctx.model instanceof Glm52Model &&
2734
+ ctx.model.expertRuntime
2735
+ ? {
2736
+ main_residency:
2737
+ ctx.model.expertRuntime.manager.snapshot(),
2738
+ mtp_residency:
2739
+ ctx.model.expertRuntime.mtp?.manager.snapshot() ?? null,
2740
+ last_turn: ctx.model.expertRuntime.lastTelemetry,
2741
+ last_repin: ctx.model.expertRuntime.lastRepin,
2742
+ }
2743
+ : null,
2744
+ },
2745
+ } : {}),
2520
2746
  // --batch: configured cap, whether batching is live for this model,
2521
2747
  // and rows currently decoding in the batch.
2522
2748
  batch: {
2523
2749
  configured: batch,
2750
+ mode: gateway.batchMode,
2524
2751
  batched: gateway.batchingEnabled,
2525
2752
  active_rows: gateway.activeRows,
2526
2753
  pending_rows: gateway.pendingRows,
@@ -2578,7 +2805,9 @@ export function createServer(
2578
2805
  };
2579
2806
  const data: Array<Record<string, unknown>> = [{
2580
2807
  id: ctx.modelId, object: "model", created, owned_by: "mlx-bun",
2581
- context_window: ctx.model.config.text.maxPositionEmbeddings,
2808
+ context_window:
2809
+ ctx.glmMemoryPlan?.contextTokens ??
2810
+ ctx.model.config.text.maxPositionEmbeddings,
2582
2811
  // Capability flags for clients (CLI/external pi) that build a
2583
2812
  // provider from discovery — `reasoning` gates the thinking toggle,
2584
2813
  // `vision` the image input declaration, `audio` whether
@@ -2587,6 +2816,29 @@ export function createServer(
2587
2816
  reasoning: ctx.template.supportsThinking,
2588
2817
  vision: !!(ctx.vision || ctx.loadVision),
2589
2818
  audio: !!(ctx.audio || ctx.loadAudio),
2819
+ batch_mode: gateway.batchMode,
2820
+ tools: true,
2821
+ structured_output: true,
2822
+ embeddings: isEmbeddingModel(ctx.model),
2823
+ adapters: !(ctx.model instanceof Glm52Model),
2824
+ training: !(ctx.model instanceof Glm52Model),
2825
+ dsa: ctx.model instanceof Glm52Model && ctx.model.capabilities.dsa,
2826
+ mtp: ctx.draft?.provider.id === "glm52-native-mtp",
2827
+ capabilities: {
2828
+ chat_completions: true,
2829
+ text_completions: true,
2830
+ anthropic_messages: true,
2831
+ responses: true,
2832
+ streaming: true,
2833
+ tools: true,
2834
+ structured_output: true,
2835
+ logprobs: true,
2836
+ embeddings: isEmbeddingModel(ctx.model),
2837
+ vision: !!(ctx.vision || ctx.loadVision),
2838
+ audio: !!(ctx.audio || ctx.loadAudio),
2839
+ adapters: !(ctx.model instanceof Glm52Model),
2840
+ training: !(ctx.model instanceof Glm52Model),
2841
+ },
2590
2842
  gen_defaults: genDefaultsOut,
2591
2843
  }];
2592
2844
  try {
@@ -2812,7 +3064,7 @@ export function createServer(
2812
3064
  // translates its body into this shape and the Response back —
2813
3065
  // generation, tools, vision, stop sequences, prompt cache, and
2814
3066
  // admission control all live here exactly once.
2815
- const handleChat = async (body: ChatRequest): Promise<Response> => {
3067
+ const handleChat = async (body: ChatRequest, signal: AbortSignal): Promise<Response> => {
2816
3068
  if (!Array.isArray(body.messages) || body.messages.length === 0)
2817
3069
  return Response.json({ error: { message: "messages required" } }, { status: 400 });
2818
3070
  // mlx-lm validates logprobs params up front (ValueError → 400)
@@ -2859,14 +3111,9 @@ export function createServer(
2859
3111
  const g = await compileGrammarForRequest(body);
2860
3112
  grammarCtrl = g.controller;
2861
3113
  if (!g.controller && g.degradeHint) {
2862
- grammarWarning = `grammar not enforced: ${g.degradeHint} — falling back to prompt injection`;
2863
- body = {
2864
- ...body,
2865
- messages: [
2866
- { role: "system", content: degradeJsonSystemPrompt(body) },
2867
- ...body.messages,
2868
- ],
2869
- };
3114
+ const degraded = applyGrammarDegrade(body, g.degradeHint);
3115
+ grammarWarning = degraded.warning;
3116
+ body = degraded.body;
2870
3117
  }
2871
3118
  }
2872
3119
  // Free what a rejected request has already allocated (grammar WASM
@@ -3113,16 +3360,22 @@ export function createServer(
3113
3360
  recordLane(id, lane);
3114
3361
 
3115
3362
  if (body.stream) {
3363
+ const streamAbort = new AbortController();
3364
+ const generationSignal = AbortSignal.any([signal, streamAbort.signal]);
3365
+ let cancelled = false;
3116
3366
  const stream = new ReadableStream<Uint8Array>({
3117
- async start(controller) {
3367
+ start(controller) {
3118
3368
  const enc = new TextEncoder();
3119
- const send = (obj: unknown) =>
3120
- controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`));
3369
+ const send = (obj: unknown) => {
3370
+ if (!generationSignal.aborted)
3371
+ controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`));
3372
+ };
3121
3373
  const chunk = (delta: Record<string, unknown>, finish: string | null) => ({
3122
3374
  id, object: "chat.completion.chunk", created, model: ctx.modelId,
3123
3375
  choices: [{ index: 0, delta, finish_reason: finish }],
3124
3376
  });
3125
- try {
3377
+ void (async () => {
3378
+ try {
3126
3379
  // The gateway owns lane selection + GPU exclusivity; this body
3127
3380
  // runs per-request (concurrently in batched mode, each writing
3128
3381
  // its own SSE stream — the per-row fan-out).
@@ -3157,7 +3410,8 @@ export function createServer(
3157
3410
  return new Promise<void>((r) => setImmediate(r));
3158
3411
  }
3159
3412
  }
3160
- }, vision, shape);
3413
+ }, vision, shape, generationSignal);
3414
+ if (generationSignal.aborted) return;
3161
3415
  // a stop match discards everything from the match on,
3162
3416
  // including text still held by the decoders
3163
3417
  let tail = "";
@@ -3206,12 +3460,21 @@ export function createServer(
3206
3460
  });
3207
3461
  // bare sentinel per the OpenAI spec — JSON.stringify would
3208
3462
  // quote it and strict SDK clients never see the terminator
3209
- controller.enqueue(enc.encode("data: [DONE]\n\n"));
3210
- } catch (e) {
3211
- send({ error: { message: (e as Error).message } });
3212
- } finally {
3213
- controller.close();
3214
- }
3463
+ controller.enqueue(enc.encode("data: [DONE]\n\n"));
3464
+ } catch (e) {
3465
+ if (!generationSignal.aborted)
3466
+ send({ error: { message: (e as Error).message } });
3467
+ } finally {
3468
+ if (!cancelled) {
3469
+ if (generationSignal.aborted) controller.error(generationSignal.reason);
3470
+ else controller.close();
3471
+ }
3472
+ }
3473
+ })();
3474
+ },
3475
+ cancel(reason) {
3476
+ cancelled = true;
3477
+ streamAbort.abort(reason);
3215
3478
  },
3216
3479
  });
3217
3480
  return new Response(stream, {
@@ -3244,7 +3507,7 @@ export function createServer(
3244
3507
  content += parts.content;
3245
3508
  reasoning += parts.reasoning;
3246
3509
  if (stopper.stopped) return false; // halt generation
3247
- }, vision, shape);
3510
+ }, vision, shape, signal);
3248
3511
  if (!stopper.stopped) {
3249
3512
  const flushed = router.flush();
3250
3513
  reasoning += router.takeReasoning();
@@ -3304,7 +3567,7 @@ export function createServer(
3304
3567
  } catch {
3305
3568
  return Response.json({ error: { message: "invalid JSON body" } }, { status: 400 });
3306
3569
  }
3307
- return handleChat(body);
3570
+ return handleChat(body, request.signal);
3308
3571
  }
3309
3572
 
3310
3573
  // Raw text completion (mlx_lm.server's /v1/completions, request_type
@@ -3358,7 +3621,8 @@ export function createServer(
3358
3621
  const g = await compileGrammarForRequest(body as unknown as ChatRequest);
3359
3622
  if (g.controller) options.grammar = g.controller;
3360
3623
  else if (g.degradeHint)
3361
- textGrammarWarning = `grammar not enforced: ${g.degradeHint} — no prompt injection on /v1/completions`;
3624
+ textGrammarWarning =
3625
+ `grammar not enforced: ${g.degradeHint} - no prompt injection on /v1/completions`;
3362
3626
  }
3363
3627
  // Mirrors the chat lane: a real controller (not the degrade path)
3364
3628
  // shapes the request for per-row grammar batching.
@@ -3366,7 +3630,8 @@ export function createServer(
3366
3630
  // mlx_lm.server's default max_tokens is 512 (its --max-tokens CLI
3367
3631
  // default). The chat lane's very generous default is wrong for raw
3368
3632
  // completion: with no template an EOS may never come.
3369
- options.maxTokens = body.max_completion_tokens ?? body.max_tokens ?? serverOptions.defaultMaxTokens ?? 512;
3633
+ options.maxTokens = body.max_completion_tokens ?? body.max_tokens ??
3634
+ defaultGeneratedTokens ?? 512;
3370
3635
  const requiredCtx = promptIds.length + options.maxTokens;
3371
3636
  if (requiredCtx > admission.maxSafeContext) {
3372
3637
  // Pre-run reject: the gateway only takes grammar disposal ownership
@@ -3427,16 +3692,22 @@ export function createServer(
3427
3692
  stopped ? "stop" : generated >= (options.maxTokens ?? 512) ? "length" : "stop";
3428
3693
 
3429
3694
  if (body.stream) {
3695
+ const streamAbort = new AbortController();
3696
+ const generationSignal = AbortSignal.any([request.signal, streamAbort.signal]);
3697
+ let cancelled = false;
3430
3698
  const stream = new ReadableStream<Uint8Array>({
3431
- async start(controller) {
3699
+ start(controller) {
3432
3700
  const enc = new TextEncoder();
3433
- const send = (obj: unknown) =>
3434
- controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`));
3701
+ const send = (obj: unknown) => {
3702
+ if (!generationSignal.aborted)
3703
+ controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`));
3704
+ };
3435
3705
  const chunk = (text: string, finish: string | null) => ({
3436
3706
  id, object: "text_completion", created, model: ctx.modelId,
3437
3707
  choices: [{ index: 0, text, finish_reason: finish }],
3438
3708
  });
3439
- try {
3709
+ void (async () => {
3710
+ try {
3440
3711
  const decoder = new StreamDecoder(ctx.tokenizer);
3441
3712
  const stopper = new StopMatcher(options.stopSequences);
3442
3713
  // ≥25 ms macrotask hop, same reason as the chat lane: keep the
@@ -3453,7 +3724,8 @@ export function createServer(
3453
3724
  return new Promise<void>((r) => setImmediate(r));
3454
3725
  }
3455
3726
  }
3456
- }, undefined, shape);
3727
+ }, undefined, shape, generationSignal);
3728
+ if (generationSignal.aborted) return;
3457
3729
  if (!stopper.stopped) {
3458
3730
  let tail = stopper.push(decoder.flush());
3459
3731
  if (!stopper.stopped) tail += stopper.flush();
@@ -3472,12 +3744,21 @@ export function createServer(
3472
3744
  ...(s.spec ? { speculation: s.spec } : {}),
3473
3745
  },
3474
3746
  });
3475
- controller.enqueue(enc.encode("data: [DONE]\n\n"));
3476
- } catch (e) {
3477
- send({ error: { message: (e as Error).message } });
3478
- } finally {
3479
- controller.close();
3480
- }
3747
+ controller.enqueue(enc.encode("data: [DONE]\n\n"));
3748
+ } catch (e) {
3749
+ if (!generationSignal.aborted)
3750
+ send({ error: { message: (e as Error).message } });
3751
+ } finally {
3752
+ if (!cancelled) {
3753
+ if (generationSignal.aborted) controller.error(generationSignal.reason);
3754
+ else controller.close();
3755
+ }
3756
+ }
3757
+ })();
3758
+ },
3759
+ cancel(reason) {
3760
+ cancelled = true;
3761
+ streamAbort.abort(reason);
3481
3762
  },
3482
3763
  });
3483
3764
  return new Response(stream, {
@@ -3501,7 +3782,7 @@ export function createServer(
3501
3782
  lpc?.push(token, lpInfo);
3502
3783
  text += stopper.push(decoder.push(token));
3503
3784
  if (stopper.stopped) return false; // halt generation
3504
- }, undefined, shape);
3785
+ }, undefined, shape, request.signal);
3505
3786
  if (!stopper.stopped) {
3506
3787
  let tail = stopper.push(decoder.flush());
3507
3788
  if (!stopper.stopped) tail += stopper.flush();
@@ -3548,7 +3829,7 @@ export function createServer(
3548
3829
  } catch (e) {
3549
3830
  return anthropicError(400, "invalid_request_error", (e as Error).message);
3550
3831
  }
3551
- const resp = await handleChat(chatBody);
3832
+ const resp = await handleChat(chatBody, request.signal);
3552
3833
  if (!resp.ok) {
3553
3834
  const err = (await resp.json().catch(() => null)) as
3554
3835
  | { error?: { message?: string } }
@@ -3629,7 +3910,7 @@ export function createServer(
3629
3910
  } catch (e) {
3630
3911
  return responsesError(400, (e as Error).message);
3631
3912
  }
3632
- const resp = await handleChat(chatBody);
3913
+ const resp = await handleChat(chatBody, request.signal);
3633
3914
  if (!resp.ok) {
3634
3915
  const err = (await resp.json().catch(() => null)) as
3635
3916
  | { error?: { message?: string } }