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.
- package/README.md +63 -27
- package/THIRD_PARTY_LICENSES.md +9 -0
- package/docs/design/colibri-glm52-port.md +1486 -0
- package/docs/design/pre-colibri-stabilization.md +329 -0
- package/docs/investigations/colibri-oracle-pin.md +1284 -0
- package/docs/investigations/indexshare-performance-spike.md +357 -0
- package/docs/planning/release-notes-v0.0.12.md +80 -0
- package/docs/planning/release-notes-v0.0.13.md +77 -0
- package/docs/reference/cli.md +28 -2
- package/docs/reference/distribution.md +25 -1
- package/docs/reference/embedding.md +5 -2
- package/docs/reference/features-matrix.md +5 -2
- package/docs/reference/library-api.md +41 -13
- package/docs/reference/models.md +49 -2
- package/docs/reference/server-api.md +113 -20
- package/docs/reference/server-config.md +21 -14
- package/docs/reference/training.md +10 -1
- package/package.json +1 -1
- package/src/anthropic.ts +54 -44
- package/src/chat-template.ts +189 -11
- package/src/cli.ts +127 -8
- package/src/config.ts +81 -26
- package/src/diffusion/diffusion-generate.ts +14 -8
- package/src/download.ts +103 -12
- package/src/eval/ifeval.ts +63 -120
- package/src/eval/runner.ts +14 -5
- package/src/eval/tasks/ifeval.ts +167 -31
- package/src/expert-io.ts +391 -0
- package/src/expert-residency.ts +1134 -0
- package/src/expert-usage.ts +415 -0
- package/src/generate.ts +114 -17
- package/src/hf-push.ts +1 -1
- package/src/index.ts +14 -2
- package/src/jobs/runner.ts +37 -18
- package/src/kv-store.ts +147 -8
- package/src/lora.ts +77 -14
- package/src/media-fetch.ts +198 -56
- package/src/mlx/array.ts +43 -0
- package/src/mlx/autograd.ts +51 -14
- package/src/mlx/checkpoint.ts +46 -13
- package/src/mlx/custom-vjp.ts +83 -35
- package/src/model/factory.ts +96 -5
- package/src/model/gemma4-base.ts +32 -0
- package/src/model/glm52-atlas.ts +511 -0
- package/src/model/glm52-cache.ts +706 -0
- package/src/model/glm52-config.ts +262 -0
- package/src/model/glm52-container.ts +305 -0
- package/src/model/glm52-coupling.ts +388 -0
- package/src/model/glm52-dsa.ts +602 -0
- package/src/model/glm52-expert-layout.ts +253 -0
- package/src/model/glm52-layout.ts +152 -0
- package/src/model/glm52-memory.ts +473 -0
- package/src/model/glm52-mla.ts +786 -0
- package/src/model/glm52-moe.ts +223 -0
- package/src/model/glm52-pilot.ts +537 -0
- package/src/model/glm52-reference.ts +445 -0
- package/src/model/glm52-residency.ts +557 -0
- package/src/model/glm52-resident-weights.ts +135 -0
- package/src/model/glm52-streamed-experts.ts +453 -0
- package/src/model/glm52-streamed-metal.ts +610 -0
- package/src/model/glm52-weights.ts +503 -0
- package/src/model/glm52.ts +1153 -0
- package/src/model/support.ts +8 -1
- package/src/native/expert_io.c +523 -0
- package/src/native-pack.ts +6 -4
- package/src/responses.ts +62 -49
- package/src/serve/batch-scheduler.ts +95 -16
- package/src/serve/generation-gateway.ts +41 -16
- package/src/server.ts +471 -93
- package/src/spec/glm52-mtp-source.ts +392 -0
- package/src/spec/serve-loop.ts +99 -18
- package/src/spec/source.ts +31 -6
- package/src/ssd-cache.ts +6 -2
- package/src/tool-call.ts +30 -2
- package/src/train/job.ts +0 -10
- package/src/train/lora-params.ts +17 -6
- package/src/train/trainer.ts +59 -26
- package/src/web/app.html +13 -2
- package/src/web/app.js +30 -3
- package/src/web/src/status.ts +51 -3
- package/src/web-tools.ts +54 -2
- 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 {
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
1179
|
-
|
|
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
|
|
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.#
|
|
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
|
|
1638
|
-
ctx.model,
|
|
1639
|
-
|
|
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
|
|
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 (
|
|
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 ??
|
|
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())
|
|
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 {
|
|
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:
|
|
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:
|
|
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
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
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
|
|
3034
|
-
|
|
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 ${
|
|
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
|
-
|
|
3456
|
+
start(controller) {
|
|
3118
3457
|
const enc = new TextEncoder();
|
|
3119
|
-
const send = (obj: unknown) =>
|
|
3120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
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 =
|
|
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 ??
|
|
3370
|
-
|
|
3371
|
-
|
|
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
|
-
|
|
3796
|
+
start(controller) {
|
|
3432
3797
|
const enc = new TextEncoder();
|
|
3433
|
-
const send = (obj: unknown) =>
|
|
3434
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
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 } }
|