mlx-bun 0.0.13 → 0.2.0
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 +31 -21
- package/bin/mlx-bun.mjs +1 -1
- package/docs/{design → archive}/hlg-sampling.md +3 -3
- package/docs/{investigations → archive/investigations}/hlg-sampling-investigation.md +1 -1
- package/docs/archive/investigations/pareto-specialized-runtime-findings.md +92 -0
- package/docs/{planning → archive/planning}/chat-ui-vision.md +1 -1
- package/docs/{planning → archive/planning}/release-notes-v0.0.9.md +1 -1
- package/docs/{planning → archive/planning}/website-readme-pass-plan.md +1 -1
- package/docs/design/colibri-glm52-port.md +1 -1
- package/docs/design/compat-cli-surface-design.md +1 -1
- package/docs/design/decode-speed-program.md +1 -1
- package/docs/design/docs-reorg-plan.md +1 -1
- package/docs/design/dspark-serving-program.md +1 -1
- package/docs/design/dspark-speculative-decoding.md +53 -3
- package/docs/design/grammar-spec-batching-integration.md +1 -1
- package/docs/design/mlx-lm-serving-execution-seam.md +81 -0
- package/docs/design/orpo-dynamic-lambda.md +1 -1
- package/docs/design/orpo-future-enhancements.md +1 -1
- package/docs/design/orpo-training.md +2 -2
- package/docs/design/parity-tier-dag.md +1 -1
- package/docs/design/repo-cleanup-plan.md +46 -4
- package/docs/design/segmented-backward-training.md +2 -2
- package/docs/design/ssd-kv-cold-tier.md +127 -0
- package/docs/design/turboquant-weights.md +467 -0
- package/docs/design/w4a16-compute-precision-spike.md +1 -1
- package/docs/design/web-chat-redesign.md +5 -5
- package/docs/planning/chunk-task-roadmap.md +1 -1
- package/docs/planning/curve-sampler-research-plan.md +4 -4
- package/docs/planning/memory-docs-and-dag-plan.md +1 -1
- package/docs/planning/release-notes-v0.2.0.md +49 -0
- package/docs/planning/video-series.md +421 -0
- package/docs/reference/cli.md +15 -4
- package/docs/reference/distribution.md +1 -0
- package/docs/reference/features-matrix.md +3 -2
- package/docs/reference/library-api.md +41 -1
- package/docs/reference/models.md +33 -0
- package/docs/reference/server-api.md +69 -14
- package/docs/reference/server-config.md +57 -33
- package/package.json +2 -2
- package/src/anthropic.ts +92 -3
- package/src/cli.ts +131 -36
- package/src/eval/kl.ts +27 -20
- package/src/eval/runner.ts +23 -4
- package/src/eval/tasks/bfcl.ts +2 -1
- package/src/eval/tasks/gsm8k.ts +2 -1
- package/src/eval/tasks/hashhop.ts +2 -1
- package/src/eval/tasks/humaneval.ts +5 -4
- package/src/eval/tasks/ifeval.ts +2 -1
- package/src/eval/tasks/mmlu.ts +2 -1
- package/src/expert-io.ts +3 -1
- package/src/expert-offload.ts +3 -1
- package/src/expert-trace.ts +3 -1
- package/src/fit.ts +24 -43
- package/src/flags.ts +7 -9
- package/src/generate.ts +107 -92
- package/src/grammar.ts +2 -1
- package/src/harness-pi.ts +1 -1
- package/src/index.ts +20 -0
- package/src/jobs/job-entry.ts +3 -2
- package/src/kv-scheme.ts +199 -0
- package/src/kv-store.ts +370 -205
- package/src/media-fetch.ts +19 -3
- package/src/memory/model.ts +2 -1
- package/src/memory/vault.ts +2 -1
- package/src/memory-plan.ts +18 -0
- package/src/mlx/ffi.ts +8 -11
- package/src/mlx/ops.ts +31 -6
- package/src/model/batched-mask.ts +1 -1
- package/src/model/batched-quant.ts +1 -1
- package/src/model/batched-rotating-quant.ts +43 -97
- package/src/model/batched-rotating-state.ts +73 -0
- package/src/model/batched-rotating.ts +56 -105
- package/src/model/batched-row-storage.ts +127 -0
- package/src/model/compiled-decode.ts +3 -2
- package/src/model/factory.ts +44 -49
- package/src/model/gemma4-base.ts +163 -10
- package/src/model/generated/gemma4-12b.ts +50 -146
- package/src/model/generated/gemma4-26b.ts +32 -92
- package/src/model/generated/gemma4-e4b.ts +26 -74
- package/src/model/glm52-cache.ts +19 -9
- package/src/model/glm52-memory.ts +28 -1
- package/src/model/minicpm5.ts +2 -1
- package/src/model/profile.ts +433 -0
- package/src/model/qwen3-delta.ts +98 -2
- package/src/model/qwen3-mrope.ts +252 -0
- package/src/model/qwen3_5.ts +217 -25
- package/src/native/frame_extract.swift +140 -0
- package/src/native-pack.ts +13 -6
- package/src/pi-web.ts +6 -5
- package/src/prompt-cache.ts +2 -2
- package/src/quantize/atomic-output.ts +38 -0
- package/src/quantize/calibration.ts +2 -1
- package/src/quantize/config-writer.ts +6 -0
- package/src/quantize/index.ts +28 -1
- package/src/quantize/job.ts +11 -0
- package/src/quantize/quantizer.ts +265 -100
- package/src/quantize/rotate.ts +603 -0
- package/src/quantize/safetensors-writer.ts +79 -1
- package/src/quantize/sensitivity.ts +41 -59
- package/src/quantize/weight-transform.ts +292 -0
- package/src/responses.ts +102 -2
- package/src/runtime-config.ts +67 -0
- package/src/sampler.ts +272 -0
- package/src/serve/admin-routes.ts +184 -0
- package/src/serve/aux-routes.ts +132 -0
- package/src/serve/batch-scheduler.ts +200 -87
- package/src/serve/completion-executor.ts +346 -0
- package/src/serve/completion-sink.ts +157 -0
- package/src/serve/discovery-routes.ts +205 -0
- package/src/serve/generation-gateway.ts +116 -109
- package/src/serve/kv-budget.ts +14 -0
- package/src/serve/lab-routes.ts +338 -0
- package/src/serve/model-admin-routes.ts +153 -0
- package/src/serve/prompt-response-trace.ts +147 -0
- package/src/serve/request-plan.ts +158 -0
- package/src/serve/static-routes.ts +101 -0
- package/src/server.ts +704 -1400
- package/src/spec/dflash-source.ts +2 -1
- package/src/spec/dspark/module-dflash.ts +1 -1
- package/src/spec/glm52-mtp-source.ts +8 -0
- package/src/spec/qwen-mtp-source.ts +431 -0
- package/src/spec/serve-loop.ts +36 -68
- package/src/ssd-cache.ts +17 -14
- package/src/ssd-durability.ts +195 -0
- package/src/train/flash-cce.ts +12 -11
- package/src/train/forward.ts +32 -1
- package/src/train/loss.ts +2 -1
- package/src/train/segmented.ts +4 -3
- package/src/train/trainer.ts +3 -2
- package/src/vision/preprocess.ts +1 -1
- package/src/vision/prompt.ts +47 -1
- package/src/vision/qwen3vl-preprocess.ts +409 -0
- package/src/vision/qwen3vl-prompt.ts +196 -0
- package/src/vision/qwen3vl-tower.ts +513 -0
- package/src/vision/video-frames.ts +148 -0
- package/src/web/app.js +10 -3
- package/src/web/src/chat.ts +12 -5
- package/src/weights.ts +81 -26
- /package/docs/{investigations → archive/investigations}/chunk-finetune-experiment.md +0 -0
- /package/docs/{investigations → archive/investigations}/colibri-oracle-pin.md +0 -0
- /package/docs/{investigations → archive/investigations}/curve-bisector-routing.md +0 -0
- /package/docs/{investigations → archive/investigations}/curve-runs/bisector-route.json +0 -0
- /package/docs/{investigations → archive/investigations}/curve-runs/bisector-trace.json +0 -0
- /package/docs/{investigations → archive/investigations}/curve-runs/curve-terrain.json +0 -0
- /package/docs/{investigations → archive/investigations}/curve-runs/curve-terrain.svg +0 -0
- /package/docs/{investigations → archive/investigations}/curve-terrain.html +0 -0
- /package/docs/{investigations → archive/investigations}/decode-roofline-lookagain.md +0 -0
- /package/docs/{investigations → archive/investigations}/dspark-handoff.md +0 -0
- /package/docs/{investigations → archive/investigations}/expert-offload-single-user-moe.md +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-ab.html +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-explorer.html +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-figs/cliff.svg +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-figs/cross.svg +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-figs/edges.svg +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-figs/k20-grids.svg +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-figs/k20-peaks.svg +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-figs/lw-curve.svg +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-figs/terrain.svg +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-report.html +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-ab.json +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-blind-prompts.md +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-blind.json +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-cliff.json +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-cross.json +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-edge.json +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-eval1.json +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-frontier.json +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-grid.json +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-grid2.json +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-lw-coarse.json +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/hlg-map.json +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/run0-original.log +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/runA-recommended.log +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/runAA-cliff-cpm5.log +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/runAB-grid2-e4b.log +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/runAC-peak-e4b-partial.log +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/runAD-ab-samples.log +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/runAE-edge-e4b.log +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/runAF-logit-calibration.log +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/runAG-eval-config2.log +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/runB-gain-variants.log +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/runC-pivots.log +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/runD-oetf.log +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/runE-eotf.log +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/runF-shaper-working.log +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/runG-shaper-sweep.log +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/runH-wsweep.log +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/runI-asweep.log +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/runJ-knobmap.log +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/runK-frontier.log +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/runL-widemap.log +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/runM-frontier-emb.log +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/runN-verify.log +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/runO-frontier-seed5000.log +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/runP-grid-AxSm.log +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/runQ-grid-tgW.log +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/runR-blind-transcript.log +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/runS-cross-coherence.log +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/runT-cross-cpm5.log +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/runU-cross-12b.log +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/runV-lw-e4b.log +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/runW-lw-12b.log +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/runX-lw-cpm5.log +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/runY-cliff-e4b.log +0 -0
- /package/docs/{investigations → archive/investigations}/hlg-runs/runZ-cliff-12b.log +0 -0
- /package/docs/{investigations → archive/investigations}/indexshare-performance-spike.md +0 -0
- /package/docs/{investigations → archive/investigations}/kernel-perf-review-2026-07.md +0 -0
- /package/docs/{investigations → archive/investigations}/lab-build-journal.md +0 -0
- /package/docs/{investigations → archive/investigations}/openwiki-evaluation.md +0 -0
- /package/docs/{investigations → archive/investigations}/orpo-base-uf-experiment-and-directions.md +0 -0
- /package/docs/{investigations → archive/investigations}/orpo-flash-cce-pin-leak.md +0 -0
- /package/docs/{investigations → archive/investigations}/orpo-uf-testing-handoff.md +0 -0
- /package/docs/{investigations → archive/investigations}/pi-builtin-investigation.md +0 -0
- /package/docs/{investigations → archive/investigations}/segmented-backward-handoff.md +0 -0
- /package/docs/{investigations → archive/investigations}/starter-model-port-handoff.md +0 -0
- /package/docs/{investigations → archive/investigations}/steel-flash-cce-handoff.md +0 -0
- /package/docs/{investigations → archive/investigations}/trainer-validation-experiment.md +0 -0
- /package/docs/{planning → archive/planning}/chat-ui-vision-memory-first.md +0 -0
- /package/docs/{planning → archive/planning}/journal.md +0 -0
- /package/docs/{planning → archive/planning}/release-notes-v0.0.10.md +0 -0
- /package/docs/{planning → archive/planning}/release-notes-v0.0.11.md +0 -0
- /package/docs/{planning → archive/planning}/release-notes-v0.0.12.md +0 -0
- /package/docs/{planning → archive/planning}/release-notes-v0.0.13.md +0 -0
- /package/docs/{planning → archive/planning}/web-ui-pass-plan.md +0 -0
package/src/server.ts
CHANGED
|
@@ -38,8 +38,7 @@ import appJs from "./web/app.js" with { type: "text" };
|
|
|
38
38
|
import manifestWebmanifest from "./web/manifest.webmanifest" with { type: "text" };
|
|
39
39
|
import iconSvg from "./web/icon.svg" with { type: "text" };
|
|
40
40
|
import swJs from "./web/sw.js" with { type: "text" };
|
|
41
|
-
import
|
|
42
|
-
import { readFileSync } from "node:fs";
|
|
41
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
43
42
|
const APP_PAGE = appHtml as unknown as string;
|
|
44
43
|
const HLJS_JS = hljsJs as unknown as string;
|
|
45
44
|
const HLJS_CSS = hljsCss as unknown as string;
|
|
@@ -47,7 +46,6 @@ const APP_JS = appJs as unknown as string;
|
|
|
47
46
|
const MANIFEST_WEBMANIFEST = manifestWebmanifest as unknown as string;
|
|
48
47
|
const ICON_SVG = iconSvg as unknown as string;
|
|
49
48
|
const SW_JS = swJs as unknown as string;
|
|
50
|
-
const pkgVersion = (pkgJson as { version: string }).version;
|
|
51
49
|
import { loadModelConfig, type KvQuantSpec, type ModelConfig, type TurboQuantScheme } from "./config";
|
|
52
50
|
import { Weights } from "./weights";
|
|
53
51
|
import { Gemma4Model } from "./model/gemma4";
|
|
@@ -67,7 +65,8 @@ import {
|
|
|
67
65
|
GLM52_G5_MEASURED_WARM_DECODE_TPS,
|
|
68
66
|
type Glm52MemoryPlan,
|
|
69
67
|
} from "./model/glm52-memory";
|
|
70
|
-
import {
|
|
68
|
+
import { isMiniCPM5Config } from "./model/support";
|
|
69
|
+
import { resolveModelProfile, type ResolvedModelProfile } from "./model/profile";
|
|
71
70
|
import { Glm52NativeMtpProvider } from "./spec/glm52-mtp-source";
|
|
72
71
|
import {
|
|
73
72
|
generate,
|
|
@@ -77,6 +76,8 @@ import {
|
|
|
77
76
|
withModelWiredLimit,
|
|
78
77
|
} from "./generate";
|
|
79
78
|
import { cloneKvCaches, SpillQueue } from "./kv-store";
|
|
79
|
+
import { resolveKvScheme } from "./kv-scheme";
|
|
80
|
+
import { runtimeValue } from "./runtime-config";
|
|
80
81
|
import { TURBOQUANT_HEAD_DIMS } from "./mlx/turboquant-tables";
|
|
81
82
|
import {
|
|
82
83
|
compileGrammarRequest, grammarEnabled, type GrammarRequest,
|
|
@@ -85,7 +86,39 @@ import type { HlgConfig } from "./sampler";
|
|
|
85
86
|
import { isMonotone, CURVE_UMIN, type CurveParams } from "./curve-sampler";
|
|
86
87
|
const CURVE_PAGE = curveDesignerHtml as unknown as string;
|
|
87
88
|
import { GenerationGateway } from "./serve/generation-gateway";
|
|
88
|
-
import {
|
|
89
|
+
import {
|
|
90
|
+
CompletionExecutor,
|
|
91
|
+
CompletionRejected,
|
|
92
|
+
prepareCompletion,
|
|
93
|
+
type CompletionSummary,
|
|
94
|
+
type PreparedCompletion,
|
|
95
|
+
} from "./serve/completion-executor";
|
|
96
|
+
import {
|
|
97
|
+
createPromptResponseTrace,
|
|
98
|
+
type PromptResponseTrace,
|
|
99
|
+
} from "./serve/prompt-response-trace";
|
|
100
|
+
import { handleAdminRoute } from "./serve/admin-routes";
|
|
101
|
+
import { handleAuxiliaryRoute } from "./serve/aux-routes";
|
|
102
|
+
import {
|
|
103
|
+
createTimedFlowControl,
|
|
104
|
+
type CompletionEvent,
|
|
105
|
+
type CompletionStreamProtocol,
|
|
106
|
+
} from "./serve/completion-sink";
|
|
107
|
+
import { createDiscoveryRoutes } from "./serve/discovery-routes";
|
|
108
|
+
import { handleLabRoute } from "./serve/lab-routes";
|
|
109
|
+
import { handleModelAdminRoute } from "./serve/model-admin-routes";
|
|
110
|
+
import { RequestOwnership } from "./serve/request-plan";
|
|
111
|
+
import { handleStaticRoute } from "./serve/static-routes";
|
|
112
|
+
const STATIC_ROUTE_ASSETS = {
|
|
113
|
+
appPage: APP_PAGE,
|
|
114
|
+
appJs: APP_JS,
|
|
115
|
+
hljsJs: HLJS_JS,
|
|
116
|
+
hljsCss: HLJS_CSS,
|
|
117
|
+
manifest: MANIFEST_WEBMANIFEST,
|
|
118
|
+
iconSvg: ICON_SVG,
|
|
119
|
+
serviceWorker: SW_JS,
|
|
120
|
+
curvePage: CURVE_PAGE,
|
|
121
|
+
};
|
|
89
122
|
import {
|
|
90
123
|
ChatTemplate, type ChatMessage, type ToolDefinition,
|
|
91
124
|
} from "./chat-template";
|
|
@@ -96,24 +129,32 @@ import {
|
|
|
96
129
|
} from "./tool-call";
|
|
97
130
|
import { PromptCache, cacheBytes } from "./prompt-cache";
|
|
98
131
|
import { SsdCacheStore } from "./ssd-cache";
|
|
132
|
+
import {
|
|
133
|
+
SsdDurabilityCoordinator,
|
|
134
|
+
type DurabilityFlushResult,
|
|
135
|
+
type DurabilitySnapshotStats,
|
|
136
|
+
} from "./ssd-durability";
|
|
99
137
|
import { configFingerprint } from "./model/fingerprint";
|
|
100
138
|
import {
|
|
101
|
-
anthropicToChatBody, chatJsonToAnthropic,
|
|
139
|
+
anthropicToChatBody, chatJsonToAnthropic, createAnthropicStreamProtocol,
|
|
102
140
|
type AnthropicRequest,
|
|
103
141
|
} from "./anthropic";
|
|
104
142
|
import {
|
|
105
143
|
ResponseStore, chatJsonToResponses, outputItemsToInputItems,
|
|
106
|
-
responsesToChatBody,
|
|
144
|
+
responsesToChatBody, createResponsesStreamProtocol,
|
|
107
145
|
type ResponsesRequest,
|
|
108
146
|
} from "./responses";
|
|
109
|
-
import { AdapterManager
|
|
110
|
-
import { embedMany, isEmbeddingModel } from "./embed";
|
|
147
|
+
import { AdapterManager } from "./lora";
|
|
111
148
|
import { fit } from "./fit";
|
|
112
149
|
import { setMemoryLimit } from "./mlx/ffi";
|
|
113
150
|
import { VisionTower } from "./vision/embedder";
|
|
114
151
|
import { SiglipVisionTower, parseSiglipConfig } from "./vision/siglip";
|
|
152
|
+
import { Qwen3VLVisionTower } from "./vision/qwen3vl-tower";
|
|
153
|
+
import { buildQwen3VLVisionPrompt } from "./vision/qwen3vl-prompt";
|
|
154
|
+
import { Qwen35Model } from "./model/qwen3_5";
|
|
155
|
+
import type { MropeRequestState } from "./model/qwen3-mrope";
|
|
115
156
|
import {
|
|
116
|
-
buildMultimodalPrompt, buildVisionPrompt, extractAudio, extractImages,
|
|
157
|
+
buildMultimodalPrompt, buildVisionPrompt, extractAudio, extractImages, extractVideos,
|
|
117
158
|
type AudioTokenIds, type VisionTokenIds, type VisionEncoder,
|
|
118
159
|
} from "./vision/prompt";
|
|
119
160
|
import { AudioTower, parseAudioConfig } from "./audio/conformer";
|
|
@@ -130,12 +171,10 @@ export interface ServerOptions {
|
|
|
130
171
|
* evict; a request over the budget alone is rejected. Unset = no
|
|
131
172
|
* aggregate cap (per-request admission via memoryBudget still applies). */
|
|
132
173
|
kvBudgetBytes?: number;
|
|
133
|
-
/** KV quantization override.
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
* the serial path); "off" forces bf16; a number forces uniform bits
|
|
138
|
-
* (group size 64, start 0) ignoring the config file. */
|
|
174
|
+
/** KV quantization override. Unset/"off" is bf16. "config" applies the
|
|
175
|
+
* model's declared mixed-precision kv_config; supported per-layer schemes
|
|
176
|
+
* compose with continuous scheduling. A number forces uniform bits
|
|
177
|
+
* (group size 64, start 0) and uses the preserved serial executor. */
|
|
139
178
|
kvQuant?: "off" | "config" | number;
|
|
140
179
|
/** TurboQuant scheme (docs/design/turboquant-kv.md): a separate axis from
|
|
141
180
|
* kvQuant above, mutually exclusive with it (`--kv-quant turbo[:k<bits>v
|
|
@@ -191,12 +230,9 @@ export interface ServerOptions {
|
|
|
191
230
|
* mlx_lm.server's flag; its default there is 512 — ours stays 65,536 so
|
|
192
231
|
* thinking traces never truncate. `--max-tokens 512` = mlx-lm behavior. */
|
|
193
232
|
defaultMaxTokens?: number;
|
|
194
|
-
/**
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
* single-queue path — a mode switch, not a load-dependent
|
|
198
|
-
* fallback. See docs/design/parallel-slots.md. NOTE: the batched executor
|
|
199
|
-
* is mid-build; until it lands, >1 warns and runs serially. */
|
|
233
|
+
/** Continuous-scheduler concurrency cap (`--batch N`). Default 8; the
|
|
234
|
+
* scheduler specializes active B=1 and B=N with mlx-lm parity at the same
|
|
235
|
+
* composition. `--batch 1` pins the preserved strict serial executor. */
|
|
200
236
|
batch?: number;
|
|
201
237
|
/** HLG tone-curve sampling default (set via --hlg-sampling on + sub-knobs).
|
|
202
238
|
* A per-request `hlg` object overrides it field-by-field. Off when unset. */
|
|
@@ -223,8 +259,82 @@ export interface ServerOptions {
|
|
|
223
259
|
ssdCacheVerify?: boolean;
|
|
224
260
|
}
|
|
225
261
|
|
|
262
|
+
interface ServerLifecycle {
|
|
263
|
+
flush: () => Promise<DurabilityFlushResult>;
|
|
264
|
+
stats: () => DurabilitySnapshotStats;
|
|
265
|
+
stopTimers: () => void;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export interface ServerShutdownResult {
|
|
269
|
+
stopped: boolean;
|
|
270
|
+
timedOut: boolean;
|
|
271
|
+
durability: DurabilityFlushResult;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const serverLifecycles = new WeakMap<Server<unknown>, ServerLifecycle>();
|
|
275
|
+
|
|
276
|
+
function emptyDurabilityResult(): DurabilityFlushResult {
|
|
277
|
+
return {
|
|
278
|
+
durable: true,
|
|
279
|
+
flushedSnapshots: 0,
|
|
280
|
+
missingSnapshots: 0,
|
|
281
|
+
pendingSnapshots: 0,
|
|
282
|
+
pendingSpills: 0,
|
|
283
|
+
pendingSpillBytes: 0,
|
|
284
|
+
droppedSpills: 0,
|
|
285
|
+
failedSpills: 0,
|
|
286
|
+
elapsedMs: 0,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Flush prompt-cache snapshots without stopping the HTTP server. */
|
|
291
|
+
export function flushServerCacheDurability(
|
|
292
|
+
server: Server<unknown>,
|
|
293
|
+
): Promise<DurabilityFlushResult> {
|
|
294
|
+
return serverLifecycles.get(server)?.flush() ?? Promise.resolve(emptyDurabilityResult());
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Stop admission, flush cache durability, then finish the Bun server. */
|
|
298
|
+
export async function shutdownServer(
|
|
299
|
+
server: Server<unknown>, timeoutMs = 120_000,
|
|
300
|
+
): Promise<ServerShutdownResult> {
|
|
301
|
+
const lifecycle = serverLifecycles.get(server);
|
|
302
|
+
lifecycle?.stopTimers();
|
|
303
|
+
const stopped = Promise.resolve(server.stop(false));
|
|
304
|
+
const work = (async (): Promise<ServerShutdownResult> => {
|
|
305
|
+
await stopped;
|
|
306
|
+
const durability = lifecycle ? await lifecycle.flush() : emptyDurabilityResult();
|
|
307
|
+
return { stopped: true, timedOut: false, durability };
|
|
308
|
+
})();
|
|
309
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
|
310
|
+
const timeout = new Promise<ServerShutdownResult>((resolve) => {
|
|
311
|
+
timeoutHandle = setTimeout(() => {
|
|
312
|
+
const stats = lifecycle?.stats();
|
|
313
|
+
resolve({
|
|
314
|
+
stopped: false,
|
|
315
|
+
timedOut: true,
|
|
316
|
+
durability: {
|
|
317
|
+
...emptyDurabilityResult(),
|
|
318
|
+
durable: false,
|
|
319
|
+
...(stats ?? {}),
|
|
320
|
+
elapsedMs: timeoutMs,
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
}, timeoutMs);
|
|
324
|
+
timeoutHandle.unref?.();
|
|
325
|
+
});
|
|
326
|
+
try {
|
|
327
|
+
return await Promise.race([work, timeout]);
|
|
328
|
+
} finally {
|
|
329
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
226
333
|
export interface ServerContext {
|
|
227
334
|
model: RuntimeModel;
|
|
335
|
+
/** Declared external artifact/family profile that selected model
|
|
336
|
+
* construction. Request-level methods are resolved separately. */
|
|
337
|
+
profile: ResolvedModelProfile;
|
|
228
338
|
tokenizer: LoadedTokenizer;
|
|
229
339
|
template: ChatTemplate;
|
|
230
340
|
modelId: string;
|
|
@@ -311,10 +421,10 @@ export async function detectDraftKind(dir: string): Promise<DraftKind> {
|
|
|
311
421
|
// dspark.json, plain HF config stamped Gemma4DSparkModel.
|
|
312
422
|
if (cfg.architectures?.[0] === "Gemma4DSparkModel") return "deepspec";
|
|
313
423
|
if (String(cfg.model_type ?? "").includes("assistant")) return "assistant";
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
424
|
+
// Native MTP heads split from a qwen3_5-family release
|
|
425
|
+
// (mlx-community/Qwen3.8-27B-MTP-*): model_type "qwen3_5_mtp". The
|
|
426
|
+
// target's recurrent DeltaNet caches roll back via the serve loop's
|
|
427
|
+
// spec-round snapshot/replay contract (SSMCache.specRound*).
|
|
318
428
|
if (String(cfg.model_type ?? "").endsWith("_mtp")) return "mtp";
|
|
319
429
|
} catch {
|
|
320
430
|
// no/unreadable config → fall through to a full second model
|
|
@@ -322,11 +432,6 @@ export async function detectDraftKind(dir: string): Promise<DraftKind> {
|
|
|
322
432
|
return "two-model";
|
|
323
433
|
}
|
|
324
434
|
|
|
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
|
-
|
|
330
435
|
async function loadGenSamplingDefaults(modelDir: string): Promise<GenSamplingDefaults> {
|
|
331
436
|
const file = Bun.file(`${modelDir}/generation_config.json`);
|
|
332
437
|
if (!(await file.exists())) return {};
|
|
@@ -349,17 +454,27 @@ export async function loadContext(
|
|
|
349
454
|
opts: LoadContextOptions = {},
|
|
350
455
|
): Promise<ServerContext> {
|
|
351
456
|
const config = await loadModelConfig(modelDir);
|
|
352
|
-
const
|
|
457
|
+
const profile = resolveModelProfile(config);
|
|
458
|
+
const glm = profile.profile.execution.loader === "colibri";
|
|
459
|
+
// Bundled MTP companion: `--draft-kind mtp` with no --draft-model resolves
|
|
460
|
+
// to the artifact's own mtp/ subfolder (single-repo packaging — the
|
|
461
|
+
// companion is a complete model dir the provider already loads). Explicit
|
|
462
|
+
// --draft-model still wins; a missing bundle is a clear refusal below.
|
|
463
|
+
if (opts.draftKind === "mtp" && !opts.draftModelDir) {
|
|
464
|
+
const bundled = `${modelDir}/mtp`;
|
|
465
|
+
if (await Bun.file(`${bundled}/config.json`).exists()) {
|
|
466
|
+
opts = { ...opts, draftModelDir: bundled };
|
|
467
|
+
} else {
|
|
468
|
+
throw new Error(
|
|
469
|
+
`--draft-kind mtp needs a companion: pass --draft-model <dir> or use ` +
|
|
470
|
+
`an artifact that bundles one at <model>/mtp/ (none at ${bundled})`,
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
353
474
|
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
475
|
const resolvedDraftKind = opts.draftModelDir
|
|
360
476
|
? opts.draftKind ?? await detectDraftKind(opts.draftModelDir)
|
|
361
477
|
: opts.draftKind;
|
|
362
|
-
if (resolvedDraftKind === "mtp") throw new Error(QWEN_MTP_UNSUPPORTED_REASON);
|
|
363
478
|
if (glm && externalDraft && opts.glm?.enableMtp === true)
|
|
364
479
|
throw new Error("native GLM MTP and --draft-model/--draft-kind are mutually exclusive");
|
|
365
480
|
let weights: Weights | null = null;
|
|
@@ -396,9 +511,7 @@ export async function loadContext(
|
|
|
396
511
|
`${(opts.memoryBudgetBytes / 1e9).toFixed(2)} GB`,
|
|
397
512
|
);
|
|
398
513
|
}
|
|
399
|
-
|
|
400
|
-
// unmatched configs run the monolith — slow, never broken
|
|
401
|
-
if (!glm) model = createModel(weights!, config);
|
|
514
|
+
if (!glm) model = createModel(weights!, config, profile);
|
|
402
515
|
const tokenizer = await loadTokenizer(modelDir);
|
|
403
516
|
// Generation must stop on the tokenizer's eos_token — the chat turn
|
|
404
517
|
// terminator (e.g. Qwen <|im_end|> = 248046). Some configs (Qwen3.5-4B)
|
|
@@ -451,6 +564,17 @@ export async function loadContext(
|
|
|
451
564
|
} else if (kind === "assistant") {
|
|
452
565
|
const { AssistantProvider } = await import("./spec/assistant-source");
|
|
453
566
|
provider = await AssistantProvider.load(dir);
|
|
567
|
+
} else if (kind === "mtp") {
|
|
568
|
+
const { QwenMtpProvider } = await import("./spec/qwen-mtp-source");
|
|
569
|
+
const p = await QwenMtpProvider.load(dir);
|
|
570
|
+
provider = p;
|
|
571
|
+
// Default the round width to the head's trained block (block_size 3 →
|
|
572
|
+
// 2 recursive drafts + the pending row per round; the head was trained
|
|
573
|
+
// multi-step, so an explicit larger --num-draft-tokens is allowed but
|
|
574
|
+
// acceptance decides whether it pays).
|
|
575
|
+
const block = (await Bun.file(`${dir}/config.json`).json() as { block_size?: number }).block_size;
|
|
576
|
+
if (opts.numDraftTokens === undefined && typeof block === "number")
|
|
577
|
+
numDraftTokens = Math.max(1, block - 1);
|
|
454
578
|
} else {
|
|
455
579
|
const { TwoModelProvider } = await import("./spec/two-model");
|
|
456
580
|
provider = await TwoModelProvider.load(dir, config.text.vocabSize);
|
|
@@ -531,6 +655,7 @@ export async function loadContext(
|
|
|
531
655
|
return {
|
|
532
656
|
draft,
|
|
533
657
|
model,
|
|
658
|
+
profile,
|
|
534
659
|
glmMemoryPlan,
|
|
535
660
|
adapters: new AdapterManager(model),
|
|
536
661
|
kvConfig: config.kvQuant,
|
|
@@ -571,6 +696,21 @@ export async function loadContext(
|
|
|
571
696
|
function makeVisionLoader(
|
|
572
697
|
modelDir: string, model: RuntimeModel, config: ModelConfig,
|
|
573
698
|
): (() => VisionEncoder) | null {
|
|
699
|
+
// Qwen3.5/3.8: the tower ships as optiq/optiq_vision.safetensors (the
|
|
700
|
+
// artifact's bf16 sidecar). The returned tower is a Qwen3VLVisionTower —
|
|
701
|
+
// it rides the same lazy slot/capability flags; the qwen chat branch is
|
|
702
|
+
// the only consumer and casts it back (the gemma branches are gated on
|
|
703
|
+
// `instanceof Gemma4Model`, so the union never crosses).
|
|
704
|
+
if (model instanceof Qwen35Model) {
|
|
705
|
+
// Vision weights arrive either as the OptiQ-convention sidecar OR in-main
|
|
706
|
+
// (mlx-vlm convention; our artifacts ship one copy in-main since
|
|
707
|
+
// 2026-08-18 — the tower loader handles both).
|
|
708
|
+
const hasSidecar = existsSync(`${modelDir}/optiq/optiq_vision.safetensors`);
|
|
709
|
+
const hasInMain = config.raw.vision_config !== undefined;
|
|
710
|
+
if (!hasSidecar && !hasInMain) return null;
|
|
711
|
+
return () =>
|
|
712
|
+
Qwen3VLVisionTower.load(modelDir) as unknown as VisionEncoder;
|
|
713
|
+
}
|
|
574
714
|
if (!(config.hasVisionSidecar && model instanceof Gemma4Model)) return null;
|
|
575
715
|
const vc = config.raw.vision_config as Record<string, any> | undefined;
|
|
576
716
|
if (vc?.model_type === "gemma4_vision") {
|
|
@@ -736,30 +876,29 @@ type TextCompletionRequest = Omit<ChatRequest, "messages" | "tools" | "tool_choi
|
|
|
736
876
|
export interface ContextAdmissionDecision {
|
|
737
877
|
/** Effective completion ceiling after admission. */
|
|
738
878
|
maxTokens: number;
|
|
739
|
-
/** True when
|
|
879
|
+
/** True when admission reduced the client's upper bound to fit. */
|
|
740
880
|
clamped: boolean;
|
|
741
881
|
}
|
|
742
882
|
|
|
743
883
|
/** Resolve a request's completion upper bound against the admitted context.
|
|
744
884
|
*
|
|
745
|
-
*
|
|
746
|
-
*
|
|
747
|
-
*
|
|
748
|
-
*
|
|
749
|
-
*
|
|
750
|
-
*
|
|
751
|
-
*
|
|
885
|
+
* `max_tokens` is a ceiling, not a promise that every token will be emitted:
|
|
886
|
+
* when a prompt fits but a broad client-wide `max_tokens` would push the
|
|
887
|
+
* reservation past the safe context, the bound is CLAMPED to the remaining
|
|
888
|
+
* room instead of rejecting an otherwise valid request (the pre-v0.0.13
|
|
889
|
+
* behavior 400'd a prompt with thousands of tokens of generation room over a
|
|
890
|
+
* 17-token overshoot). This never weakens the OOM guard — generation stops at
|
|
891
|
+
* the clamped ceiling, inside the admitted context. Only a prompt that leaves
|
|
892
|
+
* no generation slot at all is rejected. */
|
|
752
893
|
export function admitRequestContext(
|
|
753
894
|
promptTokens: number,
|
|
754
895
|
requestedMaxTokens: number,
|
|
755
896
|
maxSafeContext: number,
|
|
756
|
-
clampToFit: boolean,
|
|
757
897
|
): ContextAdmissionDecision | null {
|
|
758
898
|
const available = maxSafeContext - promptTokens;
|
|
759
899
|
if (available < 1) return null;
|
|
760
900
|
if (requestedMaxTokens <= available)
|
|
761
901
|
return { maxTokens: requestedMaxTokens, clamped: false };
|
|
762
|
-
if (!clampToFit) return null;
|
|
763
902
|
return { maxTokens: available, clamped: true };
|
|
764
903
|
}
|
|
765
904
|
|
|
@@ -1181,7 +1320,8 @@ function hasMediaPart(parts: Array<Record<string, unknown>>): boolean {
|
|
|
1181
1320
|
(p) =>
|
|
1182
1321
|
p &&
|
|
1183
1322
|
(p.type === "image" || p.type === "image_url" ||
|
|
1184
|
-
p.type === "audio" || p.type === "input_audio" || p.type === "audio_url"
|
|
1323
|
+
p.type === "audio" || p.type === "input_audio" || p.type === "audio_url" ||
|
|
1324
|
+
p.type === "video" || p.type === "video_url"),
|
|
1185
1325
|
);
|
|
1186
1326
|
}
|
|
1187
1327
|
|
|
@@ -1253,7 +1393,8 @@ export function parseLogitBias(
|
|
|
1253
1393
|
|
|
1254
1394
|
/** Default seed for a request that didn't pin one. `Date.now()` alone is NOT
|
|
1255
1395
|
* request-unique: under `--batch N` the batch lane serves ONLY default-seed
|
|
1256
|
-
* requests (explicit-seed requests
|
|
1396
|
+
* requests (explicit-seed requests use the serial mechanism, see
|
|
1397
|
+
* GenerationGateway.place),
|
|
1257
1398
|
* so two identical prompts arriving in the same millisecond would share a seed
|
|
1258
1399
|
* and — with per-row RNG keyed as stepKey(seed, generatedCount) — produce
|
|
1259
1400
|
* byte-identical completions, silently collapsing best-of-N diversity. Mix a
|
|
@@ -1345,68 +1486,24 @@ export function validateLogprobsParams(body: {
|
|
|
1345
1486
|
return null;
|
|
1346
1487
|
}
|
|
1347
1488
|
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
*
|
|
1353
|
-
* -
|
|
1354
|
-
*
|
|
1355
|
-
*
|
|
1356
|
-
*
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
readonly #topTokens: { id: number; token: string; logprob: number }[][] = [];
|
|
1367
|
-
|
|
1368
|
-
constructor(
|
|
1369
|
-
private readonly wantLogprobs: boolean,
|
|
1370
|
-
private readonly topK: number,
|
|
1371
|
-
private readonly idToToken: (id: number) => string,
|
|
1372
|
-
) {}
|
|
1373
|
-
|
|
1374
|
-
get active(): boolean {
|
|
1375
|
-
return this.wantLogprobs || this.topK > 0;
|
|
1376
|
-
}
|
|
1377
|
-
|
|
1378
|
-
push(token: number, info?: TokenLogprobs): void {
|
|
1379
|
-
if (!this.active) return;
|
|
1380
|
-
this.#tokens.push(token);
|
|
1381
|
-
if (this.wantLogprobs) this.#tokenLogprobs.push(info?.logprob ?? NaN);
|
|
1382
|
-
if (this.topK > 0)
|
|
1383
|
-
this.#topTokens.push(
|
|
1384
|
-
(info?.top ?? []).map((t) => ({
|
|
1385
|
-
id: t.id,
|
|
1386
|
-
token: this.idToToken(t.id),
|
|
1387
|
-
logprob: t.logprob,
|
|
1388
|
-
})),
|
|
1389
|
-
);
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
/** choices[0].logprobs value, or null when nothing to attach (mirrors the
|
|
1393
|
-
* reference: the key is omitted entirely for zero collected tokens). */
|
|
1394
|
-
payload(): { content: Record<string, unknown>[] } | null {
|
|
1395
|
-
if (this.#topTokens.length)
|
|
1396
|
-
return {
|
|
1397
|
-
content: this.#topTokens.map((t) =>
|
|
1398
|
-
t.length ? { ...t[0]!, top_logprobs: t } : {},
|
|
1399
|
-
),
|
|
1400
|
-
};
|
|
1401
|
-
if (this.#tokenLogprobs.length)
|
|
1402
|
-
return {
|
|
1403
|
-
content: this.#tokens.map((id, i) => ({
|
|
1404
|
-
id,
|
|
1405
|
-
logprob: this.#tokenLogprobs[i]!,
|
|
1406
|
-
})),
|
|
1407
|
-
};
|
|
1408
|
-
return null;
|
|
1409
|
-
}
|
|
1489
|
+
const REASONING_EFFORT_LEVELS =
|
|
1490
|
+
["none", "minimal", "low", "medium", "high", "xhigh"] as const;
|
|
1491
|
+
|
|
1492
|
+
/** Validate `reasoning_effort` against the accepted level names. An invalid
|
|
1493
|
+
* string must NOT pass through: resolveEnableThinking treats any defined
|
|
1494
|
+
* value ≠ "none" as thinking-ON, so a typo like "hihg" silently flipped the
|
|
1495
|
+
* thinking channel (and its hotter temperature) on thinking-off models and
|
|
1496
|
+
* burned a full default-depth reasoning budget (2026-08-18 review). Same
|
|
1497
|
+
* contract style as validateLogprobsParams above. */
|
|
1498
|
+
export function validateReasoningEffort(body: {
|
|
1499
|
+
reasoning_effort?: unknown;
|
|
1500
|
+
}): string | null {
|
|
1501
|
+
const v = body.reasoning_effort;
|
|
1502
|
+
if (v === undefined || v === null) return null;
|
|
1503
|
+
if (typeof v !== "string" || !(REASONING_EFFORT_LEVELS as readonly string[]).includes(v))
|
|
1504
|
+
return "reasoning_effort must be one of " +
|
|
1505
|
+
"'none', 'minimal', 'low', 'medium', 'high', 'xhigh'";
|
|
1506
|
+
return null;
|
|
1410
1507
|
}
|
|
1411
1508
|
|
|
1412
1509
|
/** Incremental detokenizer: emits the longest stable decoded prefix.
|
|
@@ -1560,13 +1657,14 @@ function curveJunk(s: string): boolean {
|
|
|
1560
1657
|
export function createServer(
|
|
1561
1658
|
ctx: ServerContext, port = 0, serverOptions: ServerOptions = {},
|
|
1562
1659
|
): Server<unknown> {
|
|
1563
|
-
// --batch N
|
|
1564
|
-
//
|
|
1565
|
-
//
|
|
1660
|
+
// --batch N is a concurrency cap: N===1 pins the strict serial executor;
|
|
1661
|
+
// N>1 admits supported execution compositions to the continuous scheduler.
|
|
1662
|
+
// The scheduler chooses its B=1 fast path or B=N step from active rows.
|
|
1663
|
+
// Both full-attention (CPM) and
|
|
1566
1664
|
// sliding-window (Gemma) models batch — the scheduler assembles each layer's
|
|
1567
1665
|
// cache by attention type. Non-batchable requests (vision / adapters /
|
|
1568
|
-
//
|
|
1569
|
-
//
|
|
1666
|
+
// user seed / unsupported explicit kv-quant) drain to the serial executor
|
|
1667
|
+
// (see GenerationGateway.place). No inference setting is rewritten.
|
|
1570
1668
|
// DEFAULT 8 (flipped 2026-07-05, Josh's call, after GATE-B1-SPEED): a
|
|
1571
1669
|
// lone request through the batch lane IS the serial engine (adopted
|
|
1572
1670
|
// serial-class caches, compiled decode, prompt cache + SSD restore;
|
|
@@ -1585,7 +1683,6 @@ export function createServer(
|
|
|
1585
1683
|
// and must be an explicit opt-in (--kv-quant config|4|8, or --l2
|
|
1586
1684
|
// whose presets pass it explicitly). The CLI always passes kvQuant now;
|
|
1587
1685
|
// this fallback is the library-user default and matches the CLI's.
|
|
1588
|
-
const configScheme = ctx.kvConfig?.length ? { kvConfig: ctx.kvConfig } : {};
|
|
1589
1686
|
// Mutually exclusive by contract (GenerateOptions.turboQuant doc): a
|
|
1590
1687
|
// programmatic caller setting both gets turboQuant — say so, like the
|
|
1591
1688
|
// other risky-combination warnings below.
|
|
@@ -1594,13 +1691,12 @@ export function createServer(
|
|
|
1594
1691
|
`[kv-quant] both turboQuant and --kv-quant ${serverOptions.kvQuant} are set; ` +
|
|
1595
1692
|
`turboQuant wins (they are mutually exclusive).`,
|
|
1596
1693
|
);
|
|
1597
|
-
const
|
|
1598
|
-
|
|
1599
|
-
: serverOptions.
|
|
1600
|
-
:
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
: {}; // unset → bf16 (L1 default; quantized KV is opt-in)
|
|
1694
|
+
const resolvedKvScheme = resolveKvScheme({
|
|
1695
|
+
override: serverOptions.kvQuant,
|
|
1696
|
+
turboQuant: serverOptions.turboQuant,
|
|
1697
|
+
config: ctx.kvConfig,
|
|
1698
|
+
});
|
|
1699
|
+
const kvScheme = resolvedKvScheme.generationOptions;
|
|
1604
1700
|
// Phase 3.1: kvConfig whose layers are all full-attention BATCHES (the
|
|
1605
1701
|
// scheduler applies the mixed scheme per row); uniform kvBits and configs
|
|
1606
1702
|
// touching rotating layers still route those requests serial. The warning
|
|
@@ -1613,7 +1709,7 @@ export function createServer(
|
|
|
1613
1709
|
`--kv-quant to batch in bf16. (docs/design/unified-engine-frontier-plan.md)`,
|
|
1614
1710
|
);
|
|
1615
1711
|
// TurboQuant is solo-only in v1 (novel cache class, not batchable by
|
|
1616
|
-
// construction — see GenerationGateway
|
|
1712
|
+
// construction — see GenerationGateway placement's cache-capability gate).
|
|
1617
1713
|
if (batch > 1 && kvScheme.turboQuant)
|
|
1618
1714
|
console.warn(
|
|
1619
1715
|
`[batch] --batch ${batch} with --kv-quant turbo: TurboQuant is serial-only in v1 ` +
|
|
@@ -1699,11 +1795,7 @@ export function createServer(
|
|
|
1699
1795
|
if (serverOptions.ssdCacheDir) {
|
|
1700
1796
|
if (promptCacheCap <= 0)
|
|
1701
1797
|
throw new Error("--ssd-cache requires the RAM prompt cache (--prompt-cache 0 disables it)");
|
|
1702
|
-
const schemeKey =
|
|
1703
|
-
? `turbo-k${kvScheme.turboQuant.kBits}v${kvScheme.turboQuant.vBits}`
|
|
1704
|
-
: kvScheme.kvBits
|
|
1705
|
-
? `kv${kvScheme.kvBits}`
|
|
1706
|
-
: kvScheme.kvConfig?.length ? "config" : "bf16";
|
|
1798
|
+
const schemeKey = resolvedKvScheme.cacheKey;
|
|
1707
1799
|
const tokJson = readFileSync(`${ctx.model.config.modelDir}/tokenizer.json`);
|
|
1708
1800
|
ssdStore = new SsdCacheStore({
|
|
1709
1801
|
dir: serverOptions.ssdCacheDir,
|
|
@@ -1789,8 +1881,17 @@ export function createServer(
|
|
|
1789
1881
|
embeddings: import("./mlx/array").MlxArray;
|
|
1790
1882
|
imageMask?: import("./mlx/array").MlxArray;
|
|
1791
1883
|
multimodalMask?: import("./mlx/array").MlxArray;
|
|
1884
|
+
mrope?: MropeRequestState;
|
|
1792
1885
|
},
|
|
1886
|
+
trace?: PromptResponseTrace,
|
|
1793
1887
|
) => {
|
|
1888
|
+
// Qwen vision: install the request's mRoPE state for every forward of
|
|
1889
|
+
// this serial run (prefill AND decode use the 3D interleaved positions +
|
|
1890
|
+
// delta). Scoped inside the try below — under the serial mutex — so
|
|
1891
|
+
// queued text requests never see it and a throw between here and the
|
|
1892
|
+
// try (makeCache under memory pressure, spec-path early return) can't
|
|
1893
|
+
// leave a DEAD request's positions installed for the next generation
|
|
1894
|
+
// (2026-08-18 review).
|
|
1794
1895
|
// Speculative decoding (serve --draft-model): spec-ELIGIBLE requests
|
|
1795
1896
|
// decode through the verify loop; the rest fall through to the normal
|
|
1796
1897
|
// serial path (never wrong results, just no speedup — logged once per
|
|
@@ -1841,7 +1942,12 @@ export function createServer(
|
|
|
1841
1942
|
const skipPromptCache = Boolean(vision || pagedKv);
|
|
1842
1943
|
// Both tiers in one call (Layer 0): take() prefers a strictly-longer
|
|
1843
1944
|
// SSD prefix, restores it zero-copy, and trims — see PromptCache.take.
|
|
1945
|
+
const closeCacheLookup = trace?.begin("cache.lookup_restore", {
|
|
1946
|
+
mechanism: "serial",
|
|
1947
|
+
bypassed: skipPromptCache,
|
|
1948
|
+
});
|
|
1844
1949
|
const entry = skipPromptCache ? null : promptCache.take(promptIds, cacheNs);
|
|
1950
|
+
closeCacheLookup?.();
|
|
1845
1951
|
const caches = entry?.caches ?? ctx.model.makeCache();
|
|
1846
1952
|
// Prompt-boundary snapshot (the multi-turn agent fix, 2026-07-04): the
|
|
1847
1953
|
// prompt+gen entry put() below is UNTRIMMABLE at context > sliding
|
|
@@ -1865,6 +1971,8 @@ export function createServer(
|
|
|
1865
1971
|
const snapshotBoundary =
|
|
1866
1972
|
!skipPromptCache && boundary >= 256 && boundary > (entry?.tokens.length ?? 0);
|
|
1867
1973
|
try {
|
|
1974
|
+
if (vision?.mrope && ctx.model instanceof Qwen35Model)
|
|
1975
|
+
ctx.model.mrope = vision.mrope;
|
|
1868
1976
|
const gen = generate(ctx.model, promptIds, {
|
|
1869
1977
|
...options,
|
|
1870
1978
|
pagedKv, // request-scoped (undefined strips the server-wide flag)
|
|
@@ -1893,7 +2001,7 @@ export function createServer(
|
|
|
1893
2001
|
...(vision.multimodalMask ? { multimodalMask: vision.multimodalMask } : {}),
|
|
1894
2002
|
}
|
|
1895
2003
|
: {}),
|
|
1896
|
-
});
|
|
2004
|
+
}, { trace, mechanism: "serial" });
|
|
1897
2005
|
for await (const t of gen) {
|
|
1898
2006
|
if ((await onToken(t.token, t.logprobs)) === false) break;
|
|
1899
2007
|
}
|
|
@@ -1918,6 +2026,7 @@ export function createServer(
|
|
|
1918
2026
|
options.grammar?.dispose();
|
|
1919
2027
|
throw e;
|
|
1920
2028
|
} finally {
|
|
2029
|
+
if (ctx.model instanceof Qwen35Model) ctx.model.mrope = null;
|
|
1921
2030
|
vision?.embeddings.dispose();
|
|
1922
2031
|
vision?.imageMask?.dispose();
|
|
1923
2032
|
vision?.multimodalMask?.dispose();
|
|
@@ -1934,12 +2043,10 @@ export function createServer(
|
|
|
1934
2043
|
// extraction must not race a generation mutating the entry); if the entry
|
|
1935
2044
|
// was evicted (already spilled) or extended (a newer schedule pending)
|
|
1936
2045
|
// meanwhile, findExact misses and nothing is written.
|
|
1937
|
-
// Keyed by
|
|
1938
|
-
// not cancel the pending boundary
|
|
1939
|
-
//
|
|
1940
|
-
//
|
|
1941
|
-
// self-clean at fire time (findExact misses once the RAM tier superseded
|
|
1942
|
-
// the entry, so nothing extra is written).
|
|
2046
|
+
// Keyed by namespace and exact tokens: a sub-second final [prompt+gen] put
|
|
2047
|
+
// must not cancel the pending boundary snapshot, and unrelated prompts of
|
|
2048
|
+
// equal length must not cancel each other. Exact reschedules still
|
|
2049
|
+
// coalesce; stale keys self-clean when findExact misses.
|
|
1943
2050
|
//
|
|
1944
2051
|
// NON-BLOCKING (2026-07-06, the write-behind persistence contract): the
|
|
1945
2052
|
// gateway lock is held only for a zero-copy SNAPSHOT (findExact +
|
|
@@ -1964,23 +2071,28 @@ export function createServer(
|
|
|
1964
2071
|
// still land ahead of a just-arrived request. MLX_BUN_SSD_WRITEBEHIND=0
|
|
1965
2072
|
// disables write-behind snapshots entirely — the paired-A/B lever + kill
|
|
1966
2073
|
// switch (restart survival then degrades to spill-on-evict only).
|
|
1967
|
-
const writeBehindOn =
|
|
2074
|
+
const writeBehindOn = runtimeValue("MLX_BUN_SSD_WRITEBEHIND") !== "0";
|
|
1968
2075
|
const ssdFlushGate = (): Promise<void> => gateway.onIdle();
|
|
1969
|
-
const ssdPending = new Map<string, ReturnType<typeof setTimeout>>();
|
|
1970
2076
|
// Bounded write-behind queue (2026-07-07 review fix — see SpillQueue in
|
|
1971
2077
|
// kv-store.ts): pending clones pin their entries' GPU buffers while the
|
|
1972
2078
|
// idle gate starves under sustained traffic, so QUEUED bytes are capped —
|
|
1973
2079
|
// over cap the oldest queued spill drops (clone disposed immediately, a
|
|
1974
2080
|
// future cache miss, never a wrong result) instead of accumulating past
|
|
1975
2081
|
// the prompt-cache cap. Default 2 GB (a quarter of the 8 GB RAM-cache
|
|
1976
|
-
// default); MLX_BUN_SSD_SPILL_QUEUE_GB overrides.
|
|
1977
|
-
//
|
|
1978
|
-
//
|
|
1979
|
-
// a best-effort cache tier; drops/pending are visible in /stats.
|
|
2082
|
+
// default); MLX_BUN_SSD_SPILL_QUEUE_GB overrides. The durability
|
|
2083
|
+
// coordinator retains a dirty record until the queue confirms the atomic
|
|
2084
|
+
// store. Explicit flush and graceful shutdown can therefore retry a drop.
|
|
1980
2085
|
// `|| 2` would coerce an explicit "0" back to 2 GB — parse so 0 works
|
|
1981
2086
|
// (cap 0 = keep only the newest + in-flight clone pinned; the soft cap
|
|
1982
2087
|
// never drops the item just enqueued).
|
|
1983
|
-
|
|
2088
|
+
// The gateway exists before the spill queue because the SSD writer uses
|
|
2089
|
+
// its idle boundary to avoid competing with generation.
|
|
2090
|
+
const gateway = new GenerationGateway(ctx.model, batch, runGeneration, {
|
|
2091
|
+
kvBudgetBytes: serverOptions.kvBudgetBytes,
|
|
2092
|
+
kvScheme: resolvedKvScheme,
|
|
2093
|
+
promptCache,
|
|
2094
|
+
});
|
|
2095
|
+
const spillQueueGbRaw = Number(runtimeValue("MLX_BUN_SSD_SPILL_QUEUE_GB"));
|
|
1984
2096
|
const spillQueueCapBytes =
|
|
1985
2097
|
(Number.isFinite(spillQueueGbRaw) && spillQueueGbRaw >= 0 ? spillQueueGbRaw : 2) * 1024 ** 3;
|
|
1986
2098
|
const spillQueue = ssdStore
|
|
@@ -1991,38 +2103,39 @@ export function createServer(
|
|
|
1991
2103
|
(caches) => { for (const c of caches) c.dispose(); },
|
|
1992
2104
|
)
|
|
1993
2105
|
: null;
|
|
1994
|
-
const
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2106
|
+
const durability = ssdStore && spillQueue && writeBehindOn
|
|
2107
|
+
? new SsdDurabilityCoordinator(
|
|
2108
|
+
gateway,
|
|
2109
|
+
promptCache,
|
|
2110
|
+
spillQueue,
|
|
2111
|
+
cloneKvCaches,
|
|
2112
|
+
(tokens, ns) => ssdStore.hasDurablePrefix(tokens, ns),
|
|
2113
|
+
)
|
|
2114
|
+
: null;
|
|
2115
|
+
const durabilityStats = (): DurabilitySnapshotStats =>
|
|
2116
|
+
durability?.stats ?? {
|
|
2117
|
+
pendingSnapshots: 0,
|
|
2118
|
+
pendingSpills: spillQueue?.pendingCount ?? 0,
|
|
2119
|
+
pendingSpillBytes: spillQueue?.pendingBytes ?? 0,
|
|
2120
|
+
droppedSpills: spillQueue?.droppedCount ?? 0,
|
|
2121
|
+
failedSpills: spillQueue?.failedCount ?? 0,
|
|
2122
|
+
};
|
|
2123
|
+
const flushDurability = async (): Promise<DurabilityFlushResult> => {
|
|
2124
|
+
if (durability) return durability.flush();
|
|
2125
|
+
const started = performance.now();
|
|
2126
|
+
await spillQueue?.drain();
|
|
2127
|
+
const stats = durabilityStats();
|
|
2128
|
+
return {
|
|
2129
|
+
...stats,
|
|
2130
|
+
durable: stats.pendingSpills === 0,
|
|
2131
|
+
flushedSnapshots: 0,
|
|
2132
|
+
missingSnapshots: 0,
|
|
2133
|
+
elapsedMs: performance.now() - started,
|
|
2020
2134
|
};
|
|
2021
|
-
ssdPending.set(key, setTimeout(fire, 1000));
|
|
2022
2135
|
};
|
|
2023
2136
|
// Every put() — serial lane AND batch scheduler — schedules the snapshot
|
|
2024
2137
|
// (Layer 0: batch-lane entries survive restarts too, not just evictions).
|
|
2025
|
-
promptCache.onPut = (tokens, ns) =>
|
|
2138
|
+
promptCache.onPut = durability ? (tokens, ns) => durability.schedule(tokens, ns) : null;
|
|
2026
2139
|
|
|
2027
2140
|
// Idle demotion (Layer 0): entries unused for --ssd-demote-idle seconds
|
|
2028
2141
|
// spill to SSD and free their GPU memory — the RAM tier drains between
|
|
@@ -2046,52 +2159,49 @@ export function createServer(
|
|
|
2046
2159
|
demoteTimer.unref?.();
|
|
2047
2160
|
}
|
|
2048
2161
|
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
// usable prefix (multi-turn chat TTFT under --batch N); never-merged
|
|
2061
|
-
// lone rows put() back on finish. Vision/adapter requests never batch,
|
|
2062
|
-
// so the serial lane's bypass rules are preserved by routing.
|
|
2063
|
-
promptCache,
|
|
2162
|
+
const completionExecutor = new CompletionExecutor(gateway);
|
|
2163
|
+
const completionProtocolUsage = (usage: CompletionSummary["usage"]) => ({
|
|
2164
|
+
prompt_tokens: usage.promptTokens,
|
|
2165
|
+
completion_tokens: usage.completionTokens,
|
|
2166
|
+
total_tokens: usage.totalTokens,
|
|
2167
|
+
prompt_tokens_details: { cached_tokens: usage.cachedTokens },
|
|
2168
|
+
...(usage.speculation ? { speculation: usage.speculation } : {}),
|
|
2169
|
+
});
|
|
2170
|
+
const completionUsage = (summary: CompletionSummary) => ({
|
|
2171
|
+
...completionProtocolUsage(summary.usage),
|
|
2172
|
+
lane: summary.lane,
|
|
2064
2173
|
});
|
|
2065
2174
|
|
|
2066
2175
|
// Admission ceiling, resolved once (Phase 5 memoryBudget enforcement).
|
|
2067
2176
|
// fit() solves max safe context from weights + KV growth + prefill
|
|
2068
|
-
// transient
|
|
2069
|
-
//
|
|
2070
|
-
|
|
2177
|
+
// transient. The active kv-quant scheme (uniform kvBits / per-layer
|
|
2178
|
+
// kvConfig) is billed at its true bytes/element so a quantized cache
|
|
2179
|
+
// advertises and admits the larger window it actually enables; only
|
|
2180
|
+
// TurboQuant still bills bf16 (conservative — no projector for its
|
|
2181
|
+
// layout yet, and it is solo-only in v1).
|
|
2182
|
+
const admission = ctx.glmMemoryPlan ?? fit(
|
|
2071
2183
|
ctx.model.config, ctx.model.weightsBytes, 1,
|
|
2072
2184
|
undefined, undefined, 0, serverOptions.memoryBudgetBytes,
|
|
2185
|
+
resolvedKvScheme.fitOptions,
|
|
2073
2186
|
);
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
}
|
|
2080
|
-
: genericAdmission;
|
|
2187
|
+
// A zero ceiling is a warning, never a startup refusal: killing the
|
|
2188
|
+
// server here can only parrot the per-request admission message (which
|
|
2189
|
+
// still fires, with this same ceiling) or be a false positive from the
|
|
2190
|
+
// fit model itself — it can never save anything the request gate
|
|
2191
|
+
// doesn't. Serve until physically incapable.
|
|
2081
2192
|
if (admission.maxSafeContext < 1)
|
|
2082
|
-
|
|
2083
|
-
`memory budget ${(admission.usableBytes / 1e9).toFixed(2)} GB
|
|
2084
|
-
|
|
2085
|
-
|
|
2193
|
+
console.warn(
|
|
2194
|
+
`[admission] memory budget ${(admission.usableBytes / 1e9).toFixed(2)} GB leaves no ` +
|
|
2195
|
+
`safe context for ${ctx.modelId} (weights ${(ctx.model.weightsBytes / 1e9).toFixed(2)} GB) ` +
|
|
2196
|
+
`— serving anyway; generation requests will be refused until the budget is raised`,
|
|
2086
2197
|
);
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
setMemoryLimit(serverOptions.memoryBudgetBytes);
|
|
2198
|
+
const allocatorLimit =
|
|
2199
|
+
admission.allocatorLimitBytes ?? serverOptions.memoryBudgetBytes;
|
|
2200
|
+
if (allocatorLimit) setMemoryLimit(allocatorLimit);
|
|
2091
2201
|
|
|
2092
2202
|
// /library response cache (30 s) — registry + config reads only.
|
|
2093
|
-
let libraryCache: { at: number; rows: unknown[] } | null = null;
|
|
2094
2203
|
const startedAt = Date.now();
|
|
2204
|
+
const discoveryRoutes = createDiscoveryRoutes(ctx, gateway, startedAt);
|
|
2095
2205
|
|
|
2096
2206
|
// Captured so the WebSocket handler can resolve the bound (possibly
|
|
2097
2207
|
// ephemeral) port lazily for the loopback pi provider.
|
|
@@ -2355,250 +2465,36 @@ export function createServer(
|
|
|
2355
2465
|
async fetch(request, server) {
|
|
2356
2466
|
const url = new URL(request.url);
|
|
2357
2467
|
|
|
2468
|
+
if (url.pathname === "/admin/cache/flush" && request.method === "POST") {
|
|
2469
|
+
const result = await flushDurability();
|
|
2470
|
+
return Response.json(
|
|
2471
|
+
{
|
|
2472
|
+
...result,
|
|
2473
|
+
entries: ssdStore?.entries ?? 0,
|
|
2474
|
+
longest_durable_prefix_tokens:
|
|
2475
|
+
ssdStore?.longestDurablePrefixTokens ?? 0,
|
|
2476
|
+
},
|
|
2477
|
+
{ status: result.durable ? 200 : 503 },
|
|
2478
|
+
);
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2358
2481
|
if (url.pathname === "/ws/chat") {
|
|
2359
2482
|
if (server.upgrade(request, { data: { sessionId: crypto.randomUUID() } as PiWsData }))
|
|
2360
2483
|
return undefined;
|
|
2361
2484
|
return new Response("expected websocket", { status: 426 });
|
|
2362
2485
|
}
|
|
2363
2486
|
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
// Memory panel. Handlers live in src/memory/rest.ts (pure functions,
|
|
2367
|
-
// no `ctx` dependency) so they're unit-testable without a loaded
|
|
2368
|
-
// model; dispatch here just matches path+method. The agent-tool
|
|
2369
|
-
// surface (src/memory/tools.ts) is untouched by this block.
|
|
2370
|
-
if (url.pathname === "/api/memory/status" && request.method === "GET") {
|
|
2371
|
-
const { handleMemoryStatus } = await import("./memory/rest");
|
|
2372
|
-
return handleMemoryStatus();
|
|
2373
|
-
}
|
|
2374
|
-
if (url.pathname === "/api/memory/list" && request.method === "GET") {
|
|
2375
|
-
const { handleMemoryList } = await import("./memory/rest");
|
|
2376
|
-
return handleMemoryList();
|
|
2377
|
-
}
|
|
2378
|
-
if (url.pathname === "/api/memory/search" && request.method === "GET") {
|
|
2379
|
-
const { handleMemorySearch } = await import("./memory/rest");
|
|
2380
|
-
return handleMemorySearch(url);
|
|
2381
|
-
}
|
|
2382
|
-
if (url.pathname === "/api/memory/article" && request.method === "GET") {
|
|
2383
|
-
const { handleMemoryArticle } = await import("./memory/rest");
|
|
2384
|
-
return handleMemoryArticle(url);
|
|
2385
|
-
}
|
|
2386
|
-
if (url.pathname === "/api/memory/links" && request.method === "GET") {
|
|
2387
|
-
const { handleMemoryLinks } = await import("./memory/rest");
|
|
2388
|
-
return handleMemoryLinks(url);
|
|
2389
|
-
}
|
|
2390
|
-
if (url.pathname === "/api/memory/history" && request.method === "GET") {
|
|
2391
|
-
const { handleMemoryHistory } = await import("./memory/rest");
|
|
2392
|
-
return handleMemoryHistory(url);
|
|
2393
|
-
}
|
|
2394
|
-
if (url.pathname === "/api/memory/diff" && request.method === "GET") {
|
|
2395
|
-
const { handleMemoryDiff } = await import("./memory/rest");
|
|
2396
|
-
return handleMemoryDiff(url);
|
|
2397
|
-
}
|
|
2398
|
-
if (url.pathname === "/api/memory/init" && request.method === "POST") {
|
|
2399
|
-
const { handleMemoryInit } = await import("./memory/rest");
|
|
2400
|
-
return handleMemoryInit(request);
|
|
2401
|
-
}
|
|
2402
|
-
|
|
2403
|
-
// Model Hub (web-chat-redesign.md §9 Phase 3, beat-matrix Axis 3):
|
|
2404
|
-
// thin loopback JSON routes over the registry/download/fit machinery
|
|
2405
|
-
// for the web chat's Hub panel. Handlers live in src/hub-rest.ts
|
|
2406
|
-
// (pure functions, no `ctx` dependency); dispatch here just matches
|
|
2407
|
-
// path+method, same convention as the memory wrappers above.
|
|
2408
|
-
if (url.pathname === "/api/hub/local" && request.method === "GET") {
|
|
2409
|
-
const { handleHubLocal } = await import("./hub-rest");
|
|
2410
|
-
return handleHubLocal();
|
|
2411
|
-
}
|
|
2412
|
-
if (url.pathname === "/api/hub/search" && request.method === "GET") {
|
|
2413
|
-
const { handleHubSearch } = await import("./hub-rest");
|
|
2414
|
-
return handleHubSearch(url);
|
|
2415
|
-
}
|
|
2416
|
-
if (url.pathname === "/api/hub/download" && request.method === "POST") {
|
|
2417
|
-
const { handleHubDownload } = await import("./hub-rest");
|
|
2418
|
-
return handleHubDownload(request);
|
|
2419
|
-
}
|
|
2420
|
-
if (url.pathname === "/api/hub/serve" && request.method === "POST") {
|
|
2421
|
-
const { handleHubServe } = await import("./hub-rest");
|
|
2422
|
-
return handleHubServe(request);
|
|
2423
|
-
}
|
|
2424
|
-
|
|
2425
|
-
// Session full-text search + export (docs/design/web-chat-redesign.md
|
|
2426
|
-
// §9 Phase 3, beat-matrix Axis 10/11 — the full-text-search BEAT and
|
|
2427
|
-
// Markdown/JSON chat export). Handlers live in src/serve/session-search.ts
|
|
2428
|
-
// (pure, read-only, no `ctx` dependency), dispatched here by
|
|
2429
|
-
// path+method, same convention as the memory/hub blocks above.
|
|
2430
|
-
if (url.pathname === "/api/sessions/search" && request.method === "GET") {
|
|
2431
|
-
const { handleSessionsSearch } = await import("./serve/session-search");
|
|
2432
|
-
return handleSessionsSearch(url);
|
|
2433
|
-
}
|
|
2434
|
-
if (url.pathname === "/api/sessions/export" && request.method === "GET") {
|
|
2435
|
-
const { handleSessionsExport } = await import("./serve/session-search");
|
|
2436
|
-
return handleSessionsExport(url);
|
|
2437
|
-
}
|
|
2438
|
-
|
|
2439
|
-
// The unified SPA is served at "/"; legacy deep links redirect into
|
|
2440
|
-
// the hash router so old bookmarks still land on the right section.
|
|
2441
|
-
if (url.pathname === "/" && request.method === "GET") {
|
|
2442
|
-
return new Response(APP_PAGE, {
|
|
2443
|
-
headers: { "content-type": "text/html; charset=utf-8" },
|
|
2444
|
-
});
|
|
2445
|
-
}
|
|
2446
|
-
// Vendored syntax-highlighting assets (src/web/vendor/*, no CDN — see
|
|
2447
|
-
// that dir's README). Long cache: content is versioned by the vendor
|
|
2448
|
-
// rebuild step, not by URL, but a hard reload always wins during dev.
|
|
2449
|
-
if (url.pathname === "/assets/hljs.js" && request.method === "GET") {
|
|
2450
|
-
return new Response(HLJS_JS, {
|
|
2451
|
-
headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": "public, max-age=3600" },
|
|
2452
|
-
});
|
|
2453
|
-
}
|
|
2454
|
-
if (url.pathname === "/assets/hljs.css" && request.method === "GET") {
|
|
2455
|
-
return new Response(HLJS_CSS, {
|
|
2456
|
-
headers: { "content-type": "text/css; charset=utf-8", "cache-control": "public, max-age=3600" },
|
|
2457
|
-
});
|
|
2458
|
-
}
|
|
2459
|
-
// The frontend bundle (see the import comment above) — cache header
|
|
2460
|
-
// matches the other /assets/* entries; content is versioned by the
|
|
2461
|
-
// build step, not the URL, and a hard reload always wins during dev.
|
|
2462
|
-
if (url.pathname === "/assets/app.js" && request.method === "GET") {
|
|
2463
|
-
return new Response(APP_JS, {
|
|
2464
|
-
headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": "public, max-age=3600" },
|
|
2465
|
-
});
|
|
2466
|
-
}
|
|
2467
|
-
// PWA manifest + icon (plan §9 Phase 3): linked from app.html's
|
|
2468
|
-
// <link rel="manifest">. Same versioned-by-content cache posture as
|
|
2469
|
-
// the other /assets/* entries above.
|
|
2470
|
-
if (url.pathname === "/manifest.webmanifest" && request.method === "GET") {
|
|
2471
|
-
return new Response(MANIFEST_WEBMANIFEST, {
|
|
2472
|
-
headers: { "content-type": "application/manifest+json; charset=utf-8", "cache-control": "public, max-age=3600" },
|
|
2473
|
-
});
|
|
2474
|
-
}
|
|
2475
|
-
if (url.pathname === "/assets/icon.svg" && request.method === "GET") {
|
|
2476
|
-
return new Response(ICON_SVG, {
|
|
2477
|
-
headers: { "content-type": "image/svg+xml; charset=utf-8", "cache-control": "public, max-age=3600" },
|
|
2478
|
-
});
|
|
2479
|
-
}
|
|
2480
|
-
// Service worker MUST be served at the root scope (/sw.js, not
|
|
2481
|
-
// /assets/sw.js) — a worker's default scope is the directory it's
|
|
2482
|
-
// served from, and the shell it manages includes "/" itself.
|
|
2483
|
-
// no-store: the browser's own update check needs a fresh byte
|
|
2484
|
-
// comparison every time to notice a new worker, not a cached 304.
|
|
2485
|
-
if (url.pathname === "/sw.js" && request.method === "GET") {
|
|
2486
|
-
return new Response(SW_JS, {
|
|
2487
|
-
headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" },
|
|
2488
|
-
});
|
|
2489
|
-
}
|
|
2490
|
-
// v2 HLG Curve Designer — served same-origin so /generate + /signal need no CORS.
|
|
2491
|
-
// Read fresh from disk in dev (edits show on reload, no restart); fall back to the
|
|
2492
|
-
// embedded copy when running as the compiled single binary.
|
|
2493
|
-
if (url.pathname === "/curves" && request.method === "GET") {
|
|
2494
|
-
let html = CURVE_PAGE;
|
|
2495
|
-
try { html = readFileSync(new URL("./assets/curve-designer.html", import.meta.url), "utf8"); } catch { /* binary: use embedded */ }
|
|
2496
|
-
return new Response(html, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
|
|
2497
|
-
}
|
|
2498
|
-
// Training/inference DAG map — self-contained cytoscape artifact, served
|
|
2499
|
-
// same-origin so the Routes tab can embed it in an <iframe src="/dag">.
|
|
2500
|
-
if (url.pathname === "/dag" && request.method === "GET") {
|
|
2501
|
-
try {
|
|
2502
|
-
const html = readFileSync(new URL("../docs/dag/training-inference-map.html", import.meta.url), "utf8");
|
|
2503
|
-
return new Response(html, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
|
|
2504
|
-
} catch {
|
|
2505
|
-
return new Response("DAG map artifact not found at docs/dag/training-inference-map.html", { status: 404 });
|
|
2506
|
-
}
|
|
2507
|
-
}
|
|
2508
|
-
if (url.pathname === "/curve-terrain" && request.method === "GET") {
|
|
2509
|
-
try {
|
|
2510
|
-
const html = readFileSync(new URL("../docs/investigations/curve-terrain.html", import.meta.url), "utf8");
|
|
2511
|
-
return new Response(html, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
|
|
2512
|
-
} catch {
|
|
2513
|
-
return new Response("curve terrain artifact not found; run scripts/experiments/curve-terrain.ts first", { status: 404 });
|
|
2514
|
-
}
|
|
2515
|
-
}
|
|
2516
|
-
if (request.method === "GET" &&
|
|
2517
|
-
["/status", "/chat", "/quantize", "/finetune", "/dataset"].includes(url.pathname)) {
|
|
2518
|
-
return Response.redirect(`/#${url.pathname}`, 302);
|
|
2519
|
-
}
|
|
2487
|
+
const auxiliaryResponse = await handleAuxiliaryRoute(url, request);
|
|
2488
|
+
if (auxiliaryResponse) return auxiliaryResponse;
|
|
2520
2489
|
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
// (30 s cache — registry scan + config reads, no tensor bytes).
|
|
2524
|
-
if (!libraryCache || Date.now() - libraryCache.at > 30_000) {
|
|
2525
|
-
const { Registry } = await import("./registry");
|
|
2526
|
-
const { loadModelConfig } = await import("./config");
|
|
2527
|
-
const reg = new Registry();
|
|
2528
|
-
// Always rescan when (re)building the 30s cache so models that appeared
|
|
2529
|
-
// after boot — fresh downloads AND quants written into the HF cache —
|
|
2530
|
-
// surface on their own. (scan() is INSERT-OR-REPLACE + prunes deleted,
|
|
2531
|
-
// so it's idempotent and cheap for a local cache.)
|
|
2532
|
-
await reg.scan();
|
|
2533
|
-
const { visionCapable, audioCapable } = await import("./registry");
|
|
2534
|
-
const { supportTier } = await import("./model/support");
|
|
2535
|
-
const rows = [];
|
|
2536
|
-
// listCanonical: one row per repo (refs/main) — duplicate snapshots
|
|
2537
|
-
// from upstream re-pushes stay visible only in `ls --all-revisions`.
|
|
2538
|
-
for (const m of reg.listCanonical()) {
|
|
2539
|
-
const tier = supportTier(m.modelType, m.repoId);
|
|
2540
|
-
const supported = tier !== null;
|
|
2541
|
-
let assessment = null;
|
|
2542
|
-
try {
|
|
2543
|
-
const config = await loadModelConfig(m.path);
|
|
2544
|
-
const r = fit(config, m.sizeBytes, 8192, undefined, undefined, m.expertsBytes);
|
|
2545
|
-
assessment = {
|
|
2546
|
-
fits: r.fits,
|
|
2547
|
-
max_safe_context: r.maxSafeContext,
|
|
2548
|
-
predicted_decode_tps: r.predictedDecodeTps,
|
|
2549
|
-
};
|
|
2550
|
-
} catch {}
|
|
2551
|
-
rows.push({
|
|
2552
|
-
repo_id: m.repoId, model_type: m.modelType,
|
|
2553
|
-
size_bytes: m.sizeBytes, quant_bits: m.quantBits,
|
|
2554
|
-
vision: visionCapable(m), audio: audioCapable(m),
|
|
2555
|
-
supported, support_tier: tier,
|
|
2556
|
-
serving: m.repoId === ctx.modelId,
|
|
2557
|
-
assessment,
|
|
2558
|
-
});
|
|
2559
|
-
}
|
|
2560
|
-
libraryCache = { at: Date.now(), rows };
|
|
2561
|
-
}
|
|
2562
|
-
return Response.json({ models: libraryCache.rows });
|
|
2563
|
-
}
|
|
2490
|
+
const staticResponse = handleStaticRoute(url, request, STATIC_ROUTE_ASSETS);
|
|
2491
|
+
if (staticResponse) return staticResponse;
|
|
2564
2492
|
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
return Response.json({ downloads: downloadsSnapshot() });
|
|
2568
|
-
}
|
|
2493
|
+
const discoveryResponse = await discoveryRoutes.handle(url, request);
|
|
2494
|
+
if (discoveryResponse) return discoveryResponse;
|
|
2569
2495
|
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
// status page can show live stage/log/done progress. `?dry=1` plans the DAG
|
|
2573
|
-
// without any model call or vault write — the safe wiring-verification path
|
|
2574
|
-
// (the FULL-corpus run is USER-ACTION, P6-T5). GET so EventSource can drive it.
|
|
2575
|
-
if (url.pathname === "/v1/memory/synthesize" && request.method === "GET") {
|
|
2576
|
-
const dryRun = url.searchParams.get("dry") === "1";
|
|
2577
|
-
const { runSynthesis } = await import("./memory/pipeline");
|
|
2578
|
-
const enc = new TextEncoder();
|
|
2579
|
-
const stream = new ReadableStream({
|
|
2580
|
-
async start(controller) {
|
|
2581
|
-
const send = (e: unknown) =>
|
|
2582
|
-
controller.enqueue(enc.encode(`data: ${JSON.stringify(e)}\n\n`));
|
|
2583
|
-
try {
|
|
2584
|
-
const summary = await runSynthesis({ dryRun }, (ev) => send(ev));
|
|
2585
|
-
send({ type: "summary", ...summary });
|
|
2586
|
-
controller.enqueue(enc.encode("data: [DONE]\n\n"));
|
|
2587
|
-
} catch (e) {
|
|
2588
|
-
send({ type: "error", message: (e as Error).message });
|
|
2589
|
-
} finally {
|
|
2590
|
-
controller.close();
|
|
2591
|
-
}
|
|
2592
|
-
},
|
|
2593
|
-
});
|
|
2594
|
-
return new Response(stream, {
|
|
2595
|
-
headers: {
|
|
2596
|
-
"content-type": "text/event-stream",
|
|
2597
|
-
"cache-control": "no-cache",
|
|
2598
|
-
connection: "keep-alive",
|
|
2599
|
-
},
|
|
2600
|
-
});
|
|
2601
|
-
}
|
|
2496
|
+
const modelAdminResponse = await handleModelAdminRoute(url, request, ctx, gateway);
|
|
2497
|
+
if (modelAdminResponse) return modelAdminResponse;
|
|
2602
2498
|
|
|
2603
2499
|
if (url.pathname === "/fit" && request.method === "GET") {
|
|
2604
2500
|
// Fit assessment for the status page: this-machine report at the
|
|
@@ -2674,6 +2570,7 @@ export function createServer(
|
|
|
2674
2570
|
const report = fit(
|
|
2675
2571
|
ctx.model.config, ctx.model.weightsBytes, admission.maxSafeContext,
|
|
2676
2572
|
machine, undefined, expertsBytes, serverOptions.memoryBudgetBytes,
|
|
2573
|
+
resolvedKvScheme.fitOptions,
|
|
2677
2574
|
);
|
|
2678
2575
|
return Response.json({
|
|
2679
2576
|
machine: { chip: chip.name, ram_bytes: machine.ramBytes, bandwidth_gbs: machine.bandwidthGBs },
|
|
@@ -2686,6 +2583,7 @@ export function createServer(
|
|
|
2686
2583
|
ctx.model.config, ctx.model.weightsBytes,
|
|
2687
2584
|
Math.min(8192, admission.maxSafeContext),
|
|
2688
2585
|
machine, undefined, expertsBytes, serverOptions.memoryBudgetBytes,
|
|
2586
|
+
resolvedKvScheme.fitOptions,
|
|
2689
2587
|
).predictedDecodeTps,
|
|
2690
2588
|
measured_decode_tps: measured?.decodeTps ?? null,
|
|
2691
2589
|
measured_at: measured?.ts ?? null,
|
|
@@ -2707,18 +2605,6 @@ export function createServer(
|
|
|
2707
2605
|
});
|
|
2708
2606
|
}
|
|
2709
2607
|
|
|
2710
|
-
if (url.pathname === "/v1" && request.method === "GET") {
|
|
2711
|
-
return Response.json({
|
|
2712
|
-
name: "mlx-bun", version: pkgVersion, model: ctx.modelId,
|
|
2713
|
-
endpoints: [
|
|
2714
|
-
"POST /v1/chat/completions", "POST /v1/completions", "POST /v1/messages",
|
|
2715
|
-
"POST /v1/responses", "POST /v1/embeddings", "GET /v1/models",
|
|
2716
|
-
"GET/POST/DELETE /v1/adapters", "GET /health",
|
|
2717
|
-
"GET /stats", "GET /fit", "GET /library", "GET /downloads",
|
|
2718
|
-
],
|
|
2719
|
-
});
|
|
2720
|
-
}
|
|
2721
|
-
|
|
2722
2608
|
if (url.pathname === "/stats" && request.method === "GET") {
|
|
2723
2609
|
// Active KV scheme across ALL layers. Since Phase 9 rotating
|
|
2724
2610
|
// (sliding-window) caches quantize too, so every layer the
|
|
@@ -2767,12 +2653,12 @@ export function createServer(
|
|
|
2767
2653
|
spills: ssdStore.stats.spills,
|
|
2768
2654
|
restore_ms_last: Math.round(ssdStore.stats.restoreMsLast),
|
|
2769
2655
|
demotions: promptCache.demotions,
|
|
2770
|
-
|
|
2771
|
-
// the idle-gated flush, and spills the byte cap dropped
|
|
2772
|
-
// (each = one future cache miss, never a wrong result).
|
|
2656
|
+
pending_snapshots: durability?.stats.pendingSnapshots ?? 0,
|
|
2773
2657
|
pending_spills: spillQueue?.pendingCount ?? 0,
|
|
2774
2658
|
pending_spill_bytes: spillQueue?.pendingBytes ?? 0,
|
|
2775
2659
|
dropped_spills: spillQueue?.droppedCount ?? 0,
|
|
2660
|
+
failed_spills: spillQueue?.failedCount ?? 0,
|
|
2661
|
+
longest_durable_prefix_tokens: ssdStore.longestDurablePrefixTokens,
|
|
2776
2662
|
},
|
|
2777
2663
|
} : {}),
|
|
2778
2664
|
response_store: {
|
|
@@ -2839,8 +2725,6 @@ export function createServer(
|
|
|
2839
2725
|
});
|
|
2840
2726
|
}
|
|
2841
2727
|
|
|
2842
|
-
// mlx_lm.server parity: GET /health → the exact body it writes
|
|
2843
|
-
// ('{"status": "ok"}', note the space) so byte-for-byte health checks pass.
|
|
2844
2728
|
// Engine-mode admin (unix-socket children only — never exposed on
|
|
2845
2729
|
// TCP): drain = quiesce the gateway + demote the whole prompt cache
|
|
2846
2730
|
// to the SSD tier. The pool calls this before evicting a model
|
|
@@ -2856,207 +2740,6 @@ export function createServer(
|
|
|
2856
2740
|
return Response.json({ drained: true, demotions: promptCache.demotions });
|
|
2857
2741
|
}
|
|
2858
2742
|
|
|
2859
|
-
if (url.pathname === "/health" && request.method === "GET") {
|
|
2860
|
-
return new Response('{"status": "ok"}', {
|
|
2861
|
-
headers: { "content-type": "application/json" },
|
|
2862
|
-
});
|
|
2863
|
-
}
|
|
2864
|
-
|
|
2865
|
-
// GET /v1/models — the served model FIRST (with our capability extras),
|
|
2866
|
-
// then every other servable model the registry knows (mlx-lm scans the
|
|
2867
|
-
// HF cache here; our registry is that scan, filtered to supported
|
|
2868
|
-
// architectures). GET /v1/models/<id> filters to that id — same list
|
|
2869
|
-
// shape, matching mlx_lm.server's handle_models_request.
|
|
2870
|
-
if (
|
|
2871
|
-
(url.pathname === "/v1/models" || url.pathname.startsWith("/v1/models/")) &&
|
|
2872
|
-
request.method === "GET"
|
|
2873
|
-
) {
|
|
2874
|
-
const filterId = url.pathname.length > "/v1/models/".length - 1
|
|
2875
|
-
? decodeURIComponent(url.pathname.slice("/v1/models/".length))
|
|
2876
|
-
: null;
|
|
2877
|
-
const created = Math.floor(startedAt / 1000);
|
|
2878
|
-
// Model-author sampling defaults (generation_config.json), resolved once
|
|
2879
|
-
// at load (web-ui-pass-plan.md #14 groundwork): lets a client (the web
|
|
2880
|
-
// chat's sampling popover, external pi) show the SERVED model's actual
|
|
2881
|
-
// recommended values instead of one hardcoded shape.
|
|
2882
|
-
const genDefaultsOut = {
|
|
2883
|
-
temperature: ctx.genDefaults.temperature ?? null,
|
|
2884
|
-
top_p: ctx.genDefaults.topP ?? null,
|
|
2885
|
-
top_k: ctx.genDefaults.topK ?? null,
|
|
2886
|
-
};
|
|
2887
|
-
const data: Array<Record<string, unknown>> = [{
|
|
2888
|
-
id: ctx.modelId, object: "model", created, owned_by: "mlx-bun",
|
|
2889
|
-
context_window:
|
|
2890
|
-
ctx.glmMemoryPlan?.contextTokens ??
|
|
2891
|
-
ctx.model.config.text.maxPositionEmbeddings,
|
|
2892
|
-
// Capability flags for clients (CLI/external pi) that build a
|
|
2893
|
-
// provider from discovery — `reasoning` gates the thinking toggle,
|
|
2894
|
-
// `vision` the image input declaration, `audio` whether
|
|
2895
|
-
// `input_audio` content parts are accepted (tower loaded or lazily
|
|
2896
|
-
// loadable; same signals the web embed uses).
|
|
2897
|
-
reasoning: ctx.template.supportsThinking,
|
|
2898
|
-
vision: !!(ctx.vision || ctx.loadVision),
|
|
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
|
-
},
|
|
2923
|
-
gen_defaults: genDefaultsOut,
|
|
2924
|
-
}];
|
|
2925
|
-
try {
|
|
2926
|
-
const { Registry, visionCapable } = await import("./registry");
|
|
2927
|
-
const { supportTier } = await import("./model/support");
|
|
2928
|
-
const reg = new Registry();
|
|
2929
|
-
try {
|
|
2930
|
-
if (reg.list().length === 0) await reg.scan();
|
|
2931
|
-
// Canonical rows only — a repo with N snapshots is ONE model id.
|
|
2932
|
-
for (const m of reg.listCanonical()) {
|
|
2933
|
-
if (m.repoId === ctx.modelId) continue;
|
|
2934
|
-
const tier = supportTier(m.modelType, m.repoId);
|
|
2935
|
-
if (tier === null) continue;
|
|
2936
|
-
data.push({
|
|
2937
|
-
id: m.repoId, object: "model", created,
|
|
2938
|
-
vision: visionCapable(m), tier,
|
|
2939
|
-
});
|
|
2940
|
-
}
|
|
2941
|
-
} finally {
|
|
2942
|
-
reg.close();
|
|
2943
|
-
}
|
|
2944
|
-
} catch { /* registry unavailable → served model only */ }
|
|
2945
|
-
return Response.json({
|
|
2946
|
-
object: "list",
|
|
2947
|
-
data: filterId ? data.filter((m) => m.id === filterId) : data,
|
|
2948
|
-
});
|
|
2949
|
-
}
|
|
2950
|
-
|
|
2951
|
-
// OpenAI embeddings API. Works when the SERVED model is an embedding model
|
|
2952
|
-
// (plain Qwen3 / Qwen3-Embedding) — consistent with the single-model server
|
|
2953
|
-
// design: `mlx-bun serve <embedding-model>` to use this. Optional non-standard
|
|
2954
|
-
// `instruction` applies Qwen3-Embedding's query format. Embedding is a pure
|
|
2955
|
-
// forward (no decode loop / gateway), so it runs inline.
|
|
2956
|
-
if (url.pathname === "/v1/embeddings" && request.method === "POST") {
|
|
2957
|
-
if (!isEmbeddingModel(ctx.model))
|
|
2958
|
-
return Response.json({
|
|
2959
|
-
error: {
|
|
2960
|
-
message: `served model "${ctx.modelId}" is not an embedding model; ` +
|
|
2961
|
-
`serve an embedding model (e.g. Qwen3-Embedding) to use /v1/embeddings`,
|
|
2962
|
-
type: "invalid_request_error",
|
|
2963
|
-
},
|
|
2964
|
-
}, { status: 400 });
|
|
2965
|
-
let body: { input?: string | string[]; instruction?: string };
|
|
2966
|
-
try {
|
|
2967
|
-
body = (await request.json()) as typeof body;
|
|
2968
|
-
} catch {
|
|
2969
|
-
return Response.json({ error: { message: "invalid JSON body" } }, { status: 400 });
|
|
2970
|
-
}
|
|
2971
|
-
const inputs = Array.isArray(body.input) ? body.input : body.input != null ? [body.input] : [];
|
|
2972
|
-
if (inputs.length === 0 || !inputs.every((s) => typeof s === "string"))
|
|
2973
|
-
return Response.json({
|
|
2974
|
-
error: { message: "`input` must be a string or array of strings", type: "invalid_request_error" },
|
|
2975
|
-
}, { status: 400 });
|
|
2976
|
-
const instruction = typeof body.instruction === "string" ? body.instruction : undefined;
|
|
2977
|
-
const results = embedMany(ctx.model, ctx.tokenizer, inputs, instruction);
|
|
2978
|
-
let totalTokens = 0;
|
|
2979
|
-
const data = results.map((r, index) => {
|
|
2980
|
-
totalTokens += r.tokens;
|
|
2981
|
-
return { object: "embedding", index, embedding: Array.from(r.vector) };
|
|
2982
|
-
});
|
|
2983
|
-
return Response.json({
|
|
2984
|
-
object: "list",
|
|
2985
|
-
data,
|
|
2986
|
-
model: ctx.modelId,
|
|
2987
|
-
usage: { prompt_tokens: totalTokens, total_tokens: totalTokens },
|
|
2988
|
-
});
|
|
2989
|
-
}
|
|
2990
|
-
|
|
2991
|
-
// Adapter admin (port of optiq registry semantics): list / mount /
|
|
2992
|
-
// unmount. Mount and unmount go through the generation queue so
|
|
2993
|
-
// they never race an in-flight forward pass.
|
|
2994
|
-
if (url.pathname === "/v1/adapters/available" && request.method === "GET") {
|
|
2995
|
-
// On-disk adapters that can be mounted (the chat selector's source).
|
|
2996
|
-
// Every on-disk adapter is returned — the web chat routing chip (§5.2)
|
|
2997
|
-
// needs incompatible entries visible-but-grayed, not silently dropped,
|
|
2998
|
-
// so a user understands "10 adapters exist, 2 apply here" rather than
|
|
2999
|
-
// seeing a shorter list with no explanation. `compatible` = the
|
|
3000
|
-
// adapter's recorded base repo id matches the served model (compared
|
|
3001
|
-
// on the bare name, lenient about the org); an adapter with no
|
|
3002
|
-
// recorded base is treated as compatible (mount validates it for
|
|
3003
|
-
// real). `mounted` flags ones already loaded so the UI auto-loads on
|
|
3004
|
-
// select only if needed.
|
|
3005
|
-
const { homedir } = await import("node:os");
|
|
3006
|
-
const stores = [
|
|
3007
|
-
`${homedir()}/.cache/mlx-bun-finetunes`,
|
|
3008
|
-
`${homedir()}/.cache/mlx-bun/adapters`,
|
|
3009
|
-
];
|
|
3010
|
-
const mounted = new Set(ctx.adapters.list().map((a) => a.id));
|
|
3011
|
-
const bareName = (s: string) => s.split("/").pop()!.toLowerCase();
|
|
3012
|
-
const servedName = bareName(ctx.modelId);
|
|
3013
|
-
const adapters = (await listAvailableAdapters(stores))
|
|
3014
|
-
.map((a) => ({
|
|
3015
|
-
id: a.id, path: a.path, rank: a.rank, scale: a.scale,
|
|
3016
|
-
base_model: a.baseModel, mounted: mounted.has(a.id),
|
|
3017
|
-
compatible: a.baseModel == null || bareName(a.baseModel) === servedName,
|
|
3018
|
-
}));
|
|
3019
|
-
return Response.json({ adapters });
|
|
3020
|
-
}
|
|
3021
|
-
if (url.pathname === "/v1/adapters" && request.method === "GET") {
|
|
3022
|
-
return Response.json({
|
|
3023
|
-
adapters: ctx.adapters.list().map((a) => ({
|
|
3024
|
-
id: a.id, path: a.path, rank: a.rank, scale: a.scale,
|
|
3025
|
-
size_bytes: a.sizeBytes, mounted_layers: a.mountedLayers,
|
|
3026
|
-
ram_bytes: a.ramBytes,
|
|
3027
|
-
})),
|
|
3028
|
-
});
|
|
3029
|
-
}
|
|
3030
|
-
if (url.pathname === "/v1/adapters" && request.method === "POST") {
|
|
3031
|
-
let body: { id?: string; path?: string };
|
|
3032
|
-
try {
|
|
3033
|
-
body = (await request.json()) as typeof body;
|
|
3034
|
-
} catch {
|
|
3035
|
-
return Response.json({ error: { message: "invalid JSON body" } }, { status: 400 });
|
|
3036
|
-
}
|
|
3037
|
-
if (!body.id || !body.path)
|
|
3038
|
-
return Response.json({ error: { message: "id and path required" } }, { status: 400 });
|
|
3039
|
-
try {
|
|
3040
|
-
// Under the gateway lock: mount/unmount mutate the shared adapter
|
|
3041
|
-
// registry (unmount disposes arrays a running generation could still
|
|
3042
|
-
// hold) — one mutual-exclusion domain with generation (D3).
|
|
3043
|
-
const info = await gateway.runExclusive(() => ctx.adapters.mount(body.id!, body.path!));
|
|
3044
|
-
return Response.json({
|
|
3045
|
-
id: info.id, mounted_layers: info.mountedLayers,
|
|
3046
|
-
rank: info.rank, scale: info.scale, ram_bytes: info.ramBytes,
|
|
3047
|
-
});
|
|
3048
|
-
} catch (e) {
|
|
3049
|
-
return Response.json({ error: { message: (e as Error).message } }, { status: 400 });
|
|
3050
|
-
}
|
|
3051
|
-
}
|
|
3052
|
-
if (url.pathname.startsWith("/v1/adapters/") && request.method === "DELETE") {
|
|
3053
|
-
const id = decodeURIComponent(url.pathname.slice("/v1/adapters/".length));
|
|
3054
|
-
const removed = await gateway.runExclusive(async () => ctx.adapters.unmount(id));
|
|
3055
|
-
return removed > 0
|
|
3056
|
-
? Response.json({ id, removed_layers: removed })
|
|
3057
|
-
: Response.json({ error: { message: `adapter ${id} not mounted` } }, { status: 404 });
|
|
3058
|
-
}
|
|
3059
|
-
|
|
3060
2743
|
// ---- Curve Designer: POST /signal {prompt} → next-token histogram over the curve's x-axis ----
|
|
3061
2744
|
// One prefill forward; bins the real log-prob distribution so the editor can draw the
|
|
3062
2745
|
// signal under the curve (you shape where the tokens actually are).
|
|
@@ -3145,15 +2828,27 @@ export function createServer(
|
|
|
3145
2828
|
// translates its body into this shape and the Response back —
|
|
3146
2829
|
// generation, tools, vision, stop sequences, prompt cache, and
|
|
3147
2830
|
// admission control all live here exactly once.
|
|
3148
|
-
const handleChat = async (
|
|
2831
|
+
const handleChat = async (
|
|
2832
|
+
body: ChatRequest,
|
|
2833
|
+
signal: AbortSignal,
|
|
2834
|
+
streamProtocol?: CompletionStreamProtocol,
|
|
2835
|
+
requestId?: string,
|
|
2836
|
+
trace?: PromptResponseTrace,
|
|
2837
|
+
): Promise<Response> => {
|
|
3149
2838
|
if (!Array.isArray(body.messages) || body.messages.length === 0)
|
|
3150
2839
|
return Response.json({ error: { message: "messages required" } }, { status: 400 });
|
|
3151
2840
|
// mlx-lm validates logprobs params up front (ValueError → 400)
|
|
3152
2841
|
const lpParamError = validateLogprobsParams(body);
|
|
3153
2842
|
if (lpParamError)
|
|
3154
2843
|
return Response.json({ error: { message: lpParamError } }, { status: 400 });
|
|
3155
|
-
|
|
3156
|
-
|
|
2844
|
+
// Covers /v1/messages and /v1/responses too — both funnel through
|
|
2845
|
+
// this handler after translation.
|
|
2846
|
+
const effortError = validateReasoningEffort(body);
|
|
2847
|
+
if (effortError)
|
|
2848
|
+
return Response.json({ error: { message: effortError } }, { status: 400 });
|
|
2849
|
+
|
|
2850
|
+
const id = requestId ?? `chatcmpl-${crypto.randomUUID()}`;
|
|
2851
|
+
const closePromptPrepare = trace?.begin("request.prompt_prepare");
|
|
3157
2852
|
const created = Math.floor(Date.now() / 1000);
|
|
3158
2853
|
const tools =
|
|
3159
2854
|
body.tool_choice === "none" ? null : (body.tools?.length ? body.tools : null);
|
|
@@ -3169,6 +2864,13 @@ export function createServer(
|
|
|
3169
2864
|
(p: any) => p.type === "input_audio" || p.type === "audio" || p.type === "audio_url",
|
|
3170
2865
|
),
|
|
3171
2866
|
);
|
|
2867
|
+
// Video content parts (video_url / video with base64 data) —
|
|
2868
|
+
// Qwen3.5-family only (decoded to sampled frames via the
|
|
2869
|
+
// AVFoundation sidecar, vision/video-frames.ts).
|
|
2870
|
+
const hasVideos = body.messages.some(
|
|
2871
|
+
(m) => Array.isArray(m.content) &&
|
|
2872
|
+
m.content.some((p: any) => p.type === "video_url" || p.type === "video"),
|
|
2873
|
+
);
|
|
3172
2874
|
let promptIds: number[];
|
|
3173
2875
|
let stableLen: number | null = null;
|
|
3174
2876
|
// Whether the prompt primed an open <think> (Qwen3.5/MiniCPM5 thinking
|
|
@@ -3177,6 +2879,7 @@ export function createServer(
|
|
|
3177
2879
|
let startInThinking = false;
|
|
3178
2880
|
let vision: Parameters<typeof runGeneration>[3];
|
|
3179
2881
|
let diffusionPixels: import("./mlx/array").MlxArray | null = null;
|
|
2882
|
+
const ownership = new RequestOwnership();
|
|
3180
2883
|
// Grammar-constrained decoding (src/grammar.ts). Compile BEFORE prompt
|
|
3181
2884
|
// rendering: on the degrade path (compile failed but a constraint was
|
|
3182
2885
|
// requested) inject a system message instructing valid JSON so the
|
|
@@ -3190,27 +2893,30 @@ export function createServer(
|
|
|
3190
2893
|
body.structured_outputs != null;
|
|
3191
2894
|
if (grammarReq) {
|
|
3192
2895
|
const g = await compileGrammarForRequest(body);
|
|
3193
|
-
grammarCtrl = g.controller;
|
|
2896
|
+
grammarCtrl = ownership.own(g.controller);
|
|
3194
2897
|
if (!g.controller && g.degradeHint) {
|
|
3195
2898
|
const degraded = applyGrammarDegrade(body, g.degradeHint);
|
|
3196
2899
|
grammarWarning = degraded.warning;
|
|
3197
2900
|
body = degraded.body;
|
|
3198
2901
|
}
|
|
3199
2902
|
}
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
// generate()/the gateway only take disposal ownership once running
|
|
3204
|
-
// (leaked one matcher per SSRF-blocked-media + response_format
|
|
3205
|
-
// request before the 2026-07-07 sweep).
|
|
3206
|
-
const disposeRejected = () => {
|
|
3207
|
-
grammarCtrl?.dispose();
|
|
3208
|
-
vision?.embeddings.dispose();
|
|
3209
|
-
vision?.imageMask?.dispose();
|
|
3210
|
-
vision?.multimodalMask?.dispose();
|
|
3211
|
-
diffusionPixels?.dispose();
|
|
2903
|
+
const rejectBeforeRun = (response: Response): Response => {
|
|
2904
|
+
ownership.dispose();
|
|
2905
|
+
return response;
|
|
3212
2906
|
};
|
|
3213
2907
|
try {
|
|
2908
|
+
// Video is Qwen3.5-family only and never composes with audio —
|
|
2909
|
+
// one early guard so no downstream branch can silently drop a
|
|
2910
|
+
// video part (the gemma/diffusion builders don't know the type).
|
|
2911
|
+
if (hasVideos && (hasAudio || !(ctx.model instanceof Qwen35Model))) {
|
|
2912
|
+
return rejectBeforeRun(Response.json(
|
|
2913
|
+
{ error: { message: hasAudio
|
|
2914
|
+
? "video and audio content parts cannot be combined"
|
|
2915
|
+
: `model ${ctx.modelId} does not accept video input — video ` +
|
|
2916
|
+
`content parts need a Qwen3.5-family model (e.g. Qwen3.8-27B)` } },
|
|
2917
|
+
{ status: 400 },
|
|
2918
|
+
));
|
|
2919
|
+
}
|
|
3214
2920
|
if (hasAudio) {
|
|
3215
2921
|
// Audio (and MIXED image+audio) input — A4 of
|
|
3216
2922
|
// docs/design/audio-input-plan.md. One buildMultimodalPrompt call
|
|
@@ -3220,8 +2926,7 @@ export function createServer(
|
|
|
3220
2926
|
// requests without the media, getVisionTower's contract).
|
|
3221
2927
|
const audioTower = getAudioTower(ctx);
|
|
3222
2928
|
if (!audioTower || !ctx.audioTokenIds) {
|
|
3223
|
-
|
|
3224
|
-
return Response.json(
|
|
2929
|
+
return rejectBeforeRun(Response.json(
|
|
3225
2930
|
{
|
|
3226
2931
|
error: {
|
|
3227
2932
|
message:
|
|
@@ -3231,7 +2936,7 @@ export function createServer(
|
|
|
3231
2936
|
},
|
|
3232
2937
|
},
|
|
3233
2938
|
{ status: 400 },
|
|
3234
|
-
);
|
|
2939
|
+
));
|
|
3235
2940
|
}
|
|
3236
2941
|
let visionSide:
|
|
3237
2942
|
| { tower: VisionEncoder; tokenIds: VisionTokenIds }
|
|
@@ -3239,10 +2944,9 @@ export function createServer(
|
|
|
3239
2944
|
if (hasImages) {
|
|
3240
2945
|
const tower = getVisionTower(ctx);
|
|
3241
2946
|
if (!tower) {
|
|
3242
|
-
|
|
3243
|
-
return Response.json(
|
|
2947
|
+
return rejectBeforeRun(Response.json(
|
|
3244
2948
|
{ error: { message: "model has no vision sidecar" } }, { status: 400 },
|
|
3245
|
-
);
|
|
2949
|
+
));
|
|
3246
2950
|
}
|
|
3247
2951
|
visionSide = { tower, tokenIds: ctx.visionTokenIds };
|
|
3248
2952
|
}
|
|
@@ -3277,37 +2981,62 @@ export function createServer(
|
|
|
3277
2981
|
// forwardEmbeddings path). v1 supports a single image.
|
|
3278
2982
|
const dm = ctx.model;
|
|
3279
2983
|
if (!dm.visionTower) {
|
|
3280
|
-
|
|
3281
|
-
return Response.json(
|
|
2984
|
+
return rejectBeforeRun(Response.json(
|
|
3282
2985
|
{ error: { message: "this checkpoint has no vision tower" } }, { status: 400 },
|
|
3283
|
-
);
|
|
2986
|
+
));
|
|
3284
2987
|
}
|
|
3285
2988
|
const { messages, images } = await extractImages(normalizeMessages(body.messages));
|
|
3286
2989
|
if (images.length !== 1) {
|
|
3287
|
-
|
|
3288
|
-
return Response.json(
|
|
2990
|
+
return rejectBeforeRun(Response.json(
|
|
3289
2991
|
{ error: { message: "DiffusionGemma image input supports exactly one image" } },
|
|
3290
2992
|
{ status: 400 },
|
|
3291
|
-
);
|
|
2993
|
+
));
|
|
3292
2994
|
}
|
|
3293
2995
|
const rendered = ctx.template.render(messages, { tools, addGenerationPrompt: true });
|
|
3294
2996
|
const rawIds = ctx.tokenizer.encode(rendered, /* addSpecialTokens */ false);
|
|
3295
2997
|
const { pixels, softTokens } = await dm.visionTower.preprocess(images[0]!);
|
|
2998
|
+
diffusionPixels = ownership.own(pixels);
|
|
3296
2999
|
promptIds = spliceImageTokens(rawIds, [softTokens], {
|
|
3297
3000
|
image: ctx.visionTokenIds.imageTokenId,
|
|
3298
3001
|
boi: ctx.visionTokenIds.boiTokenId,
|
|
3299
3002
|
eoi: ctx.visionTokenIds.eoiTokenId,
|
|
3300
3003
|
});
|
|
3301
|
-
|
|
3004
|
+
} else if ((hasImages || hasVideos) && ctx.model instanceof Qwen35Model) {
|
|
3005
|
+
// Qwen3.8 vision + video: the tower is a Qwen3VLVisionTower
|
|
3006
|
+
// riding the shared lazy slot (makeVisionLoader's qwen branch);
|
|
3007
|
+
// image/video spans splice into input embeddings and the request
|
|
3008
|
+
// carries mRoPE positions + delta (PLAN 14v/14w). Videos decode
|
|
3009
|
+
// to sampled frames via the AVFoundation sidecar.
|
|
3010
|
+
const tower = getVisionTower(ctx) as unknown as Qwen3VLVisionTower | null;
|
|
3011
|
+
if (!tower) {
|
|
3012
|
+
return rejectBeforeRun(Response.json(
|
|
3013
|
+
{ error: { message: "model has no vision sidecar" } }, { status: 400 },
|
|
3014
|
+
));
|
|
3015
|
+
}
|
|
3016
|
+
const { messages: withVideos, images } =
|
|
3017
|
+
await extractImages(normalizeMessages(body.messages));
|
|
3018
|
+
const { messages, videos } = await extractVideos(withVideos);
|
|
3019
|
+
const vp = await buildQwen3VLVisionPrompt(
|
|
3020
|
+
ctx.model, tower, ctx.tokenizer, ctx.template, messages, images,
|
|
3021
|
+
{
|
|
3022
|
+
imageTokenId: (ctx.model.config.raw.image_token_id as number) ?? 248056,
|
|
3023
|
+
videoTokenId: (ctx.model.config.raw.video_token_id as number) ?? 248057,
|
|
3024
|
+
visionStartId: (ctx.model.config.raw.vision_start_token_id as number) ?? 248053,
|
|
3025
|
+
visionEndId: (ctx.model.config.raw.vision_end_token_id as number) ?? 248054,
|
|
3026
|
+
},
|
|
3027
|
+
templateOptionsFor(body, tools),
|
|
3028
|
+
videos,
|
|
3029
|
+
);
|
|
3030
|
+
promptIds = vp.ids;
|
|
3031
|
+
vision = { embeddings: vp.embeddings, mrope: vp.mrope };
|
|
3302
3032
|
} else if (hasImages) {
|
|
3303
3033
|
// Loads (and caches) the tower on first image request — text-only
|
|
3304
3034
|
// sessions never pay for it.
|
|
3305
3035
|
const tower = getVisionTower(ctx);
|
|
3306
3036
|
if (!tower) {
|
|
3307
|
-
|
|
3308
|
-
return Response.json(
|
|
3037
|
+
return rejectBeforeRun(Response.json(
|
|
3309
3038
|
{ error: { message: "model has no vision sidecar" } }, { status: 400 },
|
|
3310
|
-
);
|
|
3039
|
+
));
|
|
3311
3040
|
}
|
|
3312
3041
|
const { messages, images } = await extractImages(normalizeMessages(body.messages));
|
|
3313
3042
|
// The tower is only ever non-null for Gemma4 (sidecar gate in
|
|
@@ -3325,12 +3054,15 @@ export function createServer(
|
|
|
3325
3054
|
stableLen = -1; // marker: chat path, probe lazily below
|
|
3326
3055
|
}
|
|
3327
3056
|
} catch (e) {
|
|
3328
|
-
|
|
3329
|
-
return Response.json(
|
|
3057
|
+
return rejectBeforeRun(Response.json(
|
|
3330
3058
|
{ error: { message: `prompt build failed: ${(e as Error).message}` } },
|
|
3331
3059
|
{ status: 400 },
|
|
3332
|
-
);
|
|
3060
|
+
));
|
|
3333
3061
|
}
|
|
3062
|
+
ownership.own(vision?.embeddings);
|
|
3063
|
+
ownership.own(vision?.imageMask);
|
|
3064
|
+
ownership.own(vision?.multimodalMask);
|
|
3065
|
+
ownership.own(diffusionPixels);
|
|
3334
3066
|
let options: ReturnType<typeof toOptions>;
|
|
3335
3067
|
try {
|
|
3336
3068
|
options = toOptions(body);
|
|
@@ -3348,105 +3080,81 @@ export function createServer(
|
|
|
3348
3080
|
}
|
|
3349
3081
|
} catch (e) {
|
|
3350
3082
|
// bad logit_bias (non-numeric keys/values) — mlx-lm's coercion error
|
|
3351
|
-
|
|
3352
|
-
|
|
3083
|
+
return rejectBeforeRun(
|
|
3084
|
+
Response.json({ error: { message: (e as Error).message } }, { status: 400 }),
|
|
3085
|
+
);
|
|
3353
3086
|
}
|
|
3354
3087
|
if (diffusionPixels) options.visionPixels = diffusionPixels;
|
|
3355
3088
|
// Attach the compiled grammar controller (null when no constraint /
|
|
3356
3089
|
// degrade — generate() runs the unmasked fast pipelined loop).
|
|
3357
3090
|
if (grammarCtrl) options.grammar = grammarCtrl;
|
|
3358
|
-
// Admission: reject what cannot finish within the memory budget
|
|
3359
|
-
// (the GPU OOM it would otherwise hit is uncatchable and kills
|
|
3360
|
-
// the process — Phase 6 finding).
|
|
3361
3091
|
const requestedMaxTokens = options.maxTokens ?? 1024;
|
|
3362
|
-
|
|
3363
|
-
const contextDecision = admitRequestContext(
|
|
3364
|
-
promptIds.length,
|
|
3365
|
-
requestedMaxTokens,
|
|
3366
|
-
admission.maxSafeContext,
|
|
3367
|
-
ctx.glmMemoryPlan !== null,
|
|
3368
|
-
);
|
|
3369
|
-
if (!contextDecision) {
|
|
3370
|
-
disposeRejected();
|
|
3371
|
-
return Response.json(
|
|
3372
|
-
{
|
|
3373
|
-
error: {
|
|
3374
|
-
message:
|
|
3375
|
-
`request needs ${requiredCtx} tokens of context ` +
|
|
3376
|
-
`(prompt ${promptIds.length} + max_tokens ${requestedMaxTokens}) but the ` +
|
|
3377
|
-
`memory budget caps safe context at ${admission.maxSafeContext} — ` +
|
|
3378
|
-
`shorten the prompt or lower max_tokens`,
|
|
3379
|
-
type: "memory_admission",
|
|
3380
|
-
code: "context_over_budget",
|
|
3381
|
-
},
|
|
3382
|
-
},
|
|
3383
|
-
{ status: 400 },
|
|
3384
|
-
);
|
|
3385
|
-
}
|
|
3386
|
-
options.maxTokens = contextDecision.maxTokens;
|
|
3092
|
+
let adapterIds: string[];
|
|
3387
3093
|
try {
|
|
3388
3094
|
// A request's explicit `adapter` (incl. "none") wins over the
|
|
3389
3095
|
// startup default from `serve --adapter <dir>`.
|
|
3390
|
-
|
|
3391
|
-
if (adapterIds.length) options.adapters = adapterIds;
|
|
3096
|
+
adapterIds = ctx.adapters.resolveSpec(body.adapter ?? serverOptions.defaultAdapter);
|
|
3392
3097
|
} catch (e) {
|
|
3393
|
-
|
|
3394
|
-
|
|
3098
|
+
return rejectBeforeRun(
|
|
3099
|
+
Response.json({ error: { message: (e as Error).message } }, { status: 400 }),
|
|
3100
|
+
);
|
|
3395
3101
|
}
|
|
3396
3102
|
|
|
3397
|
-
// logprobs capture: non-stream only — stream chunks never carry
|
|
3398
|
-
// logprobs (mirroring mlx_lm.server, whose streaming generate_response
|
|
3399
|
-
// calls pass no token_logprobs/top_tokens), so streaming requests skip
|
|
3400
|
-
// the capture cost entirely and stay batchable.
|
|
3401
3103
|
const wantLogprobs = body.logprobs === true;
|
|
3402
3104
|
const topLogprobs =
|
|
3403
3105
|
typeof body.top_logprobs === "number" && body.top_logprobs > 0
|
|
3404
3106
|
? body.top_logprobs : 0;
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3107
|
+
let prepared: PreparedCompletion;
|
|
3108
|
+
try {
|
|
3109
|
+
prepared = prepareCompletion({
|
|
3110
|
+
requestId: id,
|
|
3111
|
+
plan: {
|
|
3112
|
+
promptIds,
|
|
3113
|
+
options,
|
|
3114
|
+
requestedMaxTokens,
|
|
3115
|
+
maxSafeContext: admission.maxSafeContext,
|
|
3116
|
+
stream: body.stream === true,
|
|
3117
|
+
wantLogprobs,
|
|
3118
|
+
topLogprobs,
|
|
3119
|
+
adapterIds,
|
|
3120
|
+
hasVision: !!vision,
|
|
3121
|
+
userSeed: body.seed !== undefined,
|
|
3122
|
+
hasGrammar: !!grammarCtrl,
|
|
3123
|
+
hasDraft: !!ctx.draft,
|
|
3124
|
+
ownership,
|
|
3125
|
+
},
|
|
3126
|
+
vision,
|
|
3127
|
+
pipeline: {
|
|
3128
|
+
router: toolRouter(tools),
|
|
3129
|
+
stopper: new StopMatcher(options.stopSequences),
|
|
3130
|
+
thinking: new ThinkingTagSplitter(
|
|
3131
|
+
ctx.template.thinkingFormat === "think-tag",
|
|
3132
|
+
startInThinking,
|
|
3133
|
+
),
|
|
3134
|
+
collectToolCalls: true,
|
|
3135
|
+
},
|
|
3136
|
+
...(body.stream
|
|
3137
|
+
? {
|
|
3138
|
+
createFlowControl: ({ mechanism }) =>
|
|
3139
|
+
createTimedFlowControl(mechanism === "serial"),
|
|
3140
|
+
}
|
|
3141
|
+
: {}),
|
|
3142
|
+
onPlacement: ({ mechanism, shape }) => {
|
|
3143
|
+
if (runtimeValue("MLX_BUN_LANE_DEBUG") === "1")
|
|
3144
|
+
console.error(
|
|
3145
|
+
`[scheduling] mechanism=${mechanism} shape=${JSON.stringify(shape)} ` +
|
|
3146
|
+
`t=${Date.now() % 100000}`,
|
|
3147
|
+
);
|
|
3148
|
+
},
|
|
3149
|
+
idToToken: (tokenId) => ctx.tokenizer.idToToken(tokenId),
|
|
3150
|
+
});
|
|
3151
|
+
} catch (error) {
|
|
3152
|
+
closePromptPrepare?.();
|
|
3153
|
+
trace?.finish("error", { stage: "prepare_completion" });
|
|
3154
|
+
if (!(error instanceof CompletionRejected)) throw error;
|
|
3155
|
+
return Response.json({ error: error.error }, { status: error.status });
|
|
3409
3156
|
}
|
|
3410
|
-
|
|
3411
|
-
// What lane this request takes (vision/audio media / adapters /
|
|
3412
|
-
// logprobs / seed / explicit kv-quant / a mounted draft → serial;
|
|
3413
|
-
// sampler extras, repetition penalty, and grammar all BATCH — see
|
|
3414
|
-
// willBatch). `vision` is the embeddings-prefill payload for BOTH
|
|
3415
|
-
// media kinds, so hasVision routes audio serial too — batching is a
|
|
3416
|
-
// mode, not a fallback, and batched audio has no oracle (§3.2).
|
|
3417
|
-
const shape = {
|
|
3418
|
-
hasVision: !!vision,
|
|
3419
|
-
hasAdapters: !!options.adapters?.length,
|
|
3420
|
-
hasRepetitionPenalty: !!options.repetitionPenalty,
|
|
3421
|
-
// Informational since 2026-07-02: per-row logits processors batch;
|
|
3422
|
-
// willBatch no longer gates on these fields.
|
|
3423
|
-
hasLogitsExtras: !!(
|
|
3424
|
-
options.minP || options.xtcProbability || options.logitBias ||
|
|
3425
|
-
options.presencePenalty || options.frequencyPenalty
|
|
3426
|
-
),
|
|
3427
|
-
wantsLogprobs: captureLogprobs,
|
|
3428
|
-
userSeed: body.seed !== undefined,
|
|
3429
|
-
kvQuant: !!(options.kvConfig?.length || options.kvBits),
|
|
3430
|
-
turboQuant: !!options.turboQuant,
|
|
3431
|
-
// grammarCtrl is null on the degrade path (prompt injection) —
|
|
3432
|
-
// those stay batchable. A real controller batches via per-row
|
|
3433
|
-
// matchers (MLX_BUN_GRAMMAR_BATCH=0 forces it serial).
|
|
3434
|
-
hasGrammar: !!grammarCtrl,
|
|
3435
|
-
hasDraft: !!ctx.draft,
|
|
3436
|
-
};
|
|
3437
|
-
const batched = gateway.willBatch(shape);
|
|
3438
|
-
if (process.env.MLX_BUN_LANE_DEBUG === "1")
|
|
3439
|
-
console.error(`[lane] batched=${batched} shape=${JSON.stringify(shape)} t=${Date.now() % 100000}`);
|
|
3440
|
-
// Per-turn lane (docs/design/web-chat-redesign.md §2.3 caveat / risk #5):
|
|
3441
|
-
// reported on usage AND recorded in the in-process lane registry (keyed
|
|
3442
|
-
// by this response's `id`) so pi-web's WS bridge — which the pi SDK's
|
|
3443
|
-
// usage parsing can't carry a custom field through — can correlate it
|
|
3444
|
-
// via AssistantMessage.responseId. batched wins over hasDraft (mutually
|
|
3445
|
-
// exclusive by construction: willBatch already excludes hasDraft), so
|
|
3446
|
-
// the only ambiguity is serial vs serial+spec, resolved once s.spec is
|
|
3447
|
-
// known post-generation.
|
|
3448
|
-
const lane: Lane = batched ? "batched" : shape.hasDraft ? "serial+spec" : "serial";
|
|
3449
|
-
recordLane(id, lane);
|
|
3157
|
+
closePromptPrepare?.();
|
|
3450
3158
|
|
|
3451
3159
|
if (body.stream) {
|
|
3452
3160
|
const streamAbort = new AbortController();
|
|
@@ -3459,6 +3167,13 @@ export function createServer(
|
|
|
3459
3167
|
if (!generationSignal.aborted)
|
|
3460
3168
|
controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`));
|
|
3461
3169
|
};
|
|
3170
|
+
const emitFrames = (frames: string[]) => {
|
|
3171
|
+
if (generationSignal.aborted) return;
|
|
3172
|
+
for (const frame of frames) controller.enqueue(enc.encode(frame));
|
|
3173
|
+
};
|
|
3174
|
+
let latestUsage: Readonly<CompletionSummary["usage"]> | null = null;
|
|
3175
|
+
let wroteFirstEvent = false;
|
|
3176
|
+
let traceOutcome: "success" | "error" | "abort" = "success";
|
|
3462
3177
|
const chunk = (delta: Record<string, unknown>, finish: string | null) => ({
|
|
3463
3178
|
id, object: "chat.completion.chunk", created, model: ctx.modelId,
|
|
3464
3179
|
choices: [{ index: 0, delta, finish_reason: finish }],
|
|
@@ -3468,92 +3183,65 @@ export function createServer(
|
|
|
3468
3183
|
// The gateway owns lane selection + GPU exclusivity; this body
|
|
3469
3184
|
// runs per-request (concurrently in batched mode, each writing
|
|
3470
3185
|
// its own SSE stream — the per-row fan-out).
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
const
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
const parts = thinking.push(text);
|
|
3492
|
-
if (parts.reasoning) send(chunk({ reasoning: parts.reasoning }, null));
|
|
3493
|
-
if (parts.content) send(chunk({ content: parts.content }, null));
|
|
3494
|
-
if (stopper.stopped) return false; // halt generation
|
|
3495
|
-
if (!batched && (parts.content || parts.reasoning || rReason)) {
|
|
3496
|
-
const now = performance.now();
|
|
3497
|
-
if (now - lastFlush >= 25) {
|
|
3498
|
-
lastFlush = now;
|
|
3499
|
-
return new Promise<void>((r) => setImmediate(r));
|
|
3186
|
+
if (streamProtocol) emitFrames(streamProtocol.start());
|
|
3187
|
+
else send(chunk({ role: "assistant", content: "" }, null));
|
|
3188
|
+
const sendEvents = (events: readonly CompletionEvent[]) => {
|
|
3189
|
+
if (events.length && !wroteFirstEvent) {
|
|
3190
|
+
wroteFirstEvent = true;
|
|
3191
|
+
trace?.mark("response.first_write");
|
|
3192
|
+
}
|
|
3193
|
+
if (streamProtocol) {
|
|
3194
|
+
emitFrames(streamProtocol.addEvents([...events]));
|
|
3195
|
+
return;
|
|
3196
|
+
}
|
|
3197
|
+
for (const event of events) {
|
|
3198
|
+
if (event.type === "reasoning") {
|
|
3199
|
+
send(chunk({ reasoning: event.text }, null));
|
|
3200
|
+
} else if (event.type === "content") {
|
|
3201
|
+
send(chunk({ content: event.text }, null));
|
|
3202
|
+
} else {
|
|
3203
|
+
send(chunk({
|
|
3204
|
+
tool_calls: event.calls.map((call, index) => ({ index, ...call })),
|
|
3205
|
+
}, null));
|
|
3500
3206
|
}
|
|
3501
3207
|
}
|
|
3502
|
-
}
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
if (rReason) send(chunk({ reasoning: rReason }, null));
|
|
3511
|
-
tail = stopper.push(flushed);
|
|
3512
|
-
if (!stopper.stopped) tail += stopper.flush();
|
|
3513
|
-
}
|
|
3514
|
-
if (tail) {
|
|
3515
|
-
const parts = thinking.push(tail);
|
|
3516
|
-
if (parts.reasoning) send(chunk({ reasoning: parts.reasoning }, null));
|
|
3517
|
-
if (parts.content) send(chunk({ content: parts.content }, null));
|
|
3518
|
-
}
|
|
3519
|
-
{
|
|
3520
|
-
const parts = thinking.flush();
|
|
3521
|
-
if (parts.reasoning) send(chunk({ reasoning: parts.reasoning }, null));
|
|
3522
|
-
if (parts.content) send(chunk({ content: parts.content }, null));
|
|
3523
|
-
}
|
|
3524
|
-
const toolCalls = router.toolCalls();
|
|
3525
|
-
if (toolCalls.length) {
|
|
3526
|
-
send(chunk({
|
|
3527
|
-
tool_calls: toolCalls.map((tc, i) => ({ index: i, ...tc })),
|
|
3528
|
-
}, null));
|
|
3529
|
-
}
|
|
3530
|
-
const finish = toolCalls.length
|
|
3531
|
-
? "tool_calls"
|
|
3532
|
-
: stopper.stopped ? "stop"
|
|
3533
|
-
: s.generatedTokens >= (options.maxTokens ?? 1024) ? "length" : "stop";
|
|
3534
|
-
// Refine serial vs serial+spec now that s.spec is known (a mounted
|
|
3535
|
-
// draft can still decode zero speculative tokens on a very short
|
|
3536
|
-
// reply) and re-record for pi-web's WS correlation.
|
|
3537
|
-
const finalLane: Lane = batched ? "batched" : s.spec ? "serial+spec" : lane;
|
|
3538
|
-
if (finalLane !== lane) recordLane(id, finalLane);
|
|
3539
|
-
send({
|
|
3540
|
-
...chunk({}, finish),
|
|
3541
|
-
usage: {
|
|
3542
|
-
prompt_tokens: s.promptTokens,
|
|
3543
|
-
completion_tokens: s.generatedTokens,
|
|
3544
|
-
total_tokens: s.promptTokens + s.generatedTokens,
|
|
3545
|
-
prompt_tokens_details: { cached_tokens: s.cachedTokens },
|
|
3546
|
-
lane: finalLane,
|
|
3547
|
-
...(s.spec ? { speculation: s.spec } : {}),
|
|
3548
|
-
},
|
|
3208
|
+
};
|
|
3209
|
+
const summary = await completionExecutor.execute(prepared, {
|
|
3210
|
+
signal: generationSignal,
|
|
3211
|
+
trace,
|
|
3212
|
+
onEvents: sendEvents,
|
|
3213
|
+
...(streamProtocol
|
|
3214
|
+
? { onUsageProgress: (usage) => { latestUsage = usage; } }
|
|
3215
|
+
: {}),
|
|
3549
3216
|
});
|
|
3550
|
-
|
|
3551
|
-
|
|
3217
|
+
const usage = completionUsage(summary);
|
|
3218
|
+
if (streamProtocol) {
|
|
3219
|
+
emitFrames(streamProtocol.finish(summary.finishReason, usage));
|
|
3220
|
+
} else {
|
|
3221
|
+
send({ ...chunk({}, summary.finishReason), usage });
|
|
3222
|
+
// bare sentinel per the OpenAI spec — strict SDK clients
|
|
3223
|
+
// require the unquoted terminator.
|
|
3552
3224
|
controller.enqueue(enc.encode("data: [DONE]\n\n"));
|
|
3225
|
+
}
|
|
3226
|
+
trace?.mark("response.final_write");
|
|
3553
3227
|
} catch (e) {
|
|
3554
|
-
|
|
3555
|
-
|
|
3228
|
+
traceOutcome = generationSignal.aborted ? "abort" : "error";
|
|
3229
|
+
if (!generationSignal.aborted) {
|
|
3230
|
+
const message = (e as Error).message;
|
|
3231
|
+
if (streamProtocol) {
|
|
3232
|
+
emitFrames([
|
|
3233
|
+
...streamProtocol.error(message),
|
|
3234
|
+
...streamProtocol.finish(
|
|
3235
|
+
"stop",
|
|
3236
|
+
latestUsage ? completionProtocolUsage(latestUsage) : {},
|
|
3237
|
+
),
|
|
3238
|
+
]);
|
|
3239
|
+
} else {
|
|
3240
|
+
send({ error: { message } });
|
|
3241
|
+
}
|
|
3242
|
+
}
|
|
3556
3243
|
} finally {
|
|
3244
|
+
trace?.finish(traceOutcome);
|
|
3557
3245
|
if (!cancelled) {
|
|
3558
3246
|
if (generationSignal.aborted) controller.error(generationSignal.reason);
|
|
3559
3247
|
else controller.close();
|
|
@@ -3578,85 +3266,53 @@ export function createServer(
|
|
|
3578
3266
|
|
|
3579
3267
|
try {
|
|
3580
3268
|
{
|
|
3581
|
-
const
|
|
3582
|
-
|
|
3583
|
-
const
|
|
3584
|
-
// mlx-lm collects logprobs across EVERY generated token (reasoning
|
|
3585
|
-
// and tool tokens included), not just visible content — same here.
|
|
3586
|
-
const lpc = captureLogprobs
|
|
3587
|
-
? new LogprobsCollector(wantLogprobs, topLogprobs, (tid) => ctx.tokenizer.idToToken(tid))
|
|
3588
|
-
: null;
|
|
3589
|
-
let content = "";
|
|
3590
|
-
let reasoning = "";
|
|
3591
|
-
const s = await gateway.run(promptIds, options, (token, lpInfo) => {
|
|
3592
|
-
lpc?.push(token, lpInfo);
|
|
3593
|
-
const rawContent = router.push(token);
|
|
3594
|
-
reasoning += router.takeReasoning(); // gemma-channel thinking
|
|
3595
|
-
const parts = thinking.push(stopper.push(rawContent));
|
|
3596
|
-
content += parts.content;
|
|
3597
|
-
reasoning += parts.reasoning;
|
|
3598
|
-
if (stopper.stopped) return false; // halt generation
|
|
3599
|
-
}, vision, shape, signal);
|
|
3600
|
-
if (!stopper.stopped) {
|
|
3601
|
-
const flushed = router.flush();
|
|
3602
|
-
reasoning += router.takeReasoning();
|
|
3603
|
-
let tail = stopper.push(flushed);
|
|
3604
|
-
if (!stopper.stopped) tail += stopper.flush();
|
|
3605
|
-
const parts = thinking.push(tail);
|
|
3606
|
-
content += parts.content;
|
|
3607
|
-
reasoning += parts.reasoning;
|
|
3608
|
-
}
|
|
3609
|
-
{
|
|
3610
|
-
const parts = thinking.flush();
|
|
3611
|
-
content += parts.content;
|
|
3612
|
-
reasoning += parts.reasoning;
|
|
3613
|
-
}
|
|
3614
|
-
const toolCalls = router.toolCalls();
|
|
3615
|
-
const finish = toolCalls.length
|
|
3616
|
-
? "tool_calls"
|
|
3617
|
-
: stopper.stopped ? "stop"
|
|
3618
|
-
: s.generatedTokens >= (options.maxTokens ?? 1024) ? "length" : "stop";
|
|
3619
|
-
const logprobsBlock = lpc?.payload() ?? null;
|
|
3620
|
-
// Refine serial vs serial+spec now that s.spec is known; re-record
|
|
3621
|
-
// for pi-web's WS correlation (see the streaming branch above).
|
|
3622
|
-
const finalLane: Lane = batched ? "batched" : s.spec ? "serial+spec" : lane;
|
|
3623
|
-
if (finalLane !== lane) recordLane(id, finalLane);
|
|
3624
|
-
return Response.json({
|
|
3269
|
+
const summary = await completionExecutor.execute(prepared, { signal, trace });
|
|
3270
|
+
trace?.mark("response.final_write");
|
|
3271
|
+
const response = Response.json({
|
|
3625
3272
|
id, object: "chat.completion", created, model: ctx.modelId,
|
|
3626
3273
|
choices: [{
|
|
3627
3274
|
index: 0,
|
|
3628
3275
|
message: {
|
|
3629
3276
|
role: "assistant",
|
|
3630
|
-
content: content || (toolCalls.length ? null : ""),
|
|
3631
|
-
...(reasoning ? { reasoning } : {}),
|
|
3632
|
-
...(toolCalls.length ? { tool_calls: toolCalls } : {}),
|
|
3277
|
+
content: summary.content || (summary.toolCalls.length ? null : ""),
|
|
3278
|
+
...(summary.reasoning ? { reasoning: summary.reasoning } : {}),
|
|
3279
|
+
...(summary.toolCalls.length ? { tool_calls: summary.toolCalls } : {}),
|
|
3633
3280
|
},
|
|
3634
|
-
...(
|
|
3635
|
-
finish_reason:
|
|
3281
|
+
...(summary.logprobs ? { logprobs: summary.logprobs } : {}),
|
|
3282
|
+
finish_reason: summary.finishReason,
|
|
3636
3283
|
}],
|
|
3637
|
-
usage:
|
|
3638
|
-
prompt_tokens: s.promptTokens,
|
|
3639
|
-
completion_tokens: s.generatedTokens,
|
|
3640
|
-
total_tokens: s.promptTokens + s.generatedTokens,
|
|
3641
|
-
prompt_tokens_details: { cached_tokens: s.cachedTokens },
|
|
3642
|
-
lane: finalLane,
|
|
3643
|
-
...(s.spec ? { speculation: s.spec } : {}),
|
|
3644
|
-
},
|
|
3284
|
+
usage: completionUsage(summary),
|
|
3645
3285
|
}, grammarWarning ? { headers: { Warning: grammarWarning } } : undefined);
|
|
3286
|
+
trace?.finish("success");
|
|
3287
|
+
return response;
|
|
3646
3288
|
}
|
|
3647
3289
|
} catch (e) {
|
|
3290
|
+
trace?.finish(signal.aborted ? "abort" : "error");
|
|
3291
|
+
// A 500 with no server-side trace is undebuggable in the field —
|
|
3292
|
+
// always log the stack (the JSON body keeps only the message).
|
|
3293
|
+
console.error(`[serve] 500 on chat request:\n${(e as Error).stack ?? e}`);
|
|
3648
3294
|
return Response.json({ error: { message: (e as Error).message } }, { status: 500 });
|
|
3649
3295
|
}
|
|
3650
3296
|
};
|
|
3651
3297
|
|
|
3652
3298
|
if (url.pathname === "/v1/chat/completions" && request.method === "POST") {
|
|
3299
|
+
const id = `chatcmpl-${crypto.randomUUID()}`;
|
|
3300
|
+
const trace = createPromptResponseTrace({
|
|
3301
|
+
traceId: request.headers.get("x-mlx-bun-trace-id") ?? id,
|
|
3302
|
+
requestId: id,
|
|
3303
|
+
route: url.pathname,
|
|
3304
|
+
});
|
|
3305
|
+
const closeBodyParse = trace?.begin("request.body_parse");
|
|
3653
3306
|
let body: ChatRequest;
|
|
3654
3307
|
try {
|
|
3655
3308
|
body = (await request.json()) as ChatRequest;
|
|
3656
3309
|
} catch {
|
|
3310
|
+
closeBodyParse?.();
|
|
3311
|
+
trace?.finish("error", { stage: "body_parse" });
|
|
3657
3312
|
return Response.json({ error: { message: "invalid JSON body" } }, { status: 400 });
|
|
3658
3313
|
}
|
|
3659
|
-
|
|
3314
|
+
closeBodyParse?.();
|
|
3315
|
+
return handleChat(body, request.signal, undefined, id, trace);
|
|
3660
3316
|
}
|
|
3661
3317
|
|
|
3662
3318
|
// Raw text completion (mlx_lm.server's /v1/completions, request_type
|
|
@@ -3666,12 +3322,23 @@ export function createServer(
|
|
|
3666
3322
|
// GenerationGateway + admission + adapter path as chat; no tool router
|
|
3667
3323
|
// or thinking splitter (raw text in, raw text out).
|
|
3668
3324
|
if (url.pathname === "/v1/completions" && request.method === "POST") {
|
|
3325
|
+
const id = `cmpl-${crypto.randomUUID()}`;
|
|
3326
|
+
const trace = createPromptResponseTrace({
|
|
3327
|
+
traceId: request.headers.get("x-mlx-bun-trace-id") ?? id,
|
|
3328
|
+
requestId: id,
|
|
3329
|
+
route: url.pathname,
|
|
3330
|
+
});
|
|
3331
|
+
const closeBodyParse = trace?.begin("request.body_parse");
|
|
3669
3332
|
let body: TextCompletionRequest;
|
|
3670
3333
|
try {
|
|
3671
3334
|
body = (await request.json()) as TextCompletionRequest;
|
|
3672
3335
|
} catch {
|
|
3336
|
+
closeBodyParse?.();
|
|
3337
|
+
trace?.finish("error", { stage: "body_parse" });
|
|
3673
3338
|
return Response.json({ error: { message: "invalid JSON body" } }, { status: 400 });
|
|
3674
3339
|
}
|
|
3340
|
+
closeBodyParse?.();
|
|
3341
|
+
const closePromptPrepare = trace?.begin("request.prompt_prepare");
|
|
3675
3342
|
if (typeof body.prompt !== "string" || body.prompt.length === 0)
|
|
3676
3343
|
return Response.json(
|
|
3677
3344
|
{
|
|
@@ -3686,9 +3353,9 @@ export function createServer(
|
|
|
3686
3353
|
const lpParamError = validateLogprobsParams(body);
|
|
3687
3354
|
if (lpParamError)
|
|
3688
3355
|
return Response.json({ error: { message: lpParamError } }, { status: 400 });
|
|
3689
|
-
const id = `cmpl-${crypto.randomUUID()}`;
|
|
3690
3356
|
const created = Math.floor(Date.now() / 1000);
|
|
3691
3357
|
const promptIds = ctx.tokenizer.encode(body.prompt);
|
|
3358
|
+
const ownership = new RequestOwnership();
|
|
3692
3359
|
let options: ReturnType<typeof toOptions>;
|
|
3693
3360
|
try {
|
|
3694
3361
|
options = toOptions(body as unknown as ChatRequest);
|
|
@@ -3708,85 +3375,64 @@ export function createServer(
|
|
|
3708
3375
|
body.structured_outputs != null;
|
|
3709
3376
|
if (textGrammarReq) {
|
|
3710
3377
|
const g = await compileGrammarForRequest(body as unknown as ChatRequest);
|
|
3711
|
-
if (g.controller) options.grammar = g.controller;
|
|
3378
|
+
if (g.controller) options.grammar = ownership.own(g.controller);
|
|
3712
3379
|
else if (g.degradeHint)
|
|
3713
3380
|
textGrammarWarning =
|
|
3714
3381
|
`grammar not enforced: ${g.degradeHint} - no prompt injection on /v1/completions`;
|
|
3715
3382
|
}
|
|
3716
|
-
// Mirrors the chat lane: a real controller (not the degrade path)
|
|
3717
|
-
// shapes the request for per-row grammar batching.
|
|
3718
|
-
const textGrammarCtrl = options.grammar ?? null;
|
|
3719
3383
|
// mlx_lm.server's default max_tokens is 512 (its --max-tokens CLI
|
|
3720
3384
|
// default). The chat lane's very generous default is wrong for raw
|
|
3721
3385
|
// completion: with no template an EOS may never come.
|
|
3722
|
-
|
|
3386
|
+
const requestedMaxTokens = body.max_completion_tokens ?? body.max_tokens ??
|
|
3723
3387
|
defaultGeneratedTokens ?? 512;
|
|
3724
|
-
|
|
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) {
|
|
3733
|
-
// Pre-run reject: the gateway only takes grammar disposal ownership
|
|
3734
|
-
// once run — free the WASM matcher here (chat lane: disposeRejected).
|
|
3735
|
-
textGrammarCtrl?.dispose();
|
|
3736
|
-
return Response.json(
|
|
3737
|
-
{
|
|
3738
|
-
error: {
|
|
3739
|
-
message:
|
|
3740
|
-
`request needs ${requiredCtx} tokens of context ` +
|
|
3741
|
-
`(prompt ${promptIds.length} + max_tokens ${options.maxTokens}) but the ` +
|
|
3742
|
-
`memory budget caps safe context at ${admission.maxSafeContext} — ` +
|
|
3743
|
-
`shorten the prompt or lower max_tokens`,
|
|
3744
|
-
type: "memory_admission",
|
|
3745
|
-
code: "context_over_budget",
|
|
3746
|
-
},
|
|
3747
|
-
},
|
|
3748
|
-
{ status: 400 },
|
|
3749
|
-
);
|
|
3750
|
-
}
|
|
3751
|
-
options.maxTokens = contextDecision.maxTokens;
|
|
3388
|
+
let adapterIds: string[];
|
|
3752
3389
|
try {
|
|
3753
|
-
|
|
3754
|
-
if (adapterIds.length) options.adapters = adapterIds;
|
|
3390
|
+
adapterIds = ctx.adapters.resolveSpec(body.adapter ?? serverOptions.defaultAdapter);
|
|
3755
3391
|
} catch (e) {
|
|
3756
|
-
|
|
3392
|
+
ownership.dispose();
|
|
3757
3393
|
return Response.json({ error: { message: (e as Error).message } }, { status: 400 });
|
|
3758
3394
|
}
|
|
3759
|
-
// logprobs capture — same mlx-lm block as chat (generate_response is
|
|
3760
|
-
// shared in the reference too); non-stream only, stream chunks never
|
|
3761
|
-
// carry logprobs.
|
|
3762
3395
|
const wantLogprobs = body.logprobs === true;
|
|
3763
3396
|
const topLogprobs =
|
|
3764
3397
|
typeof body.top_logprobs === "number" && body.top_logprobs > 0
|
|
3765
3398
|
? body.top_logprobs : 0;
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3399
|
+
let prepared: PreparedCompletion;
|
|
3400
|
+
try {
|
|
3401
|
+
prepared = prepareCompletion({
|
|
3402
|
+
requestId: id,
|
|
3403
|
+
plan: {
|
|
3404
|
+
promptIds,
|
|
3405
|
+
options,
|
|
3406
|
+
requestedMaxTokens,
|
|
3407
|
+
maxSafeContext: admission.maxSafeContext,
|
|
3408
|
+
stream: body.stream === true,
|
|
3409
|
+
wantLogprobs,
|
|
3410
|
+
topLogprobs,
|
|
3411
|
+
adapterIds,
|
|
3412
|
+
hasVision: false,
|
|
3413
|
+
userSeed: body.seed !== undefined,
|
|
3414
|
+
hasGrammar: !!options.grammar,
|
|
3415
|
+
hasDraft: !!ctx.draft,
|
|
3416
|
+
ownership,
|
|
3417
|
+
},
|
|
3418
|
+
pipeline: {
|
|
3419
|
+
router: new ToolAwareStream(ctx.tokenizer, "plain", null),
|
|
3420
|
+
stopper: new StopMatcher(options.stopSequences),
|
|
3421
|
+
thinking: new ThinkingTagSplitter(false),
|
|
3422
|
+
collectToolCalls: false,
|
|
3423
|
+
},
|
|
3424
|
+
...(body.stream
|
|
3425
|
+
? { createFlowControl: () => createTimedFlowControl(true) }
|
|
3426
|
+
: {}),
|
|
3427
|
+
idToToken: (tokenId) => ctx.tokenizer.idToToken(tokenId),
|
|
3428
|
+
});
|
|
3429
|
+
} catch (error) {
|
|
3430
|
+
closePromptPrepare?.();
|
|
3431
|
+
trace?.finish("error", { stage: "prepare_completion" });
|
|
3432
|
+
if (!(error instanceof CompletionRejected)) throw error;
|
|
3433
|
+
return Response.json({ error: error.error }, { status: error.status });
|
|
3770
3434
|
}
|
|
3771
|
-
|
|
3772
|
-
hasVision: false,
|
|
3773
|
-
hasAdapters: !!options.adapters?.length,
|
|
3774
|
-
hasRepetitionPenalty: !!options.repetitionPenalty,
|
|
3775
|
-
hasLogitsExtras: !!(
|
|
3776
|
-
options.minP || options.xtcProbability || options.logitBias ||
|
|
3777
|
-
options.presencePenalty || options.frequencyPenalty
|
|
3778
|
-
),
|
|
3779
|
-
wantsLogprobs: captureLogprobs,
|
|
3780
|
-
userSeed: body.seed !== undefined,
|
|
3781
|
-
kvQuant: !!(options.kvConfig?.length || options.kvBits),
|
|
3782
|
-
turboQuant: !!options.turboQuant,
|
|
3783
|
-
// /v1/completions grammar (textGrammarCtrl). Same null-on-degrade
|
|
3784
|
-
// contract as the chat lane.
|
|
3785
|
-
hasGrammar: !!textGrammarCtrl,
|
|
3786
|
-
hasDraft: !!ctx.draft,
|
|
3787
|
-
};
|
|
3788
|
-
const finishReason = (stopped: boolean, generated: number): "stop" | "length" =>
|
|
3789
|
-
stopped ? "stop" : generated >= (options.maxTokens ?? 512) ? "length" : "stop";
|
|
3435
|
+
closePromptPrepare?.();
|
|
3790
3436
|
|
|
3791
3437
|
if (body.stream) {
|
|
3792
3438
|
const streamAbort = new AbortController();
|
|
@@ -3803,49 +3449,39 @@ export function createServer(
|
|
|
3803
3449
|
id, object: "text_completion", created, model: ctx.modelId,
|
|
3804
3450
|
choices: [{ index: 0, text, finish_reason: finish }],
|
|
3805
3451
|
});
|
|
3452
|
+
let wroteFirstEvent = false;
|
|
3453
|
+
let traceOutcome: "success" | "error" | "abort" = "success";
|
|
3806
3454
|
void (async () => {
|
|
3807
3455
|
try {
|
|
3808
|
-
const
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
let lastFlush = performance.now();
|
|
3813
|
-
const s = await gateway.run(promptIds, options, (token) => {
|
|
3814
|
-
const text = stopper.push(decoder.push(token));
|
|
3815
|
-
if (text) send(chunk(text, null));
|
|
3816
|
-
if (stopper.stopped) return false; // halt generation
|
|
3817
|
-
if (text) {
|
|
3818
|
-
const now = performance.now();
|
|
3819
|
-
if (now - lastFlush >= 25) {
|
|
3820
|
-
lastFlush = now;
|
|
3821
|
-
return new Promise<void>((r) => setImmediate(r));
|
|
3822
|
-
}
|
|
3456
|
+
const sendEvents = (events: readonly CompletionEvent[]) => {
|
|
3457
|
+
if (events.length && !wroteFirstEvent) {
|
|
3458
|
+
wroteFirstEvent = true;
|
|
3459
|
+
trace?.mark("response.first_write");
|
|
3823
3460
|
}
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3461
|
+
for (const event of events) {
|
|
3462
|
+
if (event.type === "content") send(chunk(event.text, null));
|
|
3463
|
+
}
|
|
3464
|
+
};
|
|
3465
|
+
const summary = await completionExecutor.execute(prepared, {
|
|
3466
|
+
signal: generationSignal,
|
|
3467
|
+
trace,
|
|
3468
|
+
onEvents: sendEvents,
|
|
3469
|
+
});
|
|
3831
3470
|
// final chunk: finish_reason + usage (mlx-lm gates usage behind
|
|
3832
3471
|
// stream_options.include_usage; we always attach it, matching
|
|
3833
3472
|
// our chat lane — an additive superset OpenAI clients ignore)
|
|
3834
3473
|
send({
|
|
3835
|
-
...chunk("", finishReason
|
|
3836
|
-
usage:
|
|
3837
|
-
prompt_tokens: s.promptTokens,
|
|
3838
|
-
completion_tokens: s.generatedTokens,
|
|
3839
|
-
total_tokens: s.promptTokens + s.generatedTokens,
|
|
3840
|
-
prompt_tokens_details: { cached_tokens: s.cachedTokens },
|
|
3841
|
-
...(s.spec ? { speculation: s.spec } : {}),
|
|
3842
|
-
},
|
|
3474
|
+
...chunk("", summary.finishReason),
|
|
3475
|
+
usage: completionUsage(summary),
|
|
3843
3476
|
});
|
|
3844
3477
|
controller.enqueue(enc.encode("data: [DONE]\n\n"));
|
|
3478
|
+
trace?.mark("response.final_write");
|
|
3845
3479
|
} catch (e) {
|
|
3480
|
+
traceOutcome = generationSignal.aborted ? "abort" : "error";
|
|
3846
3481
|
if (!generationSignal.aborted)
|
|
3847
3482
|
send({ error: { message: (e as Error).message } });
|
|
3848
3483
|
} finally {
|
|
3484
|
+
trace?.finish(traceOutcome);
|
|
3849
3485
|
if (!cancelled) {
|
|
3850
3486
|
if (generationSignal.aborted) controller.error(generationSignal.reason);
|
|
3851
3487
|
else controller.close();
|
|
@@ -3869,39 +3505,24 @@ export function createServer(
|
|
|
3869
3505
|
}
|
|
3870
3506
|
|
|
3871
3507
|
try {
|
|
3872
|
-
const
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
const s = await gateway.run(promptIds, options, (token, lpInfo) => {
|
|
3879
|
-
lpc?.push(token, lpInfo);
|
|
3880
|
-
text += stopper.push(decoder.push(token));
|
|
3881
|
-
if (stopper.stopped) return false; // halt generation
|
|
3882
|
-
}, undefined, shape, request.signal);
|
|
3883
|
-
if (!stopper.stopped) {
|
|
3884
|
-
let tail = stopper.push(decoder.flush());
|
|
3885
|
-
if (!stopper.stopped) tail += stopper.flush();
|
|
3886
|
-
text += tail;
|
|
3887
|
-
}
|
|
3888
|
-
const logprobsBlock = lpc?.payload() ?? null;
|
|
3889
|
-
return Response.json({
|
|
3508
|
+
const summary = await completionExecutor.execute(prepared, {
|
|
3509
|
+
signal: request.signal,
|
|
3510
|
+
trace,
|
|
3511
|
+
});
|
|
3512
|
+
trace?.mark("response.final_write");
|
|
3513
|
+
const response = Response.json({
|
|
3890
3514
|
id, object: "text_completion", created, model: ctx.modelId,
|
|
3891
3515
|
choices: [{
|
|
3892
|
-
index: 0, text,
|
|
3893
|
-
...(
|
|
3894
|
-
finish_reason: finishReason
|
|
3516
|
+
index: 0, text: summary.content,
|
|
3517
|
+
...(summary.logprobs ? { logprobs: summary.logprobs } : {}),
|
|
3518
|
+
finish_reason: summary.finishReason,
|
|
3895
3519
|
}],
|
|
3896
|
-
usage:
|
|
3897
|
-
prompt_tokens: s.promptTokens,
|
|
3898
|
-
completion_tokens: s.generatedTokens,
|
|
3899
|
-
total_tokens: s.promptTokens + s.generatedTokens,
|
|
3900
|
-
prompt_tokens_details: { cached_tokens: s.cachedTokens },
|
|
3901
|
-
...(s.spec ? { speculation: s.spec } : {}),
|
|
3902
|
-
},
|
|
3520
|
+
usage: completionUsage(summary),
|
|
3903
3521
|
}, textGrammarWarning ? { headers: { Warning: textGrammarWarning } } : undefined);
|
|
3522
|
+
trace?.finish("success");
|
|
3523
|
+
return response;
|
|
3904
3524
|
} catch (e) {
|
|
3525
|
+
trace?.finish(request.signal.aborted ? "abort" : "error");
|
|
3905
3526
|
return Response.json({ error: { message: (e as Error).message } }, { status: 500 });
|
|
3906
3527
|
}
|
|
3907
3528
|
}
|
|
@@ -3926,7 +3547,13 @@ export function createServer(
|
|
|
3926
3547
|
} catch (e) {
|
|
3927
3548
|
return anthropicError(400, "invalid_request_error", (e as Error).message);
|
|
3928
3549
|
}
|
|
3929
|
-
const resp = await handleChat(
|
|
3550
|
+
const resp = await handleChat(
|
|
3551
|
+
chatBody,
|
|
3552
|
+
request.signal,
|
|
3553
|
+
anthropicBody.stream
|
|
3554
|
+
? createAnthropicStreamProtocol(ctx.modelId)
|
|
3555
|
+
: undefined,
|
|
3556
|
+
);
|
|
3930
3557
|
if (!resp.ok) {
|
|
3931
3558
|
const err = (await resp.json().catch(() => null)) as
|
|
3932
3559
|
| { error?: { message?: string } }
|
|
@@ -3938,13 +3565,7 @@ export function createServer(
|
|
|
3938
3565
|
);
|
|
3939
3566
|
}
|
|
3940
3567
|
if (anthropicBody.stream) {
|
|
3941
|
-
return
|
|
3942
|
-
headers: {
|
|
3943
|
-
"content-type": "text/event-stream",
|
|
3944
|
-
"cache-control": "no-cache",
|
|
3945
|
-
connection: "keep-alive",
|
|
3946
|
-
},
|
|
3947
|
-
});
|
|
3568
|
+
return resp;
|
|
3948
3569
|
}
|
|
3949
3570
|
return Response.json(chatJsonToAnthropic(await resp.json(), ctx.modelId));
|
|
3950
3571
|
}
|
|
@@ -4007,7 +3628,22 @@ export function createServer(
|
|
|
4007
3628
|
} catch (e) {
|
|
4008
3629
|
return responsesError(400, (e as Error).message);
|
|
4009
3630
|
}
|
|
4010
|
-
const resp = await handleChat(
|
|
3631
|
+
const resp = await handleChat(
|
|
3632
|
+
chatBody,
|
|
3633
|
+
request.signal,
|
|
3634
|
+
responsesBody.stream
|
|
3635
|
+
? createResponsesStreamProtocol(
|
|
3636
|
+
ctx.modelId,
|
|
3637
|
+
prevId,
|
|
3638
|
+
(final) =>
|
|
3639
|
+
responseStore.put(final.id as string, {
|
|
3640
|
+
input: capturedInput,
|
|
3641
|
+
output: final.output as unknown[],
|
|
3642
|
+
instructions: capturedInstructions,
|
|
3643
|
+
}),
|
|
3644
|
+
)
|
|
3645
|
+
: undefined,
|
|
3646
|
+
);
|
|
4011
3647
|
if (!resp.ok) {
|
|
4012
3648
|
const err = (await resp.json().catch(() => null)) as
|
|
4013
3649
|
| { error?: { message?: string } }
|
|
@@ -4015,22 +3651,7 @@ export function createServer(
|
|
|
4015
3651
|
return responsesError(resp.status, err?.error?.message ?? "request failed");
|
|
4016
3652
|
}
|
|
4017
3653
|
if (responsesBody.stream) {
|
|
4018
|
-
|
|
4019
|
-
resp.body!, ctx.modelId, prevId,
|
|
4020
|
-
(final) =>
|
|
4021
|
-
responseStore.put(final.id as string, {
|
|
4022
|
-
input: capturedInput,
|
|
4023
|
-
output: final.output as unknown[],
|
|
4024
|
-
instructions: capturedInstructions,
|
|
4025
|
-
}),
|
|
4026
|
-
);
|
|
4027
|
-
return new Response(body, {
|
|
4028
|
-
headers: {
|
|
4029
|
-
"content-type": "text/event-stream",
|
|
4030
|
-
"cache-control": "no-cache",
|
|
4031
|
-
connection: "keep-alive",
|
|
4032
|
-
},
|
|
4033
|
-
});
|
|
3654
|
+
return resp;
|
|
4034
3655
|
}
|
|
4035
3656
|
const responses = chatJsonToResponses(await resp.json(), ctx.modelId, prevId);
|
|
4036
3657
|
responseStore.put(responses.id as string, {
|
|
@@ -4041,346 +3662,29 @@ export function createServer(
|
|
|
4041
3662
|
return Response.json(responses);
|
|
4042
3663
|
}
|
|
4043
3664
|
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
}
|
|
4049
|
-
if (
|
|
4050
|
-
const body = (await request.json().catch(() => ({}))) as {
|
|
4051
|
-
template_id?: string; inputs?: Record<string, unknown>; model_name?: string;
|
|
4052
|
-
};
|
|
4053
|
-
const { getTemplate } = await import("./dataset");
|
|
4054
|
-
if (!body.template_id || !getTemplate(body.template_id))
|
|
4055
|
-
return Response.json({ ok: false, error: `unknown template ${body.template_id}` }, { status: 400 });
|
|
4056
|
-
const store = await ensureJobs();
|
|
4057
|
-
const { submitInProcess } = await import("./jobs");
|
|
4058
|
-
const { homedir } = await import("node:os");
|
|
4059
|
-
const safe = body.template_id.replace(/[^a-z0-9_-]/gi, "");
|
|
4060
|
-
const outDir = `${homedir()}/.cache/mlx-bun/datasets/dataset-${safe}-${Date.now()}`;
|
|
4061
|
-
const { jobId } = submitInProcess(store, "dataset", {
|
|
4062
|
-
template_id: body.template_id, inputs: body.inputs ?? {},
|
|
4063
|
-
output_dir: outDir, api_url: `http://127.0.0.1:${server.port}`,
|
|
4064
|
-
model_name: body.model_name ?? "local",
|
|
4065
|
-
}, outDir);
|
|
4066
|
-
return Response.json({ ok: true, job_id: jobId, output_dir: outDir });
|
|
4067
|
-
}
|
|
4068
|
-
|
|
4069
|
-
if (url.pathname === "/api/quantize/inspect" && request.method === "POST") {
|
|
4070
|
-
const body = (await request.json().catch(() => ({}))) as { model_id?: string };
|
|
4071
|
-
const { inspectModel } = await import("./quantize");
|
|
4072
|
-
return Response.json(await inspectModel(body.model_id ?? ""));
|
|
4073
|
-
}
|
|
4074
|
-
// Turn an OS-picked folder into the absolute snapshot path on disk. The
|
|
4075
|
-
// browser can't reveal a filesystem path (security) and the HF cache
|
|
4076
|
-
// stores real bytes in blobs/ with symlinked snapshots — so we resolve by
|
|
4077
|
-
// the folder's NAME (which encodes the repo id), never by reading files.
|
|
4078
|
-
// No upload, no dependence on the cache's symlink layout, and a
|
|
4079
|
-
// just-downloaded model resolves before it's ever been indexed.
|
|
4080
|
-
if ((url.pathname === "/api/quantize/resolve-folder" || url.pathname === "/api/model/resolve-folder") && request.method === "POST") {
|
|
4081
|
-
const body = (await request.json().catch(() => ({}))) as { folder_name?: string; rel_path?: string };
|
|
4082
|
-
const { statSync, readdirSync, readFileSync } = await import("node:fs");
|
|
4083
|
-
const { join } = await import("node:path");
|
|
4084
|
-
const { homedir } = await import("node:os");
|
|
4085
|
-
const hubRoot = process.env.HF_HUB_CACHE
|
|
4086
|
-
?? (process.env.HF_HOME ? join(process.env.HF_HOME, "hub") : join(homedir(), ".cache/huggingface/hub"));
|
|
4087
|
-
const roots = [hubRoot, join(homedir(), ".cache/mlx-bun")];
|
|
4088
|
-
const hasConfig = (d: string) => { try { return statSync(join(d, "config.json")).isFile(); } catch { return false; } };
|
|
4089
|
-
// basename of the picked folder; the rel path's last-but-one segment is
|
|
4090
|
-
// the <hash> dir if they drilled into a snapshot.
|
|
4091
|
-
const folder = (body.folder_name ?? "").replace(/\\/g, "/").split("/").filter(Boolean).pop() ?? "";
|
|
4092
|
-
const relSegs = (body.rel_path ?? "").replace(/\\/g, "/").split("/").filter(Boolean);
|
|
4093
|
-
const configDir = relSegs.length >= 2 ? relSegs[relSegs.length - 2] : "";
|
|
4094
|
-
const repoIdOf = (modelsDir: string) => modelsDir.slice("models--".length).replaceAll("--", "/");
|
|
4095
|
-
// Pick a repo dir's canonical snapshot: the one refs/main points at, else
|
|
4096
|
-
// the first snapshot that actually has a config.json.
|
|
4097
|
-
const pickSnapshot = (repoDir: string): string | null => {
|
|
4098
|
-
const snaps = join(repoDir, "snapshots");
|
|
4099
|
-
let head = "";
|
|
4100
|
-
try { head = readFileSync(join(repoDir, "refs", "main"), "utf8").trim(); } catch {}
|
|
4101
|
-
if (head && hasConfig(join(snaps, head))) return join(snaps, head);
|
|
4102
|
-
try { for (const h of readdirSync(snaps)) if (hasConfig(join(snaps, h))) return join(snaps, h); } catch {}
|
|
4103
|
-
return null;
|
|
4104
|
-
};
|
|
4105
|
-
|
|
4106
|
-
// (1) HF-cache folder picked — its name IS the repo id.
|
|
4107
|
-
if (folder.startsWith("models--")) {
|
|
4108
|
-
const p = pickSnapshot(join(hubRoot, folder));
|
|
4109
|
-
if (p) return Response.json({ ok: true, path: p, repo_id: repoIdOf(folder) });
|
|
4110
|
-
}
|
|
4111
|
-
// (2) A bare <hash> snapshot folder picked directly (or carried in rel).
|
|
4112
|
-
const hashDir = configDir || folder;
|
|
4113
|
-
if (hashDir) {
|
|
4114
|
-
try {
|
|
4115
|
-
for (const repo of readdirSync(hubRoot)) {
|
|
4116
|
-
if (!repo.startsWith("models--")) continue;
|
|
4117
|
-
const cand = join(hubRoot, repo, "snapshots", hashDir);
|
|
4118
|
-
if (hasConfig(cand)) return Response.json({ ok: true, path: cand, repo_id: repoIdOf(repo) });
|
|
4119
|
-
}
|
|
4120
|
-
} catch {}
|
|
4121
|
-
}
|
|
4122
|
-
// (3) Flat local snapshot under a known root (folder name == model dir).
|
|
4123
|
-
for (const root of roots) {
|
|
4124
|
-
if (folder && hasConfig(join(root, folder))) return Response.json({ ok: true, path: join(root, folder) });
|
|
4125
|
-
}
|
|
4126
|
-
// (4) Registry rescan — last resort (also covers customized cache layouts).
|
|
4127
|
-
const { Registry } = await import("./registry");
|
|
4128
|
-
const reg = new Registry();
|
|
4129
|
-
try {
|
|
4130
|
-
await reg.scan();
|
|
4131
|
-
const all = reg.list();
|
|
4132
|
-
const rec = (folder.startsWith("models--")
|
|
4133
|
-
? all.find((m) => m.repoId === repoIdOf(folder))
|
|
4134
|
-
: undefined)
|
|
4135
|
-
?? all.find((m) => m.path.split("/").pop() === hashDir)
|
|
4136
|
-
?? all.find((m) => m.repoId.split("/").pop() === folder);
|
|
4137
|
-
if (rec) return Response.json({ ok: true, path: rec.path, repo_id: rec.repoId, model_type: rec.modelType });
|
|
4138
|
-
} finally {
|
|
4139
|
-
reg.close();
|
|
4140
|
-
}
|
|
4141
|
-
return Response.json({
|
|
4142
|
-
ok: false,
|
|
4143
|
-
error: "Couldn't locate this folder on disk — paste the path instead.",
|
|
4144
|
-
});
|
|
4145
|
-
}
|
|
4146
|
-
if (url.pathname === "/api/quantize/submit" && request.method === "POST") {
|
|
4147
|
-
const body = (await request.json().catch(() => ({}))) as {
|
|
4148
|
-
model_id?: string; bits?: number; group_size?: number;
|
|
4149
|
-
target_bpw?: number; candidate_bits?: number[]; reference?: string;
|
|
4150
|
-
calibration_mix?: string; n_calibration?: number;
|
|
4151
|
-
};
|
|
4152
|
-
if (!body.model_id)
|
|
4153
|
-
return Response.json({ ok: false, error: "model_id required" }, { status: 400 });
|
|
4154
|
-
const store = await ensureJobs();
|
|
4155
|
-
const { submitSubprocess } = await import("./jobs");
|
|
4156
|
-
const { homedir } = await import("node:os");
|
|
4157
|
-
const { join } = await import("node:path");
|
|
4158
|
-
const { mkdirSync, writeFileSync } = await import("node:fs");
|
|
4159
|
-
const { createHash } = await import("node:crypto");
|
|
4160
|
-
const bits = body.bits ?? 4, gs = body.group_size ?? 64;
|
|
4161
|
-
// Derive org/name from the source so the quant is named readably (the
|
|
4162
|
-
// source is usually an HF-cache snapshot path whose basename is a hash).
|
|
4163
|
-
const snapMatch = body.model_id.match(/(models--[^/]+)\/snapshots\//);
|
|
4164
|
-
let org = "local", name: string;
|
|
4165
|
-
if (snapMatch) {
|
|
4166
|
-
const parts = snapMatch[1]!.split("--"); // ["models", org, ...name]
|
|
4167
|
-
org = parts[1] ?? "local";
|
|
4168
|
-
name = parts.slice(2).join("--");
|
|
4169
|
-
} else if (body.model_id.includes("/") && !body.model_id.startsWith("/") && !body.model_id.startsWith("~")) {
|
|
4170
|
-
const seg = body.model_id.split("/"); // a repo id "org/name"
|
|
4171
|
-
org = seg[0]!; name = seg.slice(1).join("-");
|
|
4172
|
-
} else {
|
|
4173
|
-
name = body.model_id.split("/").filter(Boolean).at(-1) ?? "model"; // a bare path
|
|
4174
|
-
}
|
|
4175
|
-
name = (name || "model").replace(/[^a-z0-9_.-]/gi, "");
|
|
4176
|
-
org = (org || "local").replace(/[^a-z0-9_.-]/gi, "");
|
|
4177
|
-
const suffix = body.target_bpw ? `mixed-${body.target_bpw}bpw` : `${bits}bit`;
|
|
4178
|
-
// Write the quant INTO the HF hub cache as a normal models--org--name/
|
|
4179
|
-
// snapshots/<hash> entry, so the standard registry scan + every other
|
|
4180
|
-
// tool discovers it alongside downloaded models. refs/main makes it a
|
|
4181
|
-
// well-formed cache entry.
|
|
4182
|
-
const quantRepo = `${name}-OptiQ-${suffix}`;
|
|
4183
|
-
const hubRoot = process.env.HF_HUB_CACHE
|
|
4184
|
-
?? (process.env.HF_HOME ? join(process.env.HF_HOME, "hub") : join(homedir(), ".cache/huggingface/hub"));
|
|
4185
|
-
const repoDir = join(hubRoot, `models--${org}--${quantRepo}`);
|
|
4186
|
-
const snapHash = createHash("sha1").update(`${org}/${quantRepo}`).digest("hex");
|
|
4187
|
-
const outDir = join(repoDir, "snapshots", snapHash);
|
|
4188
|
-
try {
|
|
4189
|
-
mkdirSync(join(repoDir, "refs"), { recursive: true });
|
|
4190
|
-
writeFileSync(join(repoDir, "refs", "main"), snapHash);
|
|
4191
|
-
} catch {}
|
|
4192
|
-
const { jobId } = submitSubprocess(store, "quantize", {
|
|
4193
|
-
model_id: body.model_id, out_dir: outDir, bits, group_size: gs,
|
|
4194
|
-
// forwarded to the mixed-precision path when target_bpw is set
|
|
4195
|
-
target_bpw: body.target_bpw, candidate_bits: body.candidate_bits,
|
|
4196
|
-
reference: body.reference, calibration_mix: body.calibration_mix,
|
|
4197
|
-
n_calibration: body.n_calibration,
|
|
4198
|
-
}, outDir, {
|
|
4199
|
-
// The model is in the HF cache now; drop the Library cache so the next
|
|
4200
|
-
// poll rescans and shows it — no `mlx-bun scan`, no restart.
|
|
4201
|
-
onComplete: () => { libraryCache = null; },
|
|
4202
|
-
});
|
|
4203
|
-
return Response.json({ ok: true, job_id: jobId, output_dir: outDir });
|
|
4204
|
-
}
|
|
4205
|
-
|
|
4206
|
-
if (url.pathname === "/api/finetune/inspect-dataset" && request.method === "POST") {
|
|
4207
|
-
const body = (await request.json().catch(() => ({}))) as { path?: string };
|
|
4208
|
-
const { inspectDataset } = await import("./train");
|
|
4209
|
-
return Response.json(await inspectDataset(body.path ?? ""));
|
|
4210
|
-
}
|
|
4211
|
-
if (url.pathname === "/api/finetune/submit" && request.method === "POST") {
|
|
4212
|
-
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
|
4213
|
-
if (!body.model_dir || !body.data_dir)
|
|
4214
|
-
return Response.json({ ok: false, error: "model_dir and data_dir required" }, { status: 400 });
|
|
4215
|
-
const store = await ensureJobs();
|
|
4216
|
-
const { submitSubprocess } = await import("./jobs");
|
|
4217
|
-
const { homedir } = await import("node:os");
|
|
4218
|
-
const adapterPath = (body.adapter_path as string) ||
|
|
4219
|
-
`${homedir()}/.cache/mlx-bun/adapters/adapter-${Date.now()}`;
|
|
4220
|
-
const { jobId } = submitSubprocess(store, "finetune",
|
|
4221
|
-
{ ...body, adapter_path: adapterPath }, adapterPath);
|
|
4222
|
-
return Response.json({ ok: true, job_id: jobId, adapter_path: adapterPath });
|
|
4223
|
-
}
|
|
4224
|
-
if (url.pathname === "/api/finetune/merge" && request.method === "POST") {
|
|
4225
|
-
const body = (await request.json().catch(() => ({}))) as {
|
|
4226
|
-
adapter_a?: string; adapter_b?: string; scales?: number[];
|
|
4227
|
-
};
|
|
4228
|
-
if (!body.adapter_a || !body.adapter_b)
|
|
4229
|
-
return Response.json({ ok: false, error: "adapter_a and adapter_b required" }, { status: 400 });
|
|
4230
|
-
try {
|
|
4231
|
-
const { mergeAdapters } = await import("./train");
|
|
4232
|
-
const { homedir } = await import("node:os");
|
|
4233
|
-
const mergedPath = `${homedir()}/.cache/mlx-bun/adapters/merged-${Date.now()}`;
|
|
4234
|
-
const stats = await mergeAdapters([body.adapter_a, body.adapter_b], mergedPath, body.scales);
|
|
4235
|
-
return Response.json({ ok: true, merged_path: mergedPath, stats });
|
|
4236
|
-
} catch (e) {
|
|
4237
|
-
return Response.json({ ok: false, error: (e as Error).message }, { status: 400 });
|
|
4238
|
-
}
|
|
4239
|
-
}
|
|
4240
|
-
if (url.pathname === "/api/finetune/export" && request.method === "POST") {
|
|
4241
|
-
const body = (await request.json().catch(() => ({}))) as {
|
|
4242
|
-
base_model?: string; adapter_path?: string; method?: string;
|
|
4243
|
-
};
|
|
4244
|
-
if (!body.base_model || !body.adapter_path)
|
|
4245
|
-
return Response.json({ ok: false, error: "base_model and adapter_path required" }, { status: 400 });
|
|
4246
|
-
try {
|
|
4247
|
-
const { exportAdapter } = await import("./train");
|
|
4248
|
-
const { homedir } = await import("node:os");
|
|
4249
|
-
const exportPath = `${homedir()}/.cache/mlx-bun/exports/export-${Date.now()}`;
|
|
4250
|
-
const manifest = await exportAdapter(exportPath, body.base_model, body.adapter_path, body.method);
|
|
4251
|
-
return Response.json({ ok: true, export_path: exportPath, manifest });
|
|
4252
|
-
} catch (e) {
|
|
4253
|
-
return Response.json({ ok: false, error: (e as Error).message }, { status: 400 });
|
|
4254
|
-
}
|
|
4255
|
-
}
|
|
4256
|
-
|
|
4257
|
-
// --- HF token settings + push-to-hub (model & dataset repos) ------
|
|
4258
|
-
if (url.pathname === "/api/settings/hf-token" && request.method === "GET") {
|
|
4259
|
-
const { hasHfToken } = await import("./hf-push");
|
|
4260
|
-
return Response.json({ ok: true, hasToken: hasHfToken() });
|
|
4261
|
-
}
|
|
4262
|
-
if (url.pathname === "/api/settings/hf-token" && request.method === "POST") {
|
|
4263
|
-
const body = (await request.json().catch(() => ({}))) as { token?: string };
|
|
4264
|
-
if (!body.token) return Response.json({ ok: false, error: "token required" }, { status: 400 });
|
|
4265
|
-
const { saveHfToken } = await import("./hf-push");
|
|
4266
|
-
saveHfToken(body.token);
|
|
4267
|
-
return Response.json({ ok: true });
|
|
4268
|
-
}
|
|
4269
|
-
|
|
4270
|
-
// --- Durable tool-approvals settings (plan §5.4/§6.5/§9 Phase 2) ---
|
|
4271
|
-
// The web chat's own "always allow this tool" list
|
|
4272
|
-
// (~/.mlx-bun/tool-approvals.json, src/tool-approvals.ts). The
|
|
4273
|
-
// approval card itself grants an always-allow via the `approval` WS
|
|
4274
|
-
// frame (alwaysAllow:true) — these REST routes are read/forget only,
|
|
4275
|
-
// for the settings panel's list view (matches the hf-token GET/POST
|
|
4276
|
-
// pattern's separation of "the card that grants" vs "settings that
|
|
4277
|
-
// manage" — there is no POST here on purpose, granting always goes
|
|
4278
|
-
// through the approval card, never a bare settings form).
|
|
4279
|
-
if (url.pathname === "/api/settings/tool-approvals" && request.method === "GET") {
|
|
4280
|
-
const { listAlwaysAllowedTools } = await import("./tool-approvals");
|
|
4281
|
-
return Response.json({ ok: true, alwaysAllow: listAlwaysAllowedTools() });
|
|
4282
|
-
}
|
|
4283
|
-
if (url.pathname === "/api/settings/tool-approvals" && request.method === "DELETE") {
|
|
4284
|
-
const body = (await request.json().catch(() => ({}))) as { tool?: string };
|
|
4285
|
-
if (!body.tool) return Response.json({ ok: false, error: "tool required" }, { status: 400 });
|
|
4286
|
-
const { revokeToolAlwaysAllowed } = await import("./tool-approvals");
|
|
4287
|
-
const file = revokeToolAlwaysAllowed(body.tool);
|
|
4288
|
-
return Response.json({ ok: true, alwaysAllow: Object.keys(file.allows).sort() });
|
|
4289
|
-
}
|
|
4290
|
-
{
|
|
4291
|
-
const m = url.pathname.match(/^\/api\/(quantize|finetune|dataset)\/push$/);
|
|
4292
|
-
if (m && request.method === "POST") {
|
|
4293
|
-
const kind = m[1]!;
|
|
4294
|
-
const body = (await request.json().catch(() => ({}))) as {
|
|
4295
|
-
job_id?: string; repo_id?: string; private?: boolean; source_path?: string;
|
|
4296
|
-
};
|
|
4297
|
-
if (!body.repo_id) return Response.json({ ok: false, error: "repo_id required" }, { status: 400 });
|
|
4298
|
-
const { getHfToken, uploadFolder } = await import("./hf-push");
|
|
4299
|
-
const token = getHfToken();
|
|
4300
|
-
if (!token)
|
|
4301
|
-
return Response.json({ ok: false, error: "no HF token saved — add one in Settings → Hugging Face" }, { status: 400 });
|
|
4302
|
-
const store = await ensureJobs();
|
|
4303
|
-
let dir = body.source_path;
|
|
4304
|
-
if (!dir && body.job_id) dir = store.get(body.job_id)?.output_path ?? undefined;
|
|
4305
|
-
if (!dir) return Response.json({ ok: false, error: "no source dir (pass job_id or source_path)" }, { status: 400 });
|
|
4306
|
-
try {
|
|
4307
|
-
const r = await uploadFolder(dir, body.repo_id, {
|
|
4308
|
-
repoType: kind === "dataset" ? "dataset" : "model",
|
|
4309
|
-
private: !!body.private, token,
|
|
4310
|
-
});
|
|
4311
|
-
return Response.json({ ok: true, url: r.url });
|
|
4312
|
-
} catch (e) {
|
|
4313
|
-
return Response.json({ ok: false, error: (e as Error).message }, { status: 400 });
|
|
4314
|
-
}
|
|
4315
|
-
}
|
|
4316
|
-
}
|
|
4317
|
-
|
|
4318
|
-
// gc plan/execute (web-ui-pass-plan.md #9): thin wrappers over the CLI's
|
|
4319
|
-
// own `mlx-bun gc` planner/executor (src/registry.ts planGc/executeGc).
|
|
4320
|
-
// Loopback-served admin route — no auth beyond the server's own bind
|
|
4321
|
-
// (matches every other /api/* route here). GET plans (read-only, cheap:
|
|
4322
|
-
// config.json + safetensors index reads, no tensor bytes); POST executes
|
|
4323
|
-
// and requires an explicit {yes:true} body (mirrors the CLI's --yes gate)
|
|
4324
|
-
// so a stray call can't delete anything by accident.
|
|
4325
|
-
if (url.pathname === "/api/gc/plan" && request.method === "GET") {
|
|
4326
|
-
const { planGc } = await import("./registry");
|
|
4327
|
-
const plans = planGc().filter(
|
|
4328
|
-
(p) => p.pruneSnapshots.length || p.skippedSnapshots.length || p.deadBlobs.length,
|
|
4329
|
-
);
|
|
4330
|
-
const superseded = plans.map((p) => ({
|
|
4331
|
-
repo_id: p.repoId,
|
|
4332
|
-
prune_snapshots: p.pruneSnapshots.length,
|
|
4333
|
-
skipped_snapshots: p.skippedSnapshots.length,
|
|
4334
|
-
dead_blobs: p.deadBlobs.length,
|
|
4335
|
-
reclaim_bytes: p.reclaimBytes,
|
|
4336
|
-
}));
|
|
4337
|
-
const reclaim_bytes = plans.reduce((a, p) => a + p.reclaimBytes, 0);
|
|
4338
|
-
return Response.json({ ok: true, superseded, reclaim_bytes });
|
|
4339
|
-
}
|
|
4340
|
-
if (url.pathname === "/api/gc/execute" && request.method === "POST") {
|
|
4341
|
-
const body = (await request.json().catch(() => ({}))) as { yes?: boolean };
|
|
4342
|
-
if (body.yes !== true)
|
|
4343
|
-
return Response.json({ ok: false, error: "pass {\"yes\": true} to confirm deletion" }, { status: 400 });
|
|
4344
|
-
const { planGc, executeGc, Registry } = await import("./registry");
|
|
4345
|
-
const plans = planGc().filter(
|
|
4346
|
-
(p) => p.pruneSnapshots.length || p.skippedSnapshots.length || p.deadBlobs.length,
|
|
4347
|
-
);
|
|
4348
|
-
const res = executeGc(plans);
|
|
4349
|
-
const reg = new Registry();
|
|
4350
|
-
try {
|
|
4351
|
-
await reg.scan(); // reap deleted snapshots from the registry
|
|
4352
|
-
} finally {
|
|
4353
|
-
reg.close();
|
|
4354
|
-
}
|
|
4355
|
-
libraryCache = null; // force /library to reflect the deletion
|
|
4356
|
-
return Response.json({
|
|
4357
|
-
ok: true,
|
|
4358
|
-
snapshots: res.snapshots, blobs: res.blobs, reclaimed_bytes: res.reclaimedBytes,
|
|
4359
|
-
});
|
|
4360
|
-
}
|
|
3665
|
+
const labResponse = await handleLabRoute(url, request, {
|
|
3666
|
+
ensureJobs,
|
|
3667
|
+
serverPort: () => server.port,
|
|
3668
|
+
invalidateLibrary: () => discoveryRoutes.invalidateLibrary(),
|
|
3669
|
+
});
|
|
3670
|
+
if (labResponse) return labResponse;
|
|
4361
3671
|
|
|
4362
|
-
|
|
4363
|
-
|
|
4364
|
-
|
|
4365
|
-
|
|
4366
|
-
|
|
4367
|
-
}
|
|
4368
|
-
{
|
|
4369
|
-
const m = url.pathname.match(/^\/api\/jobs\/([^/]+?)(\/stream)?$/);
|
|
4370
|
-
if (m && request.method === "GET") {
|
|
4371
|
-
const store = await ensureJobs();
|
|
4372
|
-
if (m[2]) {
|
|
4373
|
-
const { streamJobResponse } = await import("./jobs");
|
|
4374
|
-
return streamJobResponse(store, m[1]!);
|
|
4375
|
-
}
|
|
4376
|
-
const job = store.get(m[1]!);
|
|
4377
|
-
if (!job) return Response.json({ ok: false, error: "job not found" }, { status: 404 });
|
|
4378
|
-
return Response.json({ ok: true, job });
|
|
4379
|
-
}
|
|
4380
|
-
}
|
|
3672
|
+
const adminResponse = await handleAdminRoute(url, request, {
|
|
3673
|
+
ensureJobs,
|
|
3674
|
+
invalidateLibrary: () => discoveryRoutes.invalidateLibrary(),
|
|
3675
|
+
});
|
|
3676
|
+
if (adminResponse) return adminResponse;
|
|
4381
3677
|
|
|
4382
3678
|
return Response.json({ error: { message: "not found" } }, { status: 404 });
|
|
4383
3679
|
},
|
|
4384
3680
|
});
|
|
3681
|
+
serverLifecycles.set(serverRef, {
|
|
3682
|
+
flush: flushDurability,
|
|
3683
|
+
stats: durabilityStats,
|
|
3684
|
+
stopTimers: () => {
|
|
3685
|
+
if (demoteTimer) clearInterval(demoteTimer);
|
|
3686
|
+
demoteTimer = null;
|
|
3687
|
+
},
|
|
3688
|
+
});
|
|
4385
3689
|
return serverRef;
|
|
4386
3690
|
}
|