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/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 {
@@ -254,7 +293,7 @@ export interface GenSamplingDefaults {
254
293
  repetitionPenalty?: number;
255
294
  }
256
295
 
257
- export type DraftKind = "dspark" | "deepspec" | "assistant" | "two-model" | "ngram";
296
+ export type DraftKind = "dspark" | "deepspec" | "assistant" | "two-model" | "ngram" | "mtp";
258
297
 
259
298
  /** Detect the draft artifact's kind so the right provider is loaded. All
260
299
  * providers share ONE serve loop (src/spec/serve-loop.ts). Exported for the
@@ -272,12 +311,22 @@ export async function detectDraftKind(dir: string): Promise<DraftKind> {
272
311
  // dspark.json, plain HF config stamped Gemma4DSparkModel.
273
312
  if (cfg.architectures?.[0] === "Gemma4DSparkModel") return "deepspec";
274
313
  if (String(cfg.model_type ?? "").includes("assistant")) return "assistant";
314
+ // Detect Qwen's companion head so loadContext can refuse it precisely.
315
+ // The target's 48 recurrent DeltaNet caches cannot yet roll back rejected
316
+ // verification rows, so treating this as a generic two-model draft would
317
+ // fail later and obscure the actual unsupported composition.
318
+ if (String(cfg.model_type ?? "").endsWith("_mtp")) return "mtp";
275
319
  } catch {
276
320
  // no/unreadable config → fall through to a full second model
277
321
  }
278
322
  return "two-model";
279
323
  }
280
324
 
325
+ export const QWEN_MTP_UNSUPPORTED_REASON =
326
+ "Qwen native MTP is not supported yet: its target has recurrent DeltaNet " +
327
+ "caches that cannot roll back rejected verification tokens. Serve Qwen3.8 " +
328
+ "without --draft-model; MTP remains a gated follow-up.";
329
+
281
330
  async function loadGenSamplingDefaults(modelDir: string): Promise<GenSamplingDefaults> {
282
331
  const file = Bun.file(`${modelDir}/generation_config.json`);
283
332
  if (!(await file.exists())) return {};
@@ -297,33 +346,47 @@ async function loadGenSamplingDefaults(modelDir: string): Promise<GenSamplingDef
297
346
 
298
347
  export async function loadContext(
299
348
  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
- } = {},
349
+ opts: LoadContextOptions = {},
318
350
  ): Promise<ServerContext> {
319
351
  const config = await loadModelConfig(modelDir);
320
- const weights = await Weights.open(modelDir);
352
+ const glm = isGlm52Config(config);
353
+ const externalDraft = opts.draftModelDir !== undefined || opts.draftKind !== undefined;
354
+ // Refuse the known-impossible composition before opening the 20 GB target
355
+ // or the companion head. The shared speculative loop requires target-cache
356
+ // rollback after a partial reject; Qwen's DeltaNet state is recurrent and
357
+ // deliberately non-trimmable. Silently degrading would make --draft-model
358
+ // a lie, while forcing it would corrupt the next target step.
359
+ const resolvedDraftKind = opts.draftModelDir
360
+ ? opts.draftKind ?? await detectDraftKind(opts.draftModelDir)
361
+ : opts.draftKind;
362
+ if (resolvedDraftKind === "mtp") throw new Error(QWEN_MTP_UNSUPPORTED_REASON);
363
+ if (glm && externalDraft && opts.glm?.enableMtp === true)
364
+ throw new Error("native GLM MTP and --draft-model/--draft-kind are mutually exclusive");
365
+ let weights: Weights | null = null;
366
+ let model!: RuntimeModel;
367
+ let glmMemoryPlan: Glm52MemoryPlan | null = null;
368
+ if (glm) {
369
+ const enableNativeMtp = !externalDraft && opts.glm?.enableMtp !== false;
370
+ const opened = await openGlm52RuntimeModel(modelDir, {
371
+ ...opts.glm,
372
+ memoryBudgetBytes: opts.memoryBudgetBytes ?? opts.glm?.memoryBudgetBytes,
373
+ // Every drafter owns the serial speculative lane. Do not reserve a
374
+ // simultaneous ordinary batch that cannot be admitted while it is live.
375
+ batchSize: enableNativeMtp || externalDraft ? 1 : opts.glm?.batchSize,
376
+ // An explicit alternate drafter owns the one speculative lane.
377
+ enableMtp: enableNativeMtp,
378
+ });
379
+ model = opened.model;
380
+ glmMemoryPlan = opened.plan;
381
+ } else {
382
+ weights = await Weights.open(modelDir);
383
+ }
321
384
  // memoryBudget enforcement at load (Phase 5): Weights.open only mmaps
322
385
  // (no GPU allocation yet), so a model whose weights can never serve
323
386
  // within the budget is refused HERE — before any unified-memory
324
387
  // commitment — with an actionable error instead of a Metal OOM later.
325
- if (opts.memoryBudgetBytes) {
326
- const weightsBytes = [...weights.shards.files.values()]
388
+ if (!glm && opts.memoryBudgetBytes) {
389
+ const weightsBytes = [...weights!.shards.files.values()]
327
390
  .reduce((a, f) => a + f.mmap.size, 0);
328
391
  const report = fit(config, weightsBytes, 1, undefined, undefined, 0, opts.memoryBudgetBytes);
329
392
  if (report.maxSafeContext < 1)
@@ -335,7 +398,7 @@ export async function loadContext(
335
398
  }
336
399
  // generated-specialization dispatch by config fingerprint (Phase C);
337
400
  // unmatched configs run the monolith — slow, never broken
338
- const model = createModel(weights, config);
401
+ if (!glm) model = createModel(weights!, config);
339
402
  const tokenizer = await loadTokenizer(modelDir);
340
403
  // Generation must stop on the tokenizer's eos_token — the chat turn
341
404
  // terminator (e.g. Qwen <|im_end|> = 248046). Some configs (Qwen3.5-4B)
@@ -368,7 +431,7 @@ export async function loadContext(
368
431
  };
369
432
  } else if (opts.draftModelDir) {
370
433
  const dir = opts.draftModelDir;
371
- const kind = opts.draftKind ?? (await detectDraftKind(dir));
434
+ const kind = resolvedDraftKind!;
372
435
  let provider: import("./spec/source").DraftProvider;
373
436
  let numDraftTokens = Math.max(1, opts.numDraftTokens ?? 3);
374
437
  if (kind === "dspark") {
@@ -433,7 +496,9 @@ export async function loadContext(
433
496
  }
434
497
  }
435
498
  if (opts.memoryBudgetBytes) {
436
- const targetBytes = [...weights.shards.files.values()].reduce((a, f) => a + f.mmap.size, 0);
499
+ const targetBytes = weights
500
+ ? [...weights.shards.files.values()].reduce((a, f) => a + f.mmap.size, 0)
501
+ : model.weightsBytes;
437
502
  // Draft weights shrink the target's envelope. Draft KV is not modeled
438
503
  // (small relative to its weights at serve contexts); admission stays
439
504
  // approximately conservative via the combined-weights term.
@@ -453,9 +518,20 @@ export async function loadContext(
453
518
  throw new Error(`--draft-kind ${opts.draftKind} requires --draft-model`);
454
519
  }
455
520
 
521
+ // GLM's checkpoint-native MTP row is the production default. It uses the
522
+ // already-planned bounded auxiliary expert tier and the same tokenizer, so
523
+ // there is no second artifact or compatibility probe to load.
524
+ if (!draft && model instanceof Glm52Model && glmMemoryPlan?.enableMtp) {
525
+ draft = {
526
+ provider: new Glm52NativeMtpProvider(model),
527
+ numDraftTokens: glmMemoryPlan.mtpDraftTokens,
528
+ };
529
+ }
530
+
456
531
  return {
457
532
  draft,
458
533
  model,
534
+ glmMemoryPlan,
459
535
  adapters: new AdapterManager(model),
460
536
  kvConfig: config.kvQuant,
461
537
  genDefaults: await loadGenSamplingDefaults(modelDir),
@@ -606,15 +682,21 @@ interface ChatRequest {
606
682
  tools?: ToolDefinition[];
607
683
  tool_choice?: "auto" | "none" | { type: string; function?: { name: string } };
608
684
  /** Forwarded to HF chat templates, matching optiq serve. MiniCPM5 uses
609
- * enable_thinking to select direct answers vs the <think> channel. */
685
+ * enable_thinking to select direct answers vs the <think> channel; Qwen3.8
686
+ * adds preserve_thinking (keep think blocks from history; template default
687
+ * true — better prompt-cache reuse in agent loops). */
610
688
  chat_template_kwargs?: {
611
689
  enable_thinking?: boolean;
690
+ preserve_thinking?: boolean;
612
691
  [key: string]: unknown;
613
692
  };
614
693
  /** OpenAI reasoning control. For models with a switchable <think> channel
615
694
  * (Qwen3.5/MiniCPM5) it gates enable_thinking: "none" → off, any level → on.
616
- * This is what Pi sends when the provider advertises reasoning. */
617
- reasoning_effort?: "none" | "minimal" | "low" | "medium" | "high";
695
+ * This is what Pi sends when the provider advertises reasoning. For
696
+ * templates that read a reasoning-depth variable (Qwen3.8: xhigh/medium/
697
+ * low), the level ALSO maps into the template via qwenReasoningEffort —
698
+ * "xhigh" accepted as a first-class value (Qwen3.8's default/deepest). */
699
+ reasoning_effort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
618
700
  /** Mounted LoRA adapter selection: "id", "a+b" (stacked), or "none". */
619
701
  adapter?: string;
620
702
  /** HLG tone-curve sampling override (per request). Snake_case wire fields,
@@ -651,6 +733,36 @@ type TextCompletionRequest = Omit<ChatRequest, "messages" | "tools" | "tool_choi
651
733
  prompt?: unknown;
652
734
  };
653
735
 
736
+ export interface ContextAdmissionDecision {
737
+ /** Effective completion ceiling after admission. */
738
+ maxTokens: number;
739
+ /** True when a fixed-context runtime reduced the client's upper bound. */
740
+ clamped: boolean;
741
+ }
742
+
743
+ /** Resolve a request's completion upper bound against the admitted context.
744
+ *
745
+ * Generic memory-budget serving preserves the established fail-fast contract:
746
+ * an oversized reservation is rejected. A fixed-context runtime such as GLM
747
+ * may instead clamp the client's upper bound to the remaining planned context.
748
+ * `max_tokens` is a ceiling, not a promise that every token will be emitted;
749
+ * clamping keeps clients that send a broad family-wide default usable without
750
+ * weakening the prompt/context OOM guard. A prompt that leaves no generation
751
+ * slot is always rejected. */
752
+ export function admitRequestContext(
753
+ promptTokens: number,
754
+ requestedMaxTokens: number,
755
+ maxSafeContext: number,
756
+ clampToFit: boolean,
757
+ ): ContextAdmissionDecision | null {
758
+ const available = maxSafeContext - promptTokens;
759
+ if (available < 1) return null;
760
+ if (requestedMaxTokens <= available)
761
+ return { maxTokens: requestedMaxTokens, clamped: false };
762
+ if (!clampToFit) return null;
763
+ return { maxTokens: available, clamped: true };
764
+ }
765
+
654
766
  /** Per-field default HLG knobs when enabling without specifying them. */
655
767
  const HLG_DEFAULTS = { width: 4, shoulder: 4, toe: 6, pivotOffset: 6 } as const;
656
768
 
@@ -1167,16 +1279,21 @@ export function nextDefaultSeed(): number {
1167
1279
  * compile fails — the response_format degrades to prompt injection rather
1168
1280
  * than a 500, oMLX parity). Returns null for {type:"text"} / unset. */
1169
1281
  export function degradeJsonSystemPrompt(body: ChatRequest): string | null {
1282
+ if (body.guided_grammar) {
1283
+ return "You must respond with text matching this grammar:\n\n" + body.guided_grammar;
1284
+ }
1170
1285
  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") {
1286
+ if (rf && typeof rf === "object" && rf.type === "json_object") {
1175
1287
  return "You must respond with valid JSON only. " +
1176
1288
  "Do not include any explanation or text outside the JSON object.";
1177
1289
  }
1178
- if (type === "json_schema") {
1179
- const spec = rf.json_schema ?? {};
1290
+ const schemaSpec = rf && typeof rf === "object" && rf.type === "json_schema"
1291
+ ? rf.json_schema
1292
+ : body.structured_outputs && typeof body.structured_outputs === "object"
1293
+ ? { schema: body.structured_outputs }
1294
+ : null;
1295
+ if (schemaSpec) {
1296
+ const spec = schemaSpec;
1180
1297
  const schema = spec.schema ?? body.structured_outputs ?? {};
1181
1298
  const name = spec.name ?? "response";
1182
1299
  const description = spec.description ?? "";
@@ -1186,9 +1303,33 @@ export function degradeJsonSystemPrompt(body: ChatRequest): string | null {
1186
1303
  "Respond with only the JSON object, no additional text or explanation.";
1187
1304
  return prompt;
1188
1305
  }
1306
+ if (body.guided_regex) {
1307
+ return "You must respond with text matching this regular expression:\n\n" +
1308
+ body.guided_regex;
1309
+ }
1310
+ if (body.guided_choice?.length) {
1311
+ return "You must respond with exactly one of these choices:\n\n" +
1312
+ body.guided_choice.map((choice) => JSON.stringify(choice)).join("\n");
1313
+ }
1189
1314
  return null;
1190
1315
  }
1191
1316
 
1317
+ export function applyGrammarDegrade(
1318
+ body: ChatRequest,
1319
+ degradeHint: string,
1320
+ ): { body: ChatRequest; warning: string } {
1321
+ const content = degradeJsonSystemPrompt(body) ??
1322
+ "Follow the requested output constraint exactly.";
1323
+ return {
1324
+ warning:
1325
+ `grammar not enforced: ${degradeHint} - falling back to prompt injection`,
1326
+ body: {
1327
+ ...body,
1328
+ messages: [{ role: "system", content }, ...body.messages],
1329
+ },
1330
+ };
1331
+ }
1332
+
1192
1333
  export function validateLogprobsParams(body: {
1193
1334
  logprobs?: unknown;
1194
1335
  top_logprobs?: unknown;
@@ -1293,9 +1434,14 @@ class LogprobsCollector {
1293
1434
  *
1294
1435
  * Exported for unit tests (serve-detok mlx-lm byte parity). */
1295
1436
  export class StreamDecoder {
1437
+ static readonly #WINDOW_TOKENS = 32;
1296
1438
  #ids: number[] = [];
1297
1439
  #emitted = "";
1298
1440
  #warnedRevision = false;
1441
+ /** Token index where the bounded decode suffix begins. */
1442
+ #windowStart = 0;
1443
+ /** Exact decoded text before #windowStart, established by suffix matching. */
1444
+ #windowPrefix = "";
1299
1445
  readonly #trimLeadingSpace: boolean;
1300
1446
  readonly #bareSpaceId: number | undefined;
1301
1447
 
@@ -1308,17 +1454,50 @@ export class StreamDecoder {
1308
1454
  }
1309
1455
 
1310
1456
  #decode(ids: number[]): string {
1311
- const full = this.tokenizer.decode(ids, this.skipSpecialTokens);
1457
+ const full = this.#decodeRaw(ids);
1312
1458
  return this.#trimLeadingSpace && full.startsWith(" ") ? full.slice(1) : full;
1313
1459
  }
1314
1460
 
1461
+ #decodeRaw(ids: number[]): string {
1462
+ return this.tokenizer.decode(ids, this.skipSpecialTokens);
1463
+ }
1464
+
1465
+ /** Decode only a bounded suffix once an exact text anchor can be proven.
1466
+ *
1467
+ * At each rebase, decode a candidate suffix independently and accept it only
1468
+ * when it is literally the suffix of the exact text decoded so far. This
1469
+ * preserves ByteLevel/SPM boundary behavior while reducing steady-state
1470
+ * decode work from the whole generation to 32–64 tokens. If no suffix can be
1471
+ * anchored (an unusual/global decoder), leave #windowStart at zero and keep
1472
+ * the full-history correctness path. */
1473
+ #decodeIncremental(): string {
1474
+ const full = this.#windowStart === 0
1475
+ ? this.#decode(this.#ids)
1476
+ : this.#windowPrefix + this.#decodeRaw(this.#ids.slice(this.#windowStart));
1477
+
1478
+ if (this.#ids.length - this.#windowStart < StreamDecoder.#WINDOW_TOKENS * 2)
1479
+ return full;
1480
+
1481
+ const preferred = this.#ids.length - StreamDecoder.#WINDOW_TOKENS;
1482
+ const oldest = Math.max(this.#windowStart, preferred - StreamDecoder.#WINDOW_TOKENS);
1483
+ for (let start = preferred; start >= oldest; start--) {
1484
+ const tail = this.#decodeRaw(this.#ids.slice(start));
1485
+ if ((tail.length > 0 || full.length === 0) && full.endsWith(tail)) {
1486
+ this.#windowStart = start;
1487
+ this.#windowPrefix = full.slice(0, full.length - tail.length);
1488
+ break;
1489
+ }
1490
+ }
1491
+ return full;
1492
+ }
1493
+
1315
1494
  push(token: number): string {
1316
1495
  this.#ids.push(token);
1317
1496
  // Bare-space hold-back (mlx-lm add_token keeps "Ġ" in _unflushed): don't
1318
1497
  // advance #emitted; the held space(s) flush as part of the next
1319
1498
  // non-bare-space token's delta — consecutive bare spaces accumulate.
1320
1499
  if (token === this.#bareSpaceId) return "";
1321
- const full = this.#decode(this.#ids);
1500
+ const full = this.#decodeIncremental();
1322
1501
  // hold back a trailing replacement char (partial multi-byte sequence)
1323
1502
  const stable = full.endsWith("�") ? full.slice(0, -1) : full;
1324
1503
  if (!stable.startsWith(this.#emitted)) {
@@ -1396,6 +1575,8 @@ export function createServer(
1396
1575
  // agentic sub-agent workload. --batch 1 pins strict serial for
1397
1576
  // arrival-independent numerics. 8 = optiq's Mac-safe concurrency.
1398
1577
  const batch = Math.max(1, Math.floor(serverOptions.batch ?? 8));
1578
+ const defaultGeneratedTokens =
1579
+ serverOptions.defaultMaxTokens ?? ctx.glmMemoryPlan?.maxGenerationTokens;
1399
1580
 
1400
1581
  // KV-quant scheme, resolved once. UNSET now means bf16 (flipped
1401
1582
  // 2026-07-05 with the naked-=-L1 default): quantized KV measured 5–20%
@@ -1634,9 +1815,15 @@ export function createServer(
1634
1815
  !options.pagedKv
1635
1816
  ) {
1636
1817
  const { specServeRun } = await import("./spec/serve-loop");
1637
- return specServeRun(
1638
- ctx.model, ctx.draft.provider, ctx.draft.numDraftTokens,
1639
- promptIds, options, onToken,
1818
+ return withModelWiredLimit(
1819
+ ctx.model,
1820
+ () => withModelUsageFlush(
1821
+ ctx.model,
1822
+ () => specServeRun(
1823
+ ctx.model, ctx.draft!.provider, ctx.draft!.numDraftTokens,
1824
+ promptIds, options, onToken,
1825
+ ),
1826
+ ),
1640
1827
  );
1641
1828
  }
1642
1829
  // Cache entries are adapter-specific: KV computed under one adapter
@@ -1880,17 +2067,27 @@ export function createServer(
1880
2067
  // fit() solves max safe context from weights + KV growth + prefill
1881
2068
  // transient; the KV term assumes bf16 (a kv-quant scheme stretches the
1882
2069
  // real ceiling, never shrinks it — admission stays conservative).
1883
- const admission = fit(
2070
+ const genericAdmission = fit(
1884
2071
  ctx.model.config, ctx.model.weightsBytes, 1,
1885
2072
  undefined, undefined, 0, serverOptions.memoryBudgetBytes,
1886
2073
  );
2074
+ const admission = ctx.glmMemoryPlan
2075
+ ? {
2076
+ ...genericAdmission,
2077
+ maxSafeContext: ctx.glmMemoryPlan.contextTokens,
2078
+ usableBytes: ctx.glmMemoryPlan.processLimitBytes,
2079
+ }
2080
+ : genericAdmission;
1887
2081
  if (admission.maxSafeContext < 1)
1888
2082
  throw new Error(
1889
2083
  `memory budget ${(admission.usableBytes / 1e9).toFixed(2)} GB cannot serve ` +
1890
2084
  `${ctx.modelId} (weights ${(ctx.model.weightsBytes / 1e9).toFixed(2)} GB): ` +
1891
2085
  `no context fits — raise the budget or pick a smaller model`,
1892
2086
  );
1893
- if (serverOptions.memoryBudgetBytes) setMemoryLimit(serverOptions.memoryBudgetBytes);
2087
+ if (ctx.glmMemoryPlan)
2088
+ setMemoryLimit(ctx.glmMemoryPlan.lineItems.allocatorReserveBytes);
2089
+ else if (serverOptions.memoryBudgetBytes)
2090
+ setMemoryLimit(serverOptions.memoryBudgetBytes);
1894
2091
 
1895
2092
  // /library response cache (30 s) — registry + config reads only.
1896
2093
  let libraryCache: { at: number; rows: unknown[] } | null = null;
@@ -1964,7 +2161,8 @@ export function createServer(
1964
2161
  // truncates the visible reply. Default very generously (the model's
1965
2162
  // context is far larger); only an explicit max_tokens narrows it. The
1966
2163
  // 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,
2164
+ maxTokens: req.max_completion_tokens ?? req.max_tokens ??
2165
+ defaultGeneratedTokens ?? 65_536,
1968
2166
  temperature: req.temperature ?? serverOptions.defaultTemperature ?? defaultTemp,
1969
2167
  topP: req.top_p ?? serverOptions.defaultTopP ?? ctx.genDefaults.topP ?? 0,
1970
2168
  topK: req.top_k ?? serverOptions.defaultTopK ?? ctx.genDefaults.topK ?? 0,
@@ -2004,7 +2202,12 @@ export function createServer(
2004
2202
  const compileGrammarForRequest = async (
2005
2203
  req: ChatRequest,
2006
2204
  ): Promise<{ controller: import("./grammar").GrammarController | null; degradeHint: string | null }> => {
2007
- if (!grammarEnabled()) return { controller: null, degradeHint: null };
2205
+ if (!grammarEnabled()) {
2206
+ return {
2207
+ controller: null,
2208
+ degradeHint: "grammar compilation disabled by MLX_BUN_GRAMMAR=0",
2209
+ };
2210
+ }
2008
2211
  const r = await compileGrammarRequest(
2009
2212
  req as GrammarRequest,
2010
2213
  ctx.tokenizer,
@@ -2014,10 +2217,36 @@ export function createServer(
2014
2217
  return { controller: r.controller, degradeHint: r.degradeHint };
2015
2218
  };
2016
2219
 
2220
+ // Map OpenAI reasoning_effort levels onto the Qwen3.8 template's supported
2221
+ // set (xhigh|medium|low — the template raises on anything else). "none"
2222
+ // means thinking off (handled by resolveEnableThinking), so no depth is
2223
+ // passed. Only consumed for templates with readsReasoningEffort.
2224
+ const qwenReasoningEffort = (
2225
+ effort: ChatRequest["reasoning_effort"],
2226
+ ): "xhigh" | "medium" | "low" | undefined => {
2227
+ switch (effort) {
2228
+ case "minimal":
2229
+ case "low":
2230
+ return "low";
2231
+ case "medium":
2232
+ return "medium";
2233
+ case "high":
2234
+ case "xhigh":
2235
+ return "xhigh";
2236
+ default:
2237
+ return undefined; // "none" / unset → template default (xhigh)
2238
+ }
2239
+ };
2240
+
2017
2241
  const templateOptionsFor = (req: ChatRequest, tools: ToolDefinition[] | null) => {
2018
2242
  // enableThinking resolution (and its precedence) lives in
2019
2243
  // resolveEnableThinking so template rendering and sampling stay in sync.
2020
- return { tools, enableThinking: resolveEnableThinking(req) };
2244
+ return {
2245
+ tools,
2246
+ enableThinking: resolveEnableThinking(req),
2247
+ reasoningEffort: qwenReasoningEffort(req.reasoning_effort),
2248
+ preserveThinking: req.chat_template_kwargs?.preserve_thinking,
2249
+ };
2021
2250
  };
2022
2251
 
2023
2252
  const promptIdsFor = (
@@ -2052,7 +2281,7 @@ export function createServer(
2052
2281
  trimmed: number[],
2053
2282
  ): number => {
2054
2283
  const opts = templateOptionsFor(req, tools);
2055
- const mode = `${opts.enableThinking}|${!!tools?.length}`;
2284
+ const mode = `${opts.enableThinking}|${!!tools?.length}|${opts.reasoningEffort ?? ""}|${opts.preserveThinking ?? ""}`;
2056
2285
  const primer = primerLenByMode.get(mode);
2057
2286
  if (primer !== undefined) return Math.max(0, trimmed.length - primer);
2058
2287
  let stableLen = trimmed.length;
@@ -2383,16 +2612,65 @@ export function createServer(
2383
2612
  const machine = thisMachine();
2384
2613
  const chip = detectChip();
2385
2614
  let expertsBytes = 0;
2615
+ let artifactDiskBytes: number | null = null;
2386
2616
  let measured: { decodeTps: number; ts: number } | null = null;
2387
2617
  try {
2388
2618
  const { Registry } = await import("./registry");
2389
2619
  const rec = new Registry().list().find((r) => r.repoId === ctx.modelId);
2390
2620
  if (rec) {
2391
2621
  expertsBytes = rec.expertsBytes;
2622
+ artifactDiskBytes = rec.sizeBytes;
2392
2623
  const { EvalDB } = await import("./evaldb");
2393
2624
  measured = new EvalDB().latestFor(rec.path);
2394
2625
  }
2395
2626
  } catch {}
2627
+ if (ctx.glmMemoryPlan) {
2628
+ const plan = ctx.glmMemoryPlan;
2629
+ const li = plan.lineItems;
2630
+ const kvBytes = li.targetKvBytes + li.mtpKvBytes;
2631
+ const transientBytes = plan.plannedProcessBytes -
2632
+ li.residentWeightsBytes - li.mainExpertSlabBytes -
2633
+ li.mtpExpertSlabBytes - kvBytes;
2634
+ return Response.json({
2635
+ machine: {
2636
+ chip: chip.name,
2637
+ ram_bytes: machine.ramBytes,
2638
+ bandwidth_gbs: machine.bandwidthGBs,
2639
+ },
2640
+ context_tokens: plan.contextTokens,
2641
+ typical_context_tokens: plan.contextTokens,
2642
+ typical_decode_tps: GLM52_G5_MEASURED_WARM_DECODE_TPS,
2643
+ measured_decode_tps: GLM52_G5_MEASURED_WARM_DECODE_TPS,
2644
+ measured_at: GLM52_G5_MEASURED_AT,
2645
+ report: {
2646
+ fits: true,
2647
+ weights_bytes: li.residentWeightsBytes,
2648
+ kv_bytes: kvBytes,
2649
+ transient_bytes: transientBytes,
2650
+ total_bytes: plan.plannedProcessBytes,
2651
+ usable_bytes: plan.processLimitBytes,
2652
+ max_safe_context: plan.contextTokens,
2653
+ predicted_decode_tps: null,
2654
+ },
2655
+ glm52: {
2656
+ artifact_disk_bytes: artifactDiskBytes,
2657
+ main_expert_slab_bytes: li.mainExpertSlabBytes,
2658
+ mtp_expert_slab_bytes: li.mtpExpertSlabBytes,
2659
+ max_generation_tokens: plan.maxGenerationTokens,
2660
+ direct_oracle_warm_decode_tps:
2661
+ GLM52_G5_DIRECT_ORACLE_WARM_DECODE_TPS,
2662
+ aspirational_decode_tps: GLM52_G5_ASPIRATIONAL_DECODE_TPS,
2663
+ },
2664
+ sku_matrix_ctx: plan.contextTokens,
2665
+ sku_matrix: [{
2666
+ sku: chip.name,
2667
+ ram_gb: Math.round(machine.ramBytes / 2 ** 30),
2668
+ fits: true,
2669
+ max_context: plan.contextTokens,
2670
+ decode_tps: GLM52_G5_MEASURED_WARM_DECODE_TPS,
2671
+ }],
2672
+ });
2673
+ }
2396
2674
  const report = fit(
2397
2675
  ctx.model.config, ctx.model.weightsBytes, admission.maxSafeContext,
2398
2676
  machine, undefined, expertsBytes, serverOptions.memoryBudgetBytes,
@@ -2513,14 +2791,44 @@ export function createServer(
2513
2791
  },
2514
2792
  admission: {
2515
2793
  max_safe_context: admission.maxSafeContext,
2516
- memory_budget_bytes: serverOptions.memoryBudgetBytes ?? null,
2794
+ memory_budget_bytes:
2795
+ ctx.glmMemoryPlan?.processLimitBytes ??
2796
+ serverOptions.memoryBudgetBytes ?? null,
2517
2797
  usable_bytes: admission.usableBytes,
2518
2798
  weights_bytes: ctx.model.weightsBytes,
2519
2799
  },
2800
+ ...(ctx.glmMemoryPlan ? {
2801
+ glm52: {
2802
+ preset: ctx.glmMemoryPlan.preset,
2803
+ planned_process_bytes: ctx.glmMemoryPlan.plannedProcessBytes,
2804
+ process_limit_bytes: ctx.glmMemoryPlan.processLimitBytes,
2805
+ context_tokens: ctx.glmMemoryPlan.contextTokens,
2806
+ max_generation_tokens: ctx.glmMemoryPlan.maxGenerationTokens,
2807
+ batch_size: ctx.glmMemoryPlan.batchSize,
2808
+ dsa: ctx.model instanceof Glm52Model && ctx.model.capabilities.dsa,
2809
+ mtp: ctx.draft?.provider.id === "glm52-native-mtp",
2810
+ mtp_draft_tokens: ctx.glmMemoryPlan.mtpDraftTokens,
2811
+ resident_weight_bytes: ctx.glmMemoryPlan.lineItems.residentWeightsBytes,
2812
+ main_expert_slab_bytes: ctx.glmMemoryPlan.lineItems.mainExpertSlabBytes,
2813
+ mtp_expert_slab_bytes: ctx.glmMemoryPlan.lineItems.mtpExpertSlabBytes,
2814
+ expert_runtime: ctx.model instanceof Glm52Model &&
2815
+ ctx.model.expertRuntime
2816
+ ? {
2817
+ main_residency:
2818
+ ctx.model.expertRuntime.manager.snapshot(),
2819
+ mtp_residency:
2820
+ ctx.model.expertRuntime.mtp?.manager.snapshot() ?? null,
2821
+ last_turn: ctx.model.expertRuntime.lastTelemetry,
2822
+ last_repin: ctx.model.expertRuntime.lastRepin,
2823
+ }
2824
+ : null,
2825
+ },
2826
+ } : {}),
2520
2827
  // --batch: configured cap, whether batching is live for this model,
2521
2828
  // and rows currently decoding in the batch.
2522
2829
  batch: {
2523
2830
  configured: batch,
2831
+ mode: gateway.batchMode,
2524
2832
  batched: gateway.batchingEnabled,
2525
2833
  active_rows: gateway.activeRows,
2526
2834
  pending_rows: gateway.pendingRows,
@@ -2578,7 +2886,9 @@ export function createServer(
2578
2886
  };
2579
2887
  const data: Array<Record<string, unknown>> = [{
2580
2888
  id: ctx.modelId, object: "model", created, owned_by: "mlx-bun",
2581
- context_window: ctx.model.config.text.maxPositionEmbeddings,
2889
+ context_window:
2890
+ ctx.glmMemoryPlan?.contextTokens ??
2891
+ ctx.model.config.text.maxPositionEmbeddings,
2582
2892
  // Capability flags for clients (CLI/external pi) that build a
2583
2893
  // provider from discovery — `reasoning` gates the thinking toggle,
2584
2894
  // `vision` the image input declaration, `audio` whether
@@ -2587,6 +2897,29 @@ export function createServer(
2587
2897
  reasoning: ctx.template.supportsThinking,
2588
2898
  vision: !!(ctx.vision || ctx.loadVision),
2589
2899
  audio: !!(ctx.audio || ctx.loadAudio),
2900
+ batch_mode: gateway.batchMode,
2901
+ tools: true,
2902
+ structured_output: true,
2903
+ embeddings: isEmbeddingModel(ctx.model),
2904
+ adapters: !(ctx.model instanceof Glm52Model),
2905
+ training: !(ctx.model instanceof Glm52Model),
2906
+ dsa: ctx.model instanceof Glm52Model && ctx.model.capabilities.dsa,
2907
+ mtp: ctx.draft?.provider.id === "glm52-native-mtp",
2908
+ capabilities: {
2909
+ chat_completions: true,
2910
+ text_completions: true,
2911
+ anthropic_messages: true,
2912
+ responses: true,
2913
+ streaming: true,
2914
+ tools: true,
2915
+ structured_output: true,
2916
+ logprobs: true,
2917
+ embeddings: isEmbeddingModel(ctx.model),
2918
+ vision: !!(ctx.vision || ctx.loadVision),
2919
+ audio: !!(ctx.audio || ctx.loadAudio),
2920
+ adapters: !(ctx.model instanceof Glm52Model),
2921
+ training: !(ctx.model instanceof Glm52Model),
2922
+ },
2590
2923
  gen_defaults: genDefaultsOut,
2591
2924
  }];
2592
2925
  try {
@@ -2812,7 +3145,7 @@ export function createServer(
2812
3145
  // translates its body into this shape and the Response back —
2813
3146
  // generation, tools, vision, stop sequences, prompt cache, and
2814
3147
  // admission control all live here exactly once.
2815
- const handleChat = async (body: ChatRequest): Promise<Response> => {
3148
+ const handleChat = async (body: ChatRequest, signal: AbortSignal): Promise<Response> => {
2816
3149
  if (!Array.isArray(body.messages) || body.messages.length === 0)
2817
3150
  return Response.json({ error: { message: "messages required" } }, { status: 400 });
2818
3151
  // mlx-lm validates logprobs params up front (ValueError → 400)
@@ -2859,14 +3192,9 @@ export function createServer(
2859
3192
  const g = await compileGrammarForRequest(body);
2860
3193
  grammarCtrl = g.controller;
2861
3194
  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
- };
3195
+ const degraded = applyGrammarDegrade(body, g.degradeHint);
3196
+ grammarWarning = degraded.warning;
3197
+ body = degraded.body;
2870
3198
  }
2871
3199
  }
2872
3200
  // Free what a rejected request has already allocated (grammar WASM
@@ -3030,15 +3358,22 @@ export function createServer(
3030
3358
  // Admission: reject what cannot finish within the memory budget
3031
3359
  // (the GPU OOM it would otherwise hit is uncatchable and kills
3032
3360
  // the process — Phase 6 finding).
3033
- const requiredCtx = promptIds.length + (options.maxTokens ?? 1024);
3034
- if (requiredCtx > admission.maxSafeContext) {
3361
+ const requestedMaxTokens = options.maxTokens ?? 1024;
3362
+ const requiredCtx = promptIds.length + requestedMaxTokens;
3363
+ const contextDecision = admitRequestContext(
3364
+ promptIds.length,
3365
+ requestedMaxTokens,
3366
+ admission.maxSafeContext,
3367
+ ctx.glmMemoryPlan !== null,
3368
+ );
3369
+ if (!contextDecision) {
3035
3370
  disposeRejected();
3036
3371
  return Response.json(
3037
3372
  {
3038
3373
  error: {
3039
3374
  message:
3040
3375
  `request needs ${requiredCtx} tokens of context ` +
3041
- `(prompt ${promptIds.length} + max_tokens ${options.maxTokens}) but the ` +
3376
+ `(prompt ${promptIds.length} + max_tokens ${requestedMaxTokens}) but the ` +
3042
3377
  `memory budget caps safe context at ${admission.maxSafeContext} — ` +
3043
3378
  `shorten the prompt or lower max_tokens`,
3044
3379
  type: "memory_admission",
@@ -3048,6 +3383,7 @@ export function createServer(
3048
3383
  { status: 400 },
3049
3384
  );
3050
3385
  }
3386
+ options.maxTokens = contextDecision.maxTokens;
3051
3387
  try {
3052
3388
  // A request's explicit `adapter` (incl. "none") wins over the
3053
3389
  // startup default from `serve --adapter <dir>`.
@@ -3113,16 +3449,22 @@ export function createServer(
3113
3449
  recordLane(id, lane);
3114
3450
 
3115
3451
  if (body.stream) {
3452
+ const streamAbort = new AbortController();
3453
+ const generationSignal = AbortSignal.any([signal, streamAbort.signal]);
3454
+ let cancelled = false;
3116
3455
  const stream = new ReadableStream<Uint8Array>({
3117
- async start(controller) {
3456
+ start(controller) {
3118
3457
  const enc = new TextEncoder();
3119
- const send = (obj: unknown) =>
3120
- controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`));
3458
+ const send = (obj: unknown) => {
3459
+ if (!generationSignal.aborted)
3460
+ controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`));
3461
+ };
3121
3462
  const chunk = (delta: Record<string, unknown>, finish: string | null) => ({
3122
3463
  id, object: "chat.completion.chunk", created, model: ctx.modelId,
3123
3464
  choices: [{ index: 0, delta, finish_reason: finish }],
3124
3465
  });
3125
- try {
3466
+ void (async () => {
3467
+ try {
3126
3468
  // The gateway owns lane selection + GPU exclusivity; this body
3127
3469
  // runs per-request (concurrently in batched mode, each writing
3128
3470
  // its own SSE stream — the per-row fan-out).
@@ -3157,7 +3499,8 @@ export function createServer(
3157
3499
  return new Promise<void>((r) => setImmediate(r));
3158
3500
  }
3159
3501
  }
3160
- }, vision, shape);
3502
+ }, vision, shape, generationSignal);
3503
+ if (generationSignal.aborted) return;
3161
3504
  // a stop match discards everything from the match on,
3162
3505
  // including text still held by the decoders
3163
3506
  let tail = "";
@@ -3206,12 +3549,21 @@ export function createServer(
3206
3549
  });
3207
3550
  // bare sentinel per the OpenAI spec — JSON.stringify would
3208
3551
  // 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
- }
3552
+ controller.enqueue(enc.encode("data: [DONE]\n\n"));
3553
+ } catch (e) {
3554
+ if (!generationSignal.aborted)
3555
+ send({ error: { message: (e as Error).message } });
3556
+ } finally {
3557
+ if (!cancelled) {
3558
+ if (generationSignal.aborted) controller.error(generationSignal.reason);
3559
+ else controller.close();
3560
+ }
3561
+ }
3562
+ })();
3563
+ },
3564
+ cancel(reason) {
3565
+ cancelled = true;
3566
+ streamAbort.abort(reason);
3215
3567
  },
3216
3568
  });
3217
3569
  return new Response(stream, {
@@ -3244,7 +3596,7 @@ export function createServer(
3244
3596
  content += parts.content;
3245
3597
  reasoning += parts.reasoning;
3246
3598
  if (stopper.stopped) return false; // halt generation
3247
- }, vision, shape);
3599
+ }, vision, shape, signal);
3248
3600
  if (!stopper.stopped) {
3249
3601
  const flushed = router.flush();
3250
3602
  reasoning += router.takeReasoning();
@@ -3304,7 +3656,7 @@ export function createServer(
3304
3656
  } catch {
3305
3657
  return Response.json({ error: { message: "invalid JSON body" } }, { status: 400 });
3306
3658
  }
3307
- return handleChat(body);
3659
+ return handleChat(body, request.signal);
3308
3660
  }
3309
3661
 
3310
3662
  // Raw text completion (mlx_lm.server's /v1/completions, request_type
@@ -3358,7 +3710,8 @@ export function createServer(
3358
3710
  const g = await compileGrammarForRequest(body as unknown as ChatRequest);
3359
3711
  if (g.controller) options.grammar = g.controller;
3360
3712
  else if (g.degradeHint)
3361
- textGrammarWarning = `grammar not enforced: ${g.degradeHint} — no prompt injection on /v1/completions`;
3713
+ textGrammarWarning =
3714
+ `grammar not enforced: ${g.degradeHint} - no prompt injection on /v1/completions`;
3362
3715
  }
3363
3716
  // Mirrors the chat lane: a real controller (not the degrade path)
3364
3717
  // shapes the request for per-row grammar batching.
@@ -3366,9 +3719,17 @@ export function createServer(
3366
3719
  // mlx_lm.server's default max_tokens is 512 (its --max-tokens CLI
3367
3720
  // default). The chat lane's very generous default is wrong for raw
3368
3721
  // completion: with no template an EOS may never come.
3369
- options.maxTokens = body.max_completion_tokens ?? body.max_tokens ?? serverOptions.defaultMaxTokens ?? 512;
3370
- const requiredCtx = promptIds.length + options.maxTokens;
3371
- if (requiredCtx > admission.maxSafeContext) {
3722
+ options.maxTokens = body.max_completion_tokens ?? body.max_tokens ??
3723
+ defaultGeneratedTokens ?? 512;
3724
+ const requestedMaxTokens = options.maxTokens;
3725
+ const requiredCtx = promptIds.length + requestedMaxTokens;
3726
+ const contextDecision = admitRequestContext(
3727
+ promptIds.length,
3728
+ requestedMaxTokens,
3729
+ admission.maxSafeContext,
3730
+ ctx.glmMemoryPlan !== null,
3731
+ );
3732
+ if (!contextDecision) {
3372
3733
  // Pre-run reject: the gateway only takes grammar disposal ownership
3373
3734
  // once run — free the WASM matcher here (chat lane: disposeRejected).
3374
3735
  textGrammarCtrl?.dispose();
@@ -3387,6 +3748,7 @@ export function createServer(
3387
3748
  { status: 400 },
3388
3749
  );
3389
3750
  }
3751
+ options.maxTokens = contextDecision.maxTokens;
3390
3752
  try {
3391
3753
  const adapterIds = ctx.adapters.resolveSpec(body.adapter ?? serverOptions.defaultAdapter);
3392
3754
  if (adapterIds.length) options.adapters = adapterIds;
@@ -3427,16 +3789,22 @@ export function createServer(
3427
3789
  stopped ? "stop" : generated >= (options.maxTokens ?? 512) ? "length" : "stop";
3428
3790
 
3429
3791
  if (body.stream) {
3792
+ const streamAbort = new AbortController();
3793
+ const generationSignal = AbortSignal.any([request.signal, streamAbort.signal]);
3794
+ let cancelled = false;
3430
3795
  const stream = new ReadableStream<Uint8Array>({
3431
- async start(controller) {
3796
+ start(controller) {
3432
3797
  const enc = new TextEncoder();
3433
- const send = (obj: unknown) =>
3434
- controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`));
3798
+ const send = (obj: unknown) => {
3799
+ if (!generationSignal.aborted)
3800
+ controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`));
3801
+ };
3435
3802
  const chunk = (text: string, finish: string | null) => ({
3436
3803
  id, object: "text_completion", created, model: ctx.modelId,
3437
3804
  choices: [{ index: 0, text, finish_reason: finish }],
3438
3805
  });
3439
- try {
3806
+ void (async () => {
3807
+ try {
3440
3808
  const decoder = new StreamDecoder(ctx.tokenizer);
3441
3809
  const stopper = new StopMatcher(options.stopSequences);
3442
3810
  // ≥25 ms macrotask hop, same reason as the chat lane: keep the
@@ -3453,7 +3821,8 @@ export function createServer(
3453
3821
  return new Promise<void>((r) => setImmediate(r));
3454
3822
  }
3455
3823
  }
3456
- }, undefined, shape);
3824
+ }, undefined, shape, generationSignal);
3825
+ if (generationSignal.aborted) return;
3457
3826
  if (!stopper.stopped) {
3458
3827
  let tail = stopper.push(decoder.flush());
3459
3828
  if (!stopper.stopped) tail += stopper.flush();
@@ -3472,12 +3841,21 @@ export function createServer(
3472
3841
  ...(s.spec ? { speculation: s.spec } : {}),
3473
3842
  },
3474
3843
  });
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
- }
3844
+ controller.enqueue(enc.encode("data: [DONE]\n\n"));
3845
+ } catch (e) {
3846
+ if (!generationSignal.aborted)
3847
+ send({ error: { message: (e as Error).message } });
3848
+ } finally {
3849
+ if (!cancelled) {
3850
+ if (generationSignal.aborted) controller.error(generationSignal.reason);
3851
+ else controller.close();
3852
+ }
3853
+ }
3854
+ })();
3855
+ },
3856
+ cancel(reason) {
3857
+ cancelled = true;
3858
+ streamAbort.abort(reason);
3481
3859
  },
3482
3860
  });
3483
3861
  return new Response(stream, {
@@ -3501,7 +3879,7 @@ export function createServer(
3501
3879
  lpc?.push(token, lpInfo);
3502
3880
  text += stopper.push(decoder.push(token));
3503
3881
  if (stopper.stopped) return false; // halt generation
3504
- }, undefined, shape);
3882
+ }, undefined, shape, request.signal);
3505
3883
  if (!stopper.stopped) {
3506
3884
  let tail = stopper.push(decoder.flush());
3507
3885
  if (!stopper.stopped) tail += stopper.flush();
@@ -3548,7 +3926,7 @@ export function createServer(
3548
3926
  } catch (e) {
3549
3927
  return anthropicError(400, "invalid_request_error", (e as Error).message);
3550
3928
  }
3551
- const resp = await handleChat(chatBody);
3929
+ const resp = await handleChat(chatBody, request.signal);
3552
3930
  if (!resp.ok) {
3553
3931
  const err = (await resp.json().catch(() => null)) as
3554
3932
  | { error?: { message?: string } }
@@ -3629,7 +4007,7 @@ export function createServer(
3629
4007
  } catch (e) {
3630
4008
  return responsesError(400, (e as Error).message);
3631
4009
  }
3632
- const resp = await handleChat(chatBody);
4010
+ const resp = await handleChat(chatBody, request.signal);
3633
4011
  if (!resp.ok) {
3634
4012
  const err = (await resp.json().catch(() => null)) as
3635
4013
  | { error?: { message?: string } }