mlx-bun 0.0.12 → 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 +40 -24
- 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/archive/planning/release-notes-v0.0.13.md +77 -0
- 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 +8 -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 -1
- package/docs/reference/library-api.md +41 -1
- package/docs/reference/models.md +33 -0
- package/docs/reference/server-api.md +86 -19
- package/docs/reference/server-config.md +57 -33
- package/package.json +2 -2
- package/src/anthropic.ts +92 -3
- package/src/chat-template.ts +34 -1
- package/src/cli.ts +131 -36
- package/src/config.ts +10 -0
- 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 +768 -1367
- 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}/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;
|
|
@@ -293,7 +403,7 @@ export interface GenSamplingDefaults {
|
|
|
293
403
|
repetitionPenalty?: number;
|
|
294
404
|
}
|
|
295
405
|
|
|
296
|
-
export type DraftKind = "dspark" | "deepspec" | "assistant" | "two-model" | "ngram";
|
|
406
|
+
export type DraftKind = "dspark" | "deepspec" | "assistant" | "two-model" | "ngram" | "mtp";
|
|
297
407
|
|
|
298
408
|
/** Detect the draft artifact's kind so the right provider is loaded. All
|
|
299
409
|
* providers share ONE serve loop (src/spec/serve-loop.ts). Exported for the
|
|
@@ -311,6 +421,11 @@ 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";
|
|
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*).
|
|
428
|
+
if (String(cfg.model_type ?? "").endsWith("_mtp")) return "mtp";
|
|
314
429
|
} catch {
|
|
315
430
|
// no/unreadable config → fall through to a full second model
|
|
316
431
|
}
|
|
@@ -339,8 +454,27 @@ export async function loadContext(
|
|
|
339
454
|
opts: LoadContextOptions = {},
|
|
340
455
|
): Promise<ServerContext> {
|
|
341
456
|
const config = await loadModelConfig(modelDir);
|
|
342
|
-
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
|
+
}
|
|
343
474
|
const externalDraft = opts.draftModelDir !== undefined || opts.draftKind !== undefined;
|
|
475
|
+
const resolvedDraftKind = opts.draftModelDir
|
|
476
|
+
? opts.draftKind ?? await detectDraftKind(opts.draftModelDir)
|
|
477
|
+
: opts.draftKind;
|
|
344
478
|
if (glm && externalDraft && opts.glm?.enableMtp === true)
|
|
345
479
|
throw new Error("native GLM MTP and --draft-model/--draft-kind are mutually exclusive");
|
|
346
480
|
let weights: Weights | null = null;
|
|
@@ -377,9 +511,7 @@ export async function loadContext(
|
|
|
377
511
|
`${(opts.memoryBudgetBytes / 1e9).toFixed(2)} GB`,
|
|
378
512
|
);
|
|
379
513
|
}
|
|
380
|
-
|
|
381
|
-
// unmatched configs run the monolith — slow, never broken
|
|
382
|
-
if (!glm) model = createModel(weights!, config);
|
|
514
|
+
if (!glm) model = createModel(weights!, config, profile);
|
|
383
515
|
const tokenizer = await loadTokenizer(modelDir);
|
|
384
516
|
// Generation must stop on the tokenizer's eos_token — the chat turn
|
|
385
517
|
// terminator (e.g. Qwen <|im_end|> = 248046). Some configs (Qwen3.5-4B)
|
|
@@ -412,7 +544,7 @@ export async function loadContext(
|
|
|
412
544
|
};
|
|
413
545
|
} else if (opts.draftModelDir) {
|
|
414
546
|
const dir = opts.draftModelDir;
|
|
415
|
-
const kind =
|
|
547
|
+
const kind = resolvedDraftKind!;
|
|
416
548
|
let provider: import("./spec/source").DraftProvider;
|
|
417
549
|
let numDraftTokens = Math.max(1, opts.numDraftTokens ?? 3);
|
|
418
550
|
if (kind === "dspark") {
|
|
@@ -432,6 +564,17 @@ export async function loadContext(
|
|
|
432
564
|
} else if (kind === "assistant") {
|
|
433
565
|
const { AssistantProvider } = await import("./spec/assistant-source");
|
|
434
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);
|
|
435
578
|
} else {
|
|
436
579
|
const { TwoModelProvider } = await import("./spec/two-model");
|
|
437
580
|
provider = await TwoModelProvider.load(dir, config.text.vocabSize);
|
|
@@ -512,6 +655,7 @@ export async function loadContext(
|
|
|
512
655
|
return {
|
|
513
656
|
draft,
|
|
514
657
|
model,
|
|
658
|
+
profile,
|
|
515
659
|
glmMemoryPlan,
|
|
516
660
|
adapters: new AdapterManager(model),
|
|
517
661
|
kvConfig: config.kvQuant,
|
|
@@ -552,6 +696,21 @@ export async function loadContext(
|
|
|
552
696
|
function makeVisionLoader(
|
|
553
697
|
modelDir: string, model: RuntimeModel, config: ModelConfig,
|
|
554
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
|
+
}
|
|
555
714
|
if (!(config.hasVisionSidecar && model instanceof Gemma4Model)) return null;
|
|
556
715
|
const vc = config.raw.vision_config as Record<string, any> | undefined;
|
|
557
716
|
if (vc?.model_type === "gemma4_vision") {
|
|
@@ -663,15 +822,21 @@ interface ChatRequest {
|
|
|
663
822
|
tools?: ToolDefinition[];
|
|
664
823
|
tool_choice?: "auto" | "none" | { type: string; function?: { name: string } };
|
|
665
824
|
/** Forwarded to HF chat templates, matching optiq serve. MiniCPM5 uses
|
|
666
|
-
* enable_thinking to select direct answers vs the <think> channel.
|
|
825
|
+
* enable_thinking to select direct answers vs the <think> channel; Qwen3.8
|
|
826
|
+
* adds preserve_thinking (keep think blocks from history; template default
|
|
827
|
+
* true — better prompt-cache reuse in agent loops). */
|
|
667
828
|
chat_template_kwargs?: {
|
|
668
829
|
enable_thinking?: boolean;
|
|
830
|
+
preserve_thinking?: boolean;
|
|
669
831
|
[key: string]: unknown;
|
|
670
832
|
};
|
|
671
833
|
/** OpenAI reasoning control. For models with a switchable <think> channel
|
|
672
834
|
* (Qwen3.5/MiniCPM5) it gates enable_thinking: "none" → off, any level → on.
|
|
673
|
-
* This is what Pi sends when the provider advertises reasoning.
|
|
674
|
-
|
|
835
|
+
* This is what Pi sends when the provider advertises reasoning. For
|
|
836
|
+
* templates that read a reasoning-depth variable (Qwen3.8: xhigh/medium/
|
|
837
|
+
* low), the level ALSO maps into the template via qwenReasoningEffort —
|
|
838
|
+
* "xhigh" accepted as a first-class value (Qwen3.8's default/deepest). */
|
|
839
|
+
reasoning_effort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
675
840
|
/** Mounted LoRA adapter selection: "id", "a+b" (stacked), or "none". */
|
|
676
841
|
adapter?: string;
|
|
677
842
|
/** HLG tone-curve sampling override (per request). Snake_case wire fields,
|
|
@@ -708,6 +873,35 @@ type TextCompletionRequest = Omit<ChatRequest, "messages" | "tools" | "tool_choi
|
|
|
708
873
|
prompt?: unknown;
|
|
709
874
|
};
|
|
710
875
|
|
|
876
|
+
export interface ContextAdmissionDecision {
|
|
877
|
+
/** Effective completion ceiling after admission. */
|
|
878
|
+
maxTokens: number;
|
|
879
|
+
/** True when admission reduced the client's upper bound to fit. */
|
|
880
|
+
clamped: boolean;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/** Resolve a request's completion upper bound against the admitted context.
|
|
884
|
+
*
|
|
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. */
|
|
893
|
+
export function admitRequestContext(
|
|
894
|
+
promptTokens: number,
|
|
895
|
+
requestedMaxTokens: number,
|
|
896
|
+
maxSafeContext: number,
|
|
897
|
+
): ContextAdmissionDecision | null {
|
|
898
|
+
const available = maxSafeContext - promptTokens;
|
|
899
|
+
if (available < 1) return null;
|
|
900
|
+
if (requestedMaxTokens <= available)
|
|
901
|
+
return { maxTokens: requestedMaxTokens, clamped: false };
|
|
902
|
+
return { maxTokens: available, clamped: true };
|
|
903
|
+
}
|
|
904
|
+
|
|
711
905
|
/** Per-field default HLG knobs when enabling without specifying them. */
|
|
712
906
|
const HLG_DEFAULTS = { width: 4, shoulder: 4, toe: 6, pivotOffset: 6 } as const;
|
|
713
907
|
|
|
@@ -1126,7 +1320,8 @@ function hasMediaPart(parts: Array<Record<string, unknown>>): boolean {
|
|
|
1126
1320
|
(p) =>
|
|
1127
1321
|
p &&
|
|
1128
1322
|
(p.type === "image" || p.type === "image_url" ||
|
|
1129
|
-
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"),
|
|
1130
1325
|
);
|
|
1131
1326
|
}
|
|
1132
1327
|
|
|
@@ -1198,7 +1393,8 @@ export function parseLogitBias(
|
|
|
1198
1393
|
|
|
1199
1394
|
/** Default seed for a request that didn't pin one. `Date.now()` alone is NOT
|
|
1200
1395
|
* request-unique: under `--batch N` the batch lane serves ONLY default-seed
|
|
1201
|
-
* requests (explicit-seed requests
|
|
1396
|
+
* requests (explicit-seed requests use the serial mechanism, see
|
|
1397
|
+
* GenerationGateway.place),
|
|
1202
1398
|
* so two identical prompts arriving in the same millisecond would share a seed
|
|
1203
1399
|
* and — with per-row RNG keyed as stepKey(seed, generatedCount) — produce
|
|
1204
1400
|
* byte-identical completions, silently collapsing best-of-N diversity. Mix a
|
|
@@ -1290,68 +1486,24 @@ export function validateLogprobsParams(body: {
|
|
|
1290
1486
|
return null;
|
|
1291
1487
|
}
|
|
1292
1488
|
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
*
|
|
1298
|
-
* -
|
|
1299
|
-
*
|
|
1300
|
-
*
|
|
1301
|
-
*
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
readonly #topTokens: { id: number; token: string; logprob: number }[][] = [];
|
|
1312
|
-
|
|
1313
|
-
constructor(
|
|
1314
|
-
private readonly wantLogprobs: boolean,
|
|
1315
|
-
private readonly topK: number,
|
|
1316
|
-
private readonly idToToken: (id: number) => string,
|
|
1317
|
-
) {}
|
|
1318
|
-
|
|
1319
|
-
get active(): boolean {
|
|
1320
|
-
return this.wantLogprobs || this.topK > 0;
|
|
1321
|
-
}
|
|
1322
|
-
|
|
1323
|
-
push(token: number, info?: TokenLogprobs): void {
|
|
1324
|
-
if (!this.active) return;
|
|
1325
|
-
this.#tokens.push(token);
|
|
1326
|
-
if (this.wantLogprobs) this.#tokenLogprobs.push(info?.logprob ?? NaN);
|
|
1327
|
-
if (this.topK > 0)
|
|
1328
|
-
this.#topTokens.push(
|
|
1329
|
-
(info?.top ?? []).map((t) => ({
|
|
1330
|
-
id: t.id,
|
|
1331
|
-
token: this.idToToken(t.id),
|
|
1332
|
-
logprob: t.logprob,
|
|
1333
|
-
})),
|
|
1334
|
-
);
|
|
1335
|
-
}
|
|
1336
|
-
|
|
1337
|
-
/** choices[0].logprobs value, or null when nothing to attach (mirrors the
|
|
1338
|
-
* reference: the key is omitted entirely for zero collected tokens). */
|
|
1339
|
-
payload(): { content: Record<string, unknown>[] } | null {
|
|
1340
|
-
if (this.#topTokens.length)
|
|
1341
|
-
return {
|
|
1342
|
-
content: this.#topTokens.map((t) =>
|
|
1343
|
-
t.length ? { ...t[0]!, top_logprobs: t } : {},
|
|
1344
|
-
),
|
|
1345
|
-
};
|
|
1346
|
-
if (this.#tokenLogprobs.length)
|
|
1347
|
-
return {
|
|
1348
|
-
content: this.#tokens.map((id, i) => ({
|
|
1349
|
-
id,
|
|
1350
|
-
logprob: this.#tokenLogprobs[i]!,
|
|
1351
|
-
})),
|
|
1352
|
-
};
|
|
1353
|
-
return null;
|
|
1354
|
-
}
|
|
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;
|
|
1355
1507
|
}
|
|
1356
1508
|
|
|
1357
1509
|
/** Incremental detokenizer: emits the longest stable decoded prefix.
|
|
@@ -1505,13 +1657,14 @@ function curveJunk(s: string): boolean {
|
|
|
1505
1657
|
export function createServer(
|
|
1506
1658
|
ctx: ServerContext, port = 0, serverOptions: ServerOptions = {},
|
|
1507
1659
|
): Server<unknown> {
|
|
1508
|
-
// --batch N
|
|
1509
|
-
//
|
|
1510
|
-
//
|
|
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
|
|
1511
1664
|
// sliding-window (Gemma) models batch — the scheduler assembles each layer's
|
|
1512
1665
|
// cache by attention type. Non-batchable requests (vision / adapters /
|
|
1513
|
-
//
|
|
1514
|
-
//
|
|
1666
|
+
// user seed / unsupported explicit kv-quant) drain to the serial executor
|
|
1667
|
+
// (see GenerationGateway.place). No inference setting is rewritten.
|
|
1515
1668
|
// DEFAULT 8 (flipped 2026-07-05, Josh's call, after GATE-B1-SPEED): a
|
|
1516
1669
|
// lone request through the batch lane IS the serial engine (adopted
|
|
1517
1670
|
// serial-class caches, compiled decode, prompt cache + SSD restore;
|
|
@@ -1530,7 +1683,6 @@ export function createServer(
|
|
|
1530
1683
|
// and must be an explicit opt-in (--kv-quant config|4|8, or --l2
|
|
1531
1684
|
// whose presets pass it explicitly). The CLI always passes kvQuant now;
|
|
1532
1685
|
// this fallback is the library-user default and matches the CLI's.
|
|
1533
|
-
const configScheme = ctx.kvConfig?.length ? { kvConfig: ctx.kvConfig } : {};
|
|
1534
1686
|
// Mutually exclusive by contract (GenerateOptions.turboQuant doc): a
|
|
1535
1687
|
// programmatic caller setting both gets turboQuant — say so, like the
|
|
1536
1688
|
// other risky-combination warnings below.
|
|
@@ -1539,13 +1691,12 @@ export function createServer(
|
|
|
1539
1691
|
`[kv-quant] both turboQuant and --kv-quant ${serverOptions.kvQuant} are set; ` +
|
|
1540
1692
|
`turboQuant wins (they are mutually exclusive).`,
|
|
1541
1693
|
);
|
|
1542
|
-
const
|
|
1543
|
-
|
|
1544
|
-
: serverOptions.
|
|
1545
|
-
:
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
: {}; // 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;
|
|
1549
1700
|
// Phase 3.1: kvConfig whose layers are all full-attention BATCHES (the
|
|
1550
1701
|
// scheduler applies the mixed scheme per row); uniform kvBits and configs
|
|
1551
1702
|
// touching rotating layers still route those requests serial. The warning
|
|
@@ -1558,7 +1709,7 @@ export function createServer(
|
|
|
1558
1709
|
`--kv-quant to batch in bf16. (docs/design/unified-engine-frontier-plan.md)`,
|
|
1559
1710
|
);
|
|
1560
1711
|
// TurboQuant is solo-only in v1 (novel cache class, not batchable by
|
|
1561
|
-
// construction — see GenerationGateway
|
|
1712
|
+
// construction — see GenerationGateway placement's cache-capability gate).
|
|
1562
1713
|
if (batch > 1 && kvScheme.turboQuant)
|
|
1563
1714
|
console.warn(
|
|
1564
1715
|
`[batch] --batch ${batch} with --kv-quant turbo: TurboQuant is serial-only in v1 ` +
|
|
@@ -1644,11 +1795,7 @@ export function createServer(
|
|
|
1644
1795
|
if (serverOptions.ssdCacheDir) {
|
|
1645
1796
|
if (promptCacheCap <= 0)
|
|
1646
1797
|
throw new Error("--ssd-cache requires the RAM prompt cache (--prompt-cache 0 disables it)");
|
|
1647
|
-
const schemeKey =
|
|
1648
|
-
? `turbo-k${kvScheme.turboQuant.kBits}v${kvScheme.turboQuant.vBits}`
|
|
1649
|
-
: kvScheme.kvBits
|
|
1650
|
-
? `kv${kvScheme.kvBits}`
|
|
1651
|
-
: kvScheme.kvConfig?.length ? "config" : "bf16";
|
|
1798
|
+
const schemeKey = resolvedKvScheme.cacheKey;
|
|
1652
1799
|
const tokJson = readFileSync(`${ctx.model.config.modelDir}/tokenizer.json`);
|
|
1653
1800
|
ssdStore = new SsdCacheStore({
|
|
1654
1801
|
dir: serverOptions.ssdCacheDir,
|
|
@@ -1734,8 +1881,17 @@ export function createServer(
|
|
|
1734
1881
|
embeddings: import("./mlx/array").MlxArray;
|
|
1735
1882
|
imageMask?: import("./mlx/array").MlxArray;
|
|
1736
1883
|
multimodalMask?: import("./mlx/array").MlxArray;
|
|
1884
|
+
mrope?: MropeRequestState;
|
|
1737
1885
|
},
|
|
1886
|
+
trace?: PromptResponseTrace,
|
|
1738
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).
|
|
1739
1895
|
// Speculative decoding (serve --draft-model): spec-ELIGIBLE requests
|
|
1740
1896
|
// decode through the verify loop; the rest fall through to the normal
|
|
1741
1897
|
// serial path (never wrong results, just no speedup — logged once per
|
|
@@ -1786,7 +1942,12 @@ export function createServer(
|
|
|
1786
1942
|
const skipPromptCache = Boolean(vision || pagedKv);
|
|
1787
1943
|
// Both tiers in one call (Layer 0): take() prefers a strictly-longer
|
|
1788
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
|
+
});
|
|
1789
1949
|
const entry = skipPromptCache ? null : promptCache.take(promptIds, cacheNs);
|
|
1950
|
+
closeCacheLookup?.();
|
|
1790
1951
|
const caches = entry?.caches ?? ctx.model.makeCache();
|
|
1791
1952
|
// Prompt-boundary snapshot (the multi-turn agent fix, 2026-07-04): the
|
|
1792
1953
|
// prompt+gen entry put() below is UNTRIMMABLE at context > sliding
|
|
@@ -1810,6 +1971,8 @@ export function createServer(
|
|
|
1810
1971
|
const snapshotBoundary =
|
|
1811
1972
|
!skipPromptCache && boundary >= 256 && boundary > (entry?.tokens.length ?? 0);
|
|
1812
1973
|
try {
|
|
1974
|
+
if (vision?.mrope && ctx.model instanceof Qwen35Model)
|
|
1975
|
+
ctx.model.mrope = vision.mrope;
|
|
1813
1976
|
const gen = generate(ctx.model, promptIds, {
|
|
1814
1977
|
...options,
|
|
1815
1978
|
pagedKv, // request-scoped (undefined strips the server-wide flag)
|
|
@@ -1838,7 +2001,7 @@ export function createServer(
|
|
|
1838
2001
|
...(vision.multimodalMask ? { multimodalMask: vision.multimodalMask } : {}),
|
|
1839
2002
|
}
|
|
1840
2003
|
: {}),
|
|
1841
|
-
});
|
|
2004
|
+
}, { trace, mechanism: "serial" });
|
|
1842
2005
|
for await (const t of gen) {
|
|
1843
2006
|
if ((await onToken(t.token, t.logprobs)) === false) break;
|
|
1844
2007
|
}
|
|
@@ -1863,6 +2026,7 @@ export function createServer(
|
|
|
1863
2026
|
options.grammar?.dispose();
|
|
1864
2027
|
throw e;
|
|
1865
2028
|
} finally {
|
|
2029
|
+
if (ctx.model instanceof Qwen35Model) ctx.model.mrope = null;
|
|
1866
2030
|
vision?.embeddings.dispose();
|
|
1867
2031
|
vision?.imageMask?.dispose();
|
|
1868
2032
|
vision?.multimodalMask?.dispose();
|
|
@@ -1879,12 +2043,10 @@ export function createServer(
|
|
|
1879
2043
|
// extraction must not race a generation mutating the entry); if the entry
|
|
1880
2044
|
// was evicted (already spilled) or extended (a newer schedule pending)
|
|
1881
2045
|
// meanwhile, findExact misses and nothing is written.
|
|
1882
|
-
// Keyed by
|
|
1883
|
-
// not cancel the pending boundary
|
|
1884
|
-
//
|
|
1885
|
-
//
|
|
1886
|
-
// self-clean at fire time (findExact misses once the RAM tier superseded
|
|
1887
|
-
// 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.
|
|
1888
2050
|
//
|
|
1889
2051
|
// NON-BLOCKING (2026-07-06, the write-behind persistence contract): the
|
|
1890
2052
|
// gateway lock is held only for a zero-copy SNAPSHOT (findExact +
|
|
@@ -1909,23 +2071,28 @@ export function createServer(
|
|
|
1909
2071
|
// still land ahead of a just-arrived request. MLX_BUN_SSD_WRITEBEHIND=0
|
|
1910
2072
|
// disables write-behind snapshots entirely — the paired-A/B lever + kill
|
|
1911
2073
|
// switch (restart survival then degrades to spill-on-evict only).
|
|
1912
|
-
const writeBehindOn =
|
|
2074
|
+
const writeBehindOn = runtimeValue("MLX_BUN_SSD_WRITEBEHIND") !== "0";
|
|
1913
2075
|
const ssdFlushGate = (): Promise<void> => gateway.onIdle();
|
|
1914
|
-
const ssdPending = new Map<string, ReturnType<typeof setTimeout>>();
|
|
1915
2076
|
// Bounded write-behind queue (2026-07-07 review fix — see SpillQueue in
|
|
1916
2077
|
// kv-store.ts): pending clones pin their entries' GPU buffers while the
|
|
1917
2078
|
// idle gate starves under sustained traffic, so QUEUED bytes are capped —
|
|
1918
2079
|
// over cap the oldest queued spill drops (clone disposed immediately, a
|
|
1919
2080
|
// future cache miss, never a wrong result) instead of accumulating past
|
|
1920
2081
|
// the prompt-cache cap. Default 2 GB (a quarter of the 8 GB RAM-cache
|
|
1921
|
-
// default); MLX_BUN_SSD_SPILL_QUEUE_GB overrides.
|
|
1922
|
-
//
|
|
1923
|
-
//
|
|
1924
|
-
// 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.
|
|
1925
2085
|
// `|| 2` would coerce an explicit "0" back to 2 GB — parse so 0 works
|
|
1926
2086
|
// (cap 0 = keep only the newest + in-flight clone pinned; the soft cap
|
|
1927
2087
|
// never drops the item just enqueued).
|
|
1928
|
-
|
|
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"));
|
|
1929
2096
|
const spillQueueCapBytes =
|
|
1930
2097
|
(Number.isFinite(spillQueueGbRaw) && spillQueueGbRaw >= 0 ? spillQueueGbRaw : 2) * 1024 ** 3;
|
|
1931
2098
|
const spillQueue = ssdStore
|
|
@@ -1936,38 +2103,39 @@ export function createServer(
|
|
|
1936
2103
|
(caches) => { for (const c of caches) c.dispose(); },
|
|
1937
2104
|
)
|
|
1938
2105
|
: null;
|
|
1939
|
-
const
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
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,
|
|
1965
2134
|
};
|
|
1966
|
-
ssdPending.set(key, setTimeout(fire, 1000));
|
|
1967
2135
|
};
|
|
1968
2136
|
// Every put() — serial lane AND batch scheduler — schedules the snapshot
|
|
1969
2137
|
// (Layer 0: batch-lane entries survive restarts too, not just evictions).
|
|
1970
|
-
promptCache.onPut = (tokens, ns) =>
|
|
2138
|
+
promptCache.onPut = durability ? (tokens, ns) => durability.schedule(tokens, ns) : null;
|
|
1971
2139
|
|
|
1972
2140
|
// Idle demotion (Layer 0): entries unused for --ssd-demote-idle seconds
|
|
1973
2141
|
// spill to SSD and free their GPU memory — the RAM tier drains between
|
|
@@ -1991,52 +2159,49 @@ export function createServer(
|
|
|
1991
2159
|
demoteTimer.unref?.();
|
|
1992
2160
|
}
|
|
1993
2161
|
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
// usable prefix (multi-turn chat TTFT under --batch N); never-merged
|
|
2006
|
-
// lone rows put() back on finish. Vision/adapter requests never batch,
|
|
2007
|
-
// so the serial lane's bypass rules are preserved by routing.
|
|
2008
|
-
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,
|
|
2009
2173
|
});
|
|
2010
2174
|
|
|
2011
2175
|
// Admission ceiling, resolved once (Phase 5 memoryBudget enforcement).
|
|
2012
2176
|
// fit() solves max safe context from weights + KV growth + prefill
|
|
2013
|
-
// transient
|
|
2014
|
-
//
|
|
2015
|
-
|
|
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(
|
|
2016
2183
|
ctx.model.config, ctx.model.weightsBytes, 1,
|
|
2017
2184
|
undefined, undefined, 0, serverOptions.memoryBudgetBytes,
|
|
2185
|
+
resolvedKvScheme.fitOptions,
|
|
2018
2186
|
);
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
}
|
|
2025
|
-
: 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.
|
|
2026
2192
|
if (admission.maxSafeContext < 1)
|
|
2027
|
-
|
|
2028
|
-
`memory budget ${(admission.usableBytes / 1e9).toFixed(2)} GB
|
|
2029
|
-
|
|
2030
|
-
|
|
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`,
|
|
2031
2197
|
);
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
setMemoryLimit(serverOptions.memoryBudgetBytes);
|
|
2198
|
+
const allocatorLimit =
|
|
2199
|
+
admission.allocatorLimitBytes ?? serverOptions.memoryBudgetBytes;
|
|
2200
|
+
if (allocatorLimit) setMemoryLimit(allocatorLimit);
|
|
2036
2201
|
|
|
2037
2202
|
// /library response cache (30 s) — registry + config reads only.
|
|
2038
|
-
let libraryCache: { at: number; rows: unknown[] } | null = null;
|
|
2039
2203
|
const startedAt = Date.now();
|
|
2204
|
+
const discoveryRoutes = createDiscoveryRoutes(ctx, gateway, startedAt);
|
|
2040
2205
|
|
|
2041
2206
|
// Captured so the WebSocket handler can resolve the bound (possibly
|
|
2042
2207
|
// ephemeral) port lazily for the loopback pi provider.
|
|
@@ -2162,10 +2327,36 @@ export function createServer(
|
|
|
2162
2327
|
return { controller: r.controller, degradeHint: r.degradeHint };
|
|
2163
2328
|
};
|
|
2164
2329
|
|
|
2330
|
+
// Map OpenAI reasoning_effort levels onto the Qwen3.8 template's supported
|
|
2331
|
+
// set (xhigh|medium|low — the template raises on anything else). "none"
|
|
2332
|
+
// means thinking off (handled by resolveEnableThinking), so no depth is
|
|
2333
|
+
// passed. Only consumed for templates with readsReasoningEffort.
|
|
2334
|
+
const qwenReasoningEffort = (
|
|
2335
|
+
effort: ChatRequest["reasoning_effort"],
|
|
2336
|
+
): "xhigh" | "medium" | "low" | undefined => {
|
|
2337
|
+
switch (effort) {
|
|
2338
|
+
case "minimal":
|
|
2339
|
+
case "low":
|
|
2340
|
+
return "low";
|
|
2341
|
+
case "medium":
|
|
2342
|
+
return "medium";
|
|
2343
|
+
case "high":
|
|
2344
|
+
case "xhigh":
|
|
2345
|
+
return "xhigh";
|
|
2346
|
+
default:
|
|
2347
|
+
return undefined; // "none" / unset → template default (xhigh)
|
|
2348
|
+
}
|
|
2349
|
+
};
|
|
2350
|
+
|
|
2165
2351
|
const templateOptionsFor = (req: ChatRequest, tools: ToolDefinition[] | null) => {
|
|
2166
2352
|
// enableThinking resolution (and its precedence) lives in
|
|
2167
2353
|
// resolveEnableThinking so template rendering and sampling stay in sync.
|
|
2168
|
-
return {
|
|
2354
|
+
return {
|
|
2355
|
+
tools,
|
|
2356
|
+
enableThinking: resolveEnableThinking(req),
|
|
2357
|
+
reasoningEffort: qwenReasoningEffort(req.reasoning_effort),
|
|
2358
|
+
preserveThinking: req.chat_template_kwargs?.preserve_thinking,
|
|
2359
|
+
};
|
|
2169
2360
|
};
|
|
2170
2361
|
|
|
2171
2362
|
const promptIdsFor = (
|
|
@@ -2200,7 +2391,7 @@ export function createServer(
|
|
|
2200
2391
|
trimmed: number[],
|
|
2201
2392
|
): number => {
|
|
2202
2393
|
const opts = templateOptionsFor(req, tools);
|
|
2203
|
-
const mode = `${opts.enableThinking}|${!!tools?.length}`;
|
|
2394
|
+
const mode = `${opts.enableThinking}|${!!tools?.length}|${opts.reasoningEffort ?? ""}|${opts.preserveThinking ?? ""}`;
|
|
2204
2395
|
const primer = primerLenByMode.get(mode);
|
|
2205
2396
|
if (primer !== undefined) return Math.max(0, trimmed.length - primer);
|
|
2206
2397
|
let stableLen = trimmed.length;
|
|
@@ -2274,250 +2465,36 @@ export function createServer(
|
|
|
2274
2465
|
async fetch(request, server) {
|
|
2275
2466
|
const url = new URL(request.url);
|
|
2276
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
|
+
|
|
2277
2481
|
if (url.pathname === "/ws/chat") {
|
|
2278
2482
|
if (server.upgrade(request, { data: { sessionId: crypto.randomUUID() } as PiWsData }))
|
|
2279
2483
|
return undefined;
|
|
2280
2484
|
return new Response("expected websocket", { status: 426 });
|
|
2281
2485
|
}
|
|
2282
2486
|
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
// Memory panel. Handlers live in src/memory/rest.ts (pure functions,
|
|
2286
|
-
// no `ctx` dependency) so they're unit-testable without a loaded
|
|
2287
|
-
// model; dispatch here just matches path+method. The agent-tool
|
|
2288
|
-
// surface (src/memory/tools.ts) is untouched by this block.
|
|
2289
|
-
if (url.pathname === "/api/memory/status" && request.method === "GET") {
|
|
2290
|
-
const { handleMemoryStatus } = await import("./memory/rest");
|
|
2291
|
-
return handleMemoryStatus();
|
|
2292
|
-
}
|
|
2293
|
-
if (url.pathname === "/api/memory/list" && request.method === "GET") {
|
|
2294
|
-
const { handleMemoryList } = await import("./memory/rest");
|
|
2295
|
-
return handleMemoryList();
|
|
2296
|
-
}
|
|
2297
|
-
if (url.pathname === "/api/memory/search" && request.method === "GET") {
|
|
2298
|
-
const { handleMemorySearch } = await import("./memory/rest");
|
|
2299
|
-
return handleMemorySearch(url);
|
|
2300
|
-
}
|
|
2301
|
-
if (url.pathname === "/api/memory/article" && request.method === "GET") {
|
|
2302
|
-
const { handleMemoryArticle } = await import("./memory/rest");
|
|
2303
|
-
return handleMemoryArticle(url);
|
|
2304
|
-
}
|
|
2305
|
-
if (url.pathname === "/api/memory/links" && request.method === "GET") {
|
|
2306
|
-
const { handleMemoryLinks } = await import("./memory/rest");
|
|
2307
|
-
return handleMemoryLinks(url);
|
|
2308
|
-
}
|
|
2309
|
-
if (url.pathname === "/api/memory/history" && request.method === "GET") {
|
|
2310
|
-
const { handleMemoryHistory } = await import("./memory/rest");
|
|
2311
|
-
return handleMemoryHistory(url);
|
|
2312
|
-
}
|
|
2313
|
-
if (url.pathname === "/api/memory/diff" && request.method === "GET") {
|
|
2314
|
-
const { handleMemoryDiff } = await import("./memory/rest");
|
|
2315
|
-
return handleMemoryDiff(url);
|
|
2316
|
-
}
|
|
2317
|
-
if (url.pathname === "/api/memory/init" && request.method === "POST") {
|
|
2318
|
-
const { handleMemoryInit } = await import("./memory/rest");
|
|
2319
|
-
return handleMemoryInit(request);
|
|
2320
|
-
}
|
|
2321
|
-
|
|
2322
|
-
// Model Hub (web-chat-redesign.md §9 Phase 3, beat-matrix Axis 3):
|
|
2323
|
-
// thin loopback JSON routes over the registry/download/fit machinery
|
|
2324
|
-
// for the web chat's Hub panel. Handlers live in src/hub-rest.ts
|
|
2325
|
-
// (pure functions, no `ctx` dependency); dispatch here just matches
|
|
2326
|
-
// path+method, same convention as the memory wrappers above.
|
|
2327
|
-
if (url.pathname === "/api/hub/local" && request.method === "GET") {
|
|
2328
|
-
const { handleHubLocal } = await import("./hub-rest");
|
|
2329
|
-
return handleHubLocal();
|
|
2330
|
-
}
|
|
2331
|
-
if (url.pathname === "/api/hub/search" && request.method === "GET") {
|
|
2332
|
-
const { handleHubSearch } = await import("./hub-rest");
|
|
2333
|
-
return handleHubSearch(url);
|
|
2334
|
-
}
|
|
2335
|
-
if (url.pathname === "/api/hub/download" && request.method === "POST") {
|
|
2336
|
-
const { handleHubDownload } = await import("./hub-rest");
|
|
2337
|
-
return handleHubDownload(request);
|
|
2338
|
-
}
|
|
2339
|
-
if (url.pathname === "/api/hub/serve" && request.method === "POST") {
|
|
2340
|
-
const { handleHubServe } = await import("./hub-rest");
|
|
2341
|
-
return handleHubServe(request);
|
|
2342
|
-
}
|
|
2343
|
-
|
|
2344
|
-
// Session full-text search + export (docs/design/web-chat-redesign.md
|
|
2345
|
-
// §9 Phase 3, beat-matrix Axis 10/11 — the full-text-search BEAT and
|
|
2346
|
-
// Markdown/JSON chat export). Handlers live in src/serve/session-search.ts
|
|
2347
|
-
// (pure, read-only, no `ctx` dependency), dispatched here by
|
|
2348
|
-
// path+method, same convention as the memory/hub blocks above.
|
|
2349
|
-
if (url.pathname === "/api/sessions/search" && request.method === "GET") {
|
|
2350
|
-
const { handleSessionsSearch } = await import("./serve/session-search");
|
|
2351
|
-
return handleSessionsSearch(url);
|
|
2352
|
-
}
|
|
2353
|
-
if (url.pathname === "/api/sessions/export" && request.method === "GET") {
|
|
2354
|
-
const { handleSessionsExport } = await import("./serve/session-search");
|
|
2355
|
-
return handleSessionsExport(url);
|
|
2356
|
-
}
|
|
2357
|
-
|
|
2358
|
-
// The unified SPA is served at "/"; legacy deep links redirect into
|
|
2359
|
-
// the hash router so old bookmarks still land on the right section.
|
|
2360
|
-
if (url.pathname === "/" && request.method === "GET") {
|
|
2361
|
-
return new Response(APP_PAGE, {
|
|
2362
|
-
headers: { "content-type": "text/html; charset=utf-8" },
|
|
2363
|
-
});
|
|
2364
|
-
}
|
|
2365
|
-
// Vendored syntax-highlighting assets (src/web/vendor/*, no CDN — see
|
|
2366
|
-
// that dir's README). Long cache: content is versioned by the vendor
|
|
2367
|
-
// rebuild step, not by URL, but a hard reload always wins during dev.
|
|
2368
|
-
if (url.pathname === "/assets/hljs.js" && request.method === "GET") {
|
|
2369
|
-
return new Response(HLJS_JS, {
|
|
2370
|
-
headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": "public, max-age=3600" },
|
|
2371
|
-
});
|
|
2372
|
-
}
|
|
2373
|
-
if (url.pathname === "/assets/hljs.css" && request.method === "GET") {
|
|
2374
|
-
return new Response(HLJS_CSS, {
|
|
2375
|
-
headers: { "content-type": "text/css; charset=utf-8", "cache-control": "public, max-age=3600" },
|
|
2376
|
-
});
|
|
2377
|
-
}
|
|
2378
|
-
// The frontend bundle (see the import comment above) — cache header
|
|
2379
|
-
// matches the other /assets/* entries; content is versioned by the
|
|
2380
|
-
// build step, not the URL, and a hard reload always wins during dev.
|
|
2381
|
-
if (url.pathname === "/assets/app.js" && request.method === "GET") {
|
|
2382
|
-
return new Response(APP_JS, {
|
|
2383
|
-
headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": "public, max-age=3600" },
|
|
2384
|
-
});
|
|
2385
|
-
}
|
|
2386
|
-
// PWA manifest + icon (plan §9 Phase 3): linked from app.html's
|
|
2387
|
-
// <link rel="manifest">. Same versioned-by-content cache posture as
|
|
2388
|
-
// the other /assets/* entries above.
|
|
2389
|
-
if (url.pathname === "/manifest.webmanifest" && request.method === "GET") {
|
|
2390
|
-
return new Response(MANIFEST_WEBMANIFEST, {
|
|
2391
|
-
headers: { "content-type": "application/manifest+json; charset=utf-8", "cache-control": "public, max-age=3600" },
|
|
2392
|
-
});
|
|
2393
|
-
}
|
|
2394
|
-
if (url.pathname === "/assets/icon.svg" && request.method === "GET") {
|
|
2395
|
-
return new Response(ICON_SVG, {
|
|
2396
|
-
headers: { "content-type": "image/svg+xml; charset=utf-8", "cache-control": "public, max-age=3600" },
|
|
2397
|
-
});
|
|
2398
|
-
}
|
|
2399
|
-
// Service worker MUST be served at the root scope (/sw.js, not
|
|
2400
|
-
// /assets/sw.js) — a worker's default scope is the directory it's
|
|
2401
|
-
// served from, and the shell it manages includes "/" itself.
|
|
2402
|
-
// no-store: the browser's own update check needs a fresh byte
|
|
2403
|
-
// comparison every time to notice a new worker, not a cached 304.
|
|
2404
|
-
if (url.pathname === "/sw.js" && request.method === "GET") {
|
|
2405
|
-
return new Response(SW_JS, {
|
|
2406
|
-
headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" },
|
|
2407
|
-
});
|
|
2408
|
-
}
|
|
2409
|
-
// v2 HLG Curve Designer — served same-origin so /generate + /signal need no CORS.
|
|
2410
|
-
// Read fresh from disk in dev (edits show on reload, no restart); fall back to the
|
|
2411
|
-
// embedded copy when running as the compiled single binary.
|
|
2412
|
-
if (url.pathname === "/curves" && request.method === "GET") {
|
|
2413
|
-
let html = CURVE_PAGE;
|
|
2414
|
-
try { html = readFileSync(new URL("./assets/curve-designer.html", import.meta.url), "utf8"); } catch { /* binary: use embedded */ }
|
|
2415
|
-
return new Response(html, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
|
|
2416
|
-
}
|
|
2417
|
-
// Training/inference DAG map — self-contained cytoscape artifact, served
|
|
2418
|
-
// same-origin so the Routes tab can embed it in an <iframe src="/dag">.
|
|
2419
|
-
if (url.pathname === "/dag" && request.method === "GET") {
|
|
2420
|
-
try {
|
|
2421
|
-
const html = readFileSync(new URL("../docs/dag/training-inference-map.html", import.meta.url), "utf8");
|
|
2422
|
-
return new Response(html, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
|
|
2423
|
-
} catch {
|
|
2424
|
-
return new Response("DAG map artifact not found at docs/dag/training-inference-map.html", { status: 404 });
|
|
2425
|
-
}
|
|
2426
|
-
}
|
|
2427
|
-
if (url.pathname === "/curve-terrain" && request.method === "GET") {
|
|
2428
|
-
try {
|
|
2429
|
-
const html = readFileSync(new URL("../docs/investigations/curve-terrain.html", import.meta.url), "utf8");
|
|
2430
|
-
return new Response(html, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
|
|
2431
|
-
} catch {
|
|
2432
|
-
return new Response("curve terrain artifact not found; run scripts/experiments/curve-terrain.ts first", { status: 404 });
|
|
2433
|
-
}
|
|
2434
|
-
}
|
|
2435
|
-
if (request.method === "GET" &&
|
|
2436
|
-
["/status", "/chat", "/quantize", "/finetune", "/dataset"].includes(url.pathname)) {
|
|
2437
|
-
return Response.redirect(`/#${url.pathname}`, 302);
|
|
2438
|
-
}
|
|
2487
|
+
const auxiliaryResponse = await handleAuxiliaryRoute(url, request);
|
|
2488
|
+
if (auxiliaryResponse) return auxiliaryResponse;
|
|
2439
2489
|
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
// (30 s cache — registry scan + config reads, no tensor bytes).
|
|
2443
|
-
if (!libraryCache || Date.now() - libraryCache.at > 30_000) {
|
|
2444
|
-
const { Registry } = await import("./registry");
|
|
2445
|
-
const { loadModelConfig } = await import("./config");
|
|
2446
|
-
const reg = new Registry();
|
|
2447
|
-
// Always rescan when (re)building the 30s cache so models that appeared
|
|
2448
|
-
// after boot — fresh downloads AND quants written into the HF cache —
|
|
2449
|
-
// surface on their own. (scan() is INSERT-OR-REPLACE + prunes deleted,
|
|
2450
|
-
// so it's idempotent and cheap for a local cache.)
|
|
2451
|
-
await reg.scan();
|
|
2452
|
-
const { visionCapable, audioCapable } = await import("./registry");
|
|
2453
|
-
const { supportTier } = await import("./model/support");
|
|
2454
|
-
const rows = [];
|
|
2455
|
-
// listCanonical: one row per repo (refs/main) — duplicate snapshots
|
|
2456
|
-
// from upstream re-pushes stay visible only in `ls --all-revisions`.
|
|
2457
|
-
for (const m of reg.listCanonical()) {
|
|
2458
|
-
const tier = supportTier(m.modelType, m.repoId);
|
|
2459
|
-
const supported = tier !== null;
|
|
2460
|
-
let assessment = null;
|
|
2461
|
-
try {
|
|
2462
|
-
const config = await loadModelConfig(m.path);
|
|
2463
|
-
const r = fit(config, m.sizeBytes, 8192, undefined, undefined, m.expertsBytes);
|
|
2464
|
-
assessment = {
|
|
2465
|
-
fits: r.fits,
|
|
2466
|
-
max_safe_context: r.maxSafeContext,
|
|
2467
|
-
predicted_decode_tps: r.predictedDecodeTps,
|
|
2468
|
-
};
|
|
2469
|
-
} catch {}
|
|
2470
|
-
rows.push({
|
|
2471
|
-
repo_id: m.repoId, model_type: m.modelType,
|
|
2472
|
-
size_bytes: m.sizeBytes, quant_bits: m.quantBits,
|
|
2473
|
-
vision: visionCapable(m), audio: audioCapable(m),
|
|
2474
|
-
supported, support_tier: tier,
|
|
2475
|
-
serving: m.repoId === ctx.modelId,
|
|
2476
|
-
assessment,
|
|
2477
|
-
});
|
|
2478
|
-
}
|
|
2479
|
-
libraryCache = { at: Date.now(), rows };
|
|
2480
|
-
}
|
|
2481
|
-
return Response.json({ models: libraryCache.rows });
|
|
2482
|
-
}
|
|
2490
|
+
const staticResponse = handleStaticRoute(url, request, STATIC_ROUTE_ASSETS);
|
|
2491
|
+
if (staticResponse) return staticResponse;
|
|
2483
2492
|
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
return Response.json({ downloads: downloadsSnapshot() });
|
|
2487
|
-
}
|
|
2493
|
+
const discoveryResponse = await discoveryRoutes.handle(url, request);
|
|
2494
|
+
if (discoveryResponse) return discoveryResponse;
|
|
2488
2495
|
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
// status page can show live stage/log/done progress. `?dry=1` plans the DAG
|
|
2492
|
-
// without any model call or vault write — the safe wiring-verification path
|
|
2493
|
-
// (the FULL-corpus run is USER-ACTION, P6-T5). GET so EventSource can drive it.
|
|
2494
|
-
if (url.pathname === "/v1/memory/synthesize" && request.method === "GET") {
|
|
2495
|
-
const dryRun = url.searchParams.get("dry") === "1";
|
|
2496
|
-
const { runSynthesis } = await import("./memory/pipeline");
|
|
2497
|
-
const enc = new TextEncoder();
|
|
2498
|
-
const stream = new ReadableStream({
|
|
2499
|
-
async start(controller) {
|
|
2500
|
-
const send = (e: unknown) =>
|
|
2501
|
-
controller.enqueue(enc.encode(`data: ${JSON.stringify(e)}\n\n`));
|
|
2502
|
-
try {
|
|
2503
|
-
const summary = await runSynthesis({ dryRun }, (ev) => send(ev));
|
|
2504
|
-
send({ type: "summary", ...summary });
|
|
2505
|
-
controller.enqueue(enc.encode("data: [DONE]\n\n"));
|
|
2506
|
-
} catch (e) {
|
|
2507
|
-
send({ type: "error", message: (e as Error).message });
|
|
2508
|
-
} finally {
|
|
2509
|
-
controller.close();
|
|
2510
|
-
}
|
|
2511
|
-
},
|
|
2512
|
-
});
|
|
2513
|
-
return new Response(stream, {
|
|
2514
|
-
headers: {
|
|
2515
|
-
"content-type": "text/event-stream",
|
|
2516
|
-
"cache-control": "no-cache",
|
|
2517
|
-
connection: "keep-alive",
|
|
2518
|
-
},
|
|
2519
|
-
});
|
|
2520
|
-
}
|
|
2496
|
+
const modelAdminResponse = await handleModelAdminRoute(url, request, ctx, gateway);
|
|
2497
|
+
if (modelAdminResponse) return modelAdminResponse;
|
|
2521
2498
|
|
|
2522
2499
|
if (url.pathname === "/fit" && request.method === "GET") {
|
|
2523
2500
|
// Fit assessment for the status page: this-machine report at the
|
|
@@ -2593,6 +2570,7 @@ export function createServer(
|
|
|
2593
2570
|
const report = fit(
|
|
2594
2571
|
ctx.model.config, ctx.model.weightsBytes, admission.maxSafeContext,
|
|
2595
2572
|
machine, undefined, expertsBytes, serverOptions.memoryBudgetBytes,
|
|
2573
|
+
resolvedKvScheme.fitOptions,
|
|
2596
2574
|
);
|
|
2597
2575
|
return Response.json({
|
|
2598
2576
|
machine: { chip: chip.name, ram_bytes: machine.ramBytes, bandwidth_gbs: machine.bandwidthGBs },
|
|
@@ -2605,6 +2583,7 @@ export function createServer(
|
|
|
2605
2583
|
ctx.model.config, ctx.model.weightsBytes,
|
|
2606
2584
|
Math.min(8192, admission.maxSafeContext),
|
|
2607
2585
|
machine, undefined, expertsBytes, serverOptions.memoryBudgetBytes,
|
|
2586
|
+
resolvedKvScheme.fitOptions,
|
|
2608
2587
|
).predictedDecodeTps,
|
|
2609
2588
|
measured_decode_tps: measured?.decodeTps ?? null,
|
|
2610
2589
|
measured_at: measured?.ts ?? null,
|
|
@@ -2626,18 +2605,6 @@ export function createServer(
|
|
|
2626
2605
|
});
|
|
2627
2606
|
}
|
|
2628
2607
|
|
|
2629
|
-
if (url.pathname === "/v1" && request.method === "GET") {
|
|
2630
|
-
return Response.json({
|
|
2631
|
-
name: "mlx-bun", version: pkgVersion, model: ctx.modelId,
|
|
2632
|
-
endpoints: [
|
|
2633
|
-
"POST /v1/chat/completions", "POST /v1/completions", "POST /v1/messages",
|
|
2634
|
-
"POST /v1/responses", "POST /v1/embeddings", "GET /v1/models",
|
|
2635
|
-
"GET/POST/DELETE /v1/adapters", "GET /health",
|
|
2636
|
-
"GET /stats", "GET /fit", "GET /library", "GET /downloads",
|
|
2637
|
-
],
|
|
2638
|
-
});
|
|
2639
|
-
}
|
|
2640
|
-
|
|
2641
2608
|
if (url.pathname === "/stats" && request.method === "GET") {
|
|
2642
2609
|
// Active KV scheme across ALL layers. Since Phase 9 rotating
|
|
2643
2610
|
// (sliding-window) caches quantize too, so every layer the
|
|
@@ -2686,12 +2653,12 @@ export function createServer(
|
|
|
2686
2653
|
spills: ssdStore.stats.spills,
|
|
2687
2654
|
restore_ms_last: Math.round(ssdStore.stats.restoreMsLast),
|
|
2688
2655
|
demotions: promptCache.demotions,
|
|
2689
|
-
|
|
2690
|
-
// the idle-gated flush, and spills the byte cap dropped
|
|
2691
|
-
// (each = one future cache miss, never a wrong result).
|
|
2656
|
+
pending_snapshots: durability?.stats.pendingSnapshots ?? 0,
|
|
2692
2657
|
pending_spills: spillQueue?.pendingCount ?? 0,
|
|
2693
2658
|
pending_spill_bytes: spillQueue?.pendingBytes ?? 0,
|
|
2694
2659
|
dropped_spills: spillQueue?.droppedCount ?? 0,
|
|
2660
|
+
failed_spills: spillQueue?.failedCount ?? 0,
|
|
2661
|
+
longest_durable_prefix_tokens: ssdStore.longestDurablePrefixTokens,
|
|
2695
2662
|
},
|
|
2696
2663
|
} : {}),
|
|
2697
2664
|
response_store: {
|
|
@@ -2758,8 +2725,6 @@ export function createServer(
|
|
|
2758
2725
|
});
|
|
2759
2726
|
}
|
|
2760
2727
|
|
|
2761
|
-
// mlx_lm.server parity: GET /health → the exact body it writes
|
|
2762
|
-
// ('{"status": "ok"}', note the space) so byte-for-byte health checks pass.
|
|
2763
2728
|
// Engine-mode admin (unix-socket children only — never exposed on
|
|
2764
2729
|
// TCP): drain = quiesce the gateway + demote the whole prompt cache
|
|
2765
2730
|
// to the SSD tier. The pool calls this before evicting a model
|
|
@@ -2775,207 +2740,6 @@ export function createServer(
|
|
|
2775
2740
|
return Response.json({ drained: true, demotions: promptCache.demotions });
|
|
2776
2741
|
}
|
|
2777
2742
|
|
|
2778
|
-
if (url.pathname === "/health" && request.method === "GET") {
|
|
2779
|
-
return new Response('{"status": "ok"}', {
|
|
2780
|
-
headers: { "content-type": "application/json" },
|
|
2781
|
-
});
|
|
2782
|
-
}
|
|
2783
|
-
|
|
2784
|
-
// GET /v1/models — the served model FIRST (with our capability extras),
|
|
2785
|
-
// then every other servable model the registry knows (mlx-lm scans the
|
|
2786
|
-
// HF cache here; our registry is that scan, filtered to supported
|
|
2787
|
-
// architectures). GET /v1/models/<id> filters to that id — same list
|
|
2788
|
-
// shape, matching mlx_lm.server's handle_models_request.
|
|
2789
|
-
if (
|
|
2790
|
-
(url.pathname === "/v1/models" || url.pathname.startsWith("/v1/models/")) &&
|
|
2791
|
-
request.method === "GET"
|
|
2792
|
-
) {
|
|
2793
|
-
const filterId = url.pathname.length > "/v1/models/".length - 1
|
|
2794
|
-
? decodeURIComponent(url.pathname.slice("/v1/models/".length))
|
|
2795
|
-
: null;
|
|
2796
|
-
const created = Math.floor(startedAt / 1000);
|
|
2797
|
-
// Model-author sampling defaults (generation_config.json), resolved once
|
|
2798
|
-
// at load (web-ui-pass-plan.md #14 groundwork): lets a client (the web
|
|
2799
|
-
// chat's sampling popover, external pi) show the SERVED model's actual
|
|
2800
|
-
// recommended values instead of one hardcoded shape.
|
|
2801
|
-
const genDefaultsOut = {
|
|
2802
|
-
temperature: ctx.genDefaults.temperature ?? null,
|
|
2803
|
-
top_p: ctx.genDefaults.topP ?? null,
|
|
2804
|
-
top_k: ctx.genDefaults.topK ?? null,
|
|
2805
|
-
};
|
|
2806
|
-
const data: Array<Record<string, unknown>> = [{
|
|
2807
|
-
id: ctx.modelId, object: "model", created, owned_by: "mlx-bun",
|
|
2808
|
-
context_window:
|
|
2809
|
-
ctx.glmMemoryPlan?.contextTokens ??
|
|
2810
|
-
ctx.model.config.text.maxPositionEmbeddings,
|
|
2811
|
-
// Capability flags for clients (CLI/external pi) that build a
|
|
2812
|
-
// provider from discovery — `reasoning` gates the thinking toggle,
|
|
2813
|
-
// `vision` the image input declaration, `audio` whether
|
|
2814
|
-
// `input_audio` content parts are accepted (tower loaded or lazily
|
|
2815
|
-
// loadable; same signals the web embed uses).
|
|
2816
|
-
reasoning: ctx.template.supportsThinking,
|
|
2817
|
-
vision: !!(ctx.vision || ctx.loadVision),
|
|
2818
|
-
audio: !!(ctx.audio || ctx.loadAudio),
|
|
2819
|
-
batch_mode: gateway.batchMode,
|
|
2820
|
-
tools: true,
|
|
2821
|
-
structured_output: true,
|
|
2822
|
-
embeddings: isEmbeddingModel(ctx.model),
|
|
2823
|
-
adapters: !(ctx.model instanceof Glm52Model),
|
|
2824
|
-
training: !(ctx.model instanceof Glm52Model),
|
|
2825
|
-
dsa: ctx.model instanceof Glm52Model && ctx.model.capabilities.dsa,
|
|
2826
|
-
mtp: ctx.draft?.provider.id === "glm52-native-mtp",
|
|
2827
|
-
capabilities: {
|
|
2828
|
-
chat_completions: true,
|
|
2829
|
-
text_completions: true,
|
|
2830
|
-
anthropic_messages: true,
|
|
2831
|
-
responses: true,
|
|
2832
|
-
streaming: true,
|
|
2833
|
-
tools: true,
|
|
2834
|
-
structured_output: true,
|
|
2835
|
-
logprobs: true,
|
|
2836
|
-
embeddings: isEmbeddingModel(ctx.model),
|
|
2837
|
-
vision: !!(ctx.vision || ctx.loadVision),
|
|
2838
|
-
audio: !!(ctx.audio || ctx.loadAudio),
|
|
2839
|
-
adapters: !(ctx.model instanceof Glm52Model),
|
|
2840
|
-
training: !(ctx.model instanceof Glm52Model),
|
|
2841
|
-
},
|
|
2842
|
-
gen_defaults: genDefaultsOut,
|
|
2843
|
-
}];
|
|
2844
|
-
try {
|
|
2845
|
-
const { Registry, visionCapable } = await import("./registry");
|
|
2846
|
-
const { supportTier } = await import("./model/support");
|
|
2847
|
-
const reg = new Registry();
|
|
2848
|
-
try {
|
|
2849
|
-
if (reg.list().length === 0) await reg.scan();
|
|
2850
|
-
// Canonical rows only — a repo with N snapshots is ONE model id.
|
|
2851
|
-
for (const m of reg.listCanonical()) {
|
|
2852
|
-
if (m.repoId === ctx.modelId) continue;
|
|
2853
|
-
const tier = supportTier(m.modelType, m.repoId);
|
|
2854
|
-
if (tier === null) continue;
|
|
2855
|
-
data.push({
|
|
2856
|
-
id: m.repoId, object: "model", created,
|
|
2857
|
-
vision: visionCapable(m), tier,
|
|
2858
|
-
});
|
|
2859
|
-
}
|
|
2860
|
-
} finally {
|
|
2861
|
-
reg.close();
|
|
2862
|
-
}
|
|
2863
|
-
} catch { /* registry unavailable → served model only */ }
|
|
2864
|
-
return Response.json({
|
|
2865
|
-
object: "list",
|
|
2866
|
-
data: filterId ? data.filter((m) => m.id === filterId) : data,
|
|
2867
|
-
});
|
|
2868
|
-
}
|
|
2869
|
-
|
|
2870
|
-
// OpenAI embeddings API. Works when the SERVED model is an embedding model
|
|
2871
|
-
// (plain Qwen3 / Qwen3-Embedding) — consistent with the single-model server
|
|
2872
|
-
// design: `mlx-bun serve <embedding-model>` to use this. Optional non-standard
|
|
2873
|
-
// `instruction` applies Qwen3-Embedding's query format. Embedding is a pure
|
|
2874
|
-
// forward (no decode loop / gateway), so it runs inline.
|
|
2875
|
-
if (url.pathname === "/v1/embeddings" && request.method === "POST") {
|
|
2876
|
-
if (!isEmbeddingModel(ctx.model))
|
|
2877
|
-
return Response.json({
|
|
2878
|
-
error: {
|
|
2879
|
-
message: `served model "${ctx.modelId}" is not an embedding model; ` +
|
|
2880
|
-
`serve an embedding model (e.g. Qwen3-Embedding) to use /v1/embeddings`,
|
|
2881
|
-
type: "invalid_request_error",
|
|
2882
|
-
},
|
|
2883
|
-
}, { status: 400 });
|
|
2884
|
-
let body: { input?: string | string[]; instruction?: string };
|
|
2885
|
-
try {
|
|
2886
|
-
body = (await request.json()) as typeof body;
|
|
2887
|
-
} catch {
|
|
2888
|
-
return Response.json({ error: { message: "invalid JSON body" } }, { status: 400 });
|
|
2889
|
-
}
|
|
2890
|
-
const inputs = Array.isArray(body.input) ? body.input : body.input != null ? [body.input] : [];
|
|
2891
|
-
if (inputs.length === 0 || !inputs.every((s) => typeof s === "string"))
|
|
2892
|
-
return Response.json({
|
|
2893
|
-
error: { message: "`input` must be a string or array of strings", type: "invalid_request_error" },
|
|
2894
|
-
}, { status: 400 });
|
|
2895
|
-
const instruction = typeof body.instruction === "string" ? body.instruction : undefined;
|
|
2896
|
-
const results = embedMany(ctx.model, ctx.tokenizer, inputs, instruction);
|
|
2897
|
-
let totalTokens = 0;
|
|
2898
|
-
const data = results.map((r, index) => {
|
|
2899
|
-
totalTokens += r.tokens;
|
|
2900
|
-
return { object: "embedding", index, embedding: Array.from(r.vector) };
|
|
2901
|
-
});
|
|
2902
|
-
return Response.json({
|
|
2903
|
-
object: "list",
|
|
2904
|
-
data,
|
|
2905
|
-
model: ctx.modelId,
|
|
2906
|
-
usage: { prompt_tokens: totalTokens, total_tokens: totalTokens },
|
|
2907
|
-
});
|
|
2908
|
-
}
|
|
2909
|
-
|
|
2910
|
-
// Adapter admin (port of optiq registry semantics): list / mount /
|
|
2911
|
-
// unmount. Mount and unmount go through the generation queue so
|
|
2912
|
-
// they never race an in-flight forward pass.
|
|
2913
|
-
if (url.pathname === "/v1/adapters/available" && request.method === "GET") {
|
|
2914
|
-
// On-disk adapters that can be mounted (the chat selector's source).
|
|
2915
|
-
// Every on-disk adapter is returned — the web chat routing chip (§5.2)
|
|
2916
|
-
// needs incompatible entries visible-but-grayed, not silently dropped,
|
|
2917
|
-
// so a user understands "10 adapters exist, 2 apply here" rather than
|
|
2918
|
-
// seeing a shorter list with no explanation. `compatible` = the
|
|
2919
|
-
// adapter's recorded base repo id matches the served model (compared
|
|
2920
|
-
// on the bare name, lenient about the org); an adapter with no
|
|
2921
|
-
// recorded base is treated as compatible (mount validates it for
|
|
2922
|
-
// real). `mounted` flags ones already loaded so the UI auto-loads on
|
|
2923
|
-
// select only if needed.
|
|
2924
|
-
const { homedir } = await import("node:os");
|
|
2925
|
-
const stores = [
|
|
2926
|
-
`${homedir()}/.cache/mlx-bun-finetunes`,
|
|
2927
|
-
`${homedir()}/.cache/mlx-bun/adapters`,
|
|
2928
|
-
];
|
|
2929
|
-
const mounted = new Set(ctx.adapters.list().map((a) => a.id));
|
|
2930
|
-
const bareName = (s: string) => s.split("/").pop()!.toLowerCase();
|
|
2931
|
-
const servedName = bareName(ctx.modelId);
|
|
2932
|
-
const adapters = (await listAvailableAdapters(stores))
|
|
2933
|
-
.map((a) => ({
|
|
2934
|
-
id: a.id, path: a.path, rank: a.rank, scale: a.scale,
|
|
2935
|
-
base_model: a.baseModel, mounted: mounted.has(a.id),
|
|
2936
|
-
compatible: a.baseModel == null || bareName(a.baseModel) === servedName,
|
|
2937
|
-
}));
|
|
2938
|
-
return Response.json({ adapters });
|
|
2939
|
-
}
|
|
2940
|
-
if (url.pathname === "/v1/adapters" && request.method === "GET") {
|
|
2941
|
-
return Response.json({
|
|
2942
|
-
adapters: ctx.adapters.list().map((a) => ({
|
|
2943
|
-
id: a.id, path: a.path, rank: a.rank, scale: a.scale,
|
|
2944
|
-
size_bytes: a.sizeBytes, mounted_layers: a.mountedLayers,
|
|
2945
|
-
ram_bytes: a.ramBytes,
|
|
2946
|
-
})),
|
|
2947
|
-
});
|
|
2948
|
-
}
|
|
2949
|
-
if (url.pathname === "/v1/adapters" && request.method === "POST") {
|
|
2950
|
-
let body: { id?: string; path?: string };
|
|
2951
|
-
try {
|
|
2952
|
-
body = (await request.json()) as typeof body;
|
|
2953
|
-
} catch {
|
|
2954
|
-
return Response.json({ error: { message: "invalid JSON body" } }, { status: 400 });
|
|
2955
|
-
}
|
|
2956
|
-
if (!body.id || !body.path)
|
|
2957
|
-
return Response.json({ error: { message: "id and path required" } }, { status: 400 });
|
|
2958
|
-
try {
|
|
2959
|
-
// Under the gateway lock: mount/unmount mutate the shared adapter
|
|
2960
|
-
// registry (unmount disposes arrays a running generation could still
|
|
2961
|
-
// hold) — one mutual-exclusion domain with generation (D3).
|
|
2962
|
-
const info = await gateway.runExclusive(() => ctx.adapters.mount(body.id!, body.path!));
|
|
2963
|
-
return Response.json({
|
|
2964
|
-
id: info.id, mounted_layers: info.mountedLayers,
|
|
2965
|
-
rank: info.rank, scale: info.scale, ram_bytes: info.ramBytes,
|
|
2966
|
-
});
|
|
2967
|
-
} catch (e) {
|
|
2968
|
-
return Response.json({ error: { message: (e as Error).message } }, { status: 400 });
|
|
2969
|
-
}
|
|
2970
|
-
}
|
|
2971
|
-
if (url.pathname.startsWith("/v1/adapters/") && request.method === "DELETE") {
|
|
2972
|
-
const id = decodeURIComponent(url.pathname.slice("/v1/adapters/".length));
|
|
2973
|
-
const removed = await gateway.runExclusive(async () => ctx.adapters.unmount(id));
|
|
2974
|
-
return removed > 0
|
|
2975
|
-
? Response.json({ id, removed_layers: removed })
|
|
2976
|
-
: Response.json({ error: { message: `adapter ${id} not mounted` } }, { status: 404 });
|
|
2977
|
-
}
|
|
2978
|
-
|
|
2979
2743
|
// ---- Curve Designer: POST /signal {prompt} → next-token histogram over the curve's x-axis ----
|
|
2980
2744
|
// One prefill forward; bins the real log-prob distribution so the editor can draw the
|
|
2981
2745
|
// signal under the curve (you shape where the tokens actually are).
|
|
@@ -3064,15 +2828,27 @@ export function createServer(
|
|
|
3064
2828
|
// translates its body into this shape and the Response back —
|
|
3065
2829
|
// generation, tools, vision, stop sequences, prompt cache, and
|
|
3066
2830
|
// admission control all live here exactly once.
|
|
3067
|
-
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> => {
|
|
3068
2838
|
if (!Array.isArray(body.messages) || body.messages.length === 0)
|
|
3069
2839
|
return Response.json({ error: { message: "messages required" } }, { status: 400 });
|
|
3070
2840
|
// mlx-lm validates logprobs params up front (ValueError → 400)
|
|
3071
2841
|
const lpParamError = validateLogprobsParams(body);
|
|
3072
2842
|
if (lpParamError)
|
|
3073
2843
|
return Response.json({ error: { message: lpParamError } }, { status: 400 });
|
|
3074
|
-
|
|
3075
|
-
|
|
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");
|
|
3076
2852
|
const created = Math.floor(Date.now() / 1000);
|
|
3077
2853
|
const tools =
|
|
3078
2854
|
body.tool_choice === "none" ? null : (body.tools?.length ? body.tools : null);
|
|
@@ -3088,6 +2864,13 @@ export function createServer(
|
|
|
3088
2864
|
(p: any) => p.type === "input_audio" || p.type === "audio" || p.type === "audio_url",
|
|
3089
2865
|
),
|
|
3090
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
|
+
);
|
|
3091
2874
|
let promptIds: number[];
|
|
3092
2875
|
let stableLen: number | null = null;
|
|
3093
2876
|
// Whether the prompt primed an open <think> (Qwen3.5/MiniCPM5 thinking
|
|
@@ -3096,6 +2879,7 @@ export function createServer(
|
|
|
3096
2879
|
let startInThinking = false;
|
|
3097
2880
|
let vision: Parameters<typeof runGeneration>[3];
|
|
3098
2881
|
let diffusionPixels: import("./mlx/array").MlxArray | null = null;
|
|
2882
|
+
const ownership = new RequestOwnership();
|
|
3099
2883
|
// Grammar-constrained decoding (src/grammar.ts). Compile BEFORE prompt
|
|
3100
2884
|
// rendering: on the degrade path (compile failed but a constraint was
|
|
3101
2885
|
// requested) inject a system message instructing valid JSON so the
|
|
@@ -3109,27 +2893,30 @@ export function createServer(
|
|
|
3109
2893
|
body.structured_outputs != null;
|
|
3110
2894
|
if (grammarReq) {
|
|
3111
2895
|
const g = await compileGrammarForRequest(body);
|
|
3112
|
-
grammarCtrl = g.controller;
|
|
2896
|
+
grammarCtrl = ownership.own(g.controller);
|
|
3113
2897
|
if (!g.controller && g.degradeHint) {
|
|
3114
2898
|
const degraded = applyGrammarDegrade(body, g.degradeHint);
|
|
3115
2899
|
grammarWarning = degraded.warning;
|
|
3116
2900
|
body = degraded.body;
|
|
3117
2901
|
}
|
|
3118
2902
|
}
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
// generate()/the gateway only take disposal ownership once running
|
|
3123
|
-
// (leaked one matcher per SSRF-blocked-media + response_format
|
|
3124
|
-
// request before the 2026-07-07 sweep).
|
|
3125
|
-
const disposeRejected = () => {
|
|
3126
|
-
grammarCtrl?.dispose();
|
|
3127
|
-
vision?.embeddings.dispose();
|
|
3128
|
-
vision?.imageMask?.dispose();
|
|
3129
|
-
vision?.multimodalMask?.dispose();
|
|
3130
|
-
diffusionPixels?.dispose();
|
|
2903
|
+
const rejectBeforeRun = (response: Response): Response => {
|
|
2904
|
+
ownership.dispose();
|
|
2905
|
+
return response;
|
|
3131
2906
|
};
|
|
3132
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
|
+
}
|
|
3133
2920
|
if (hasAudio) {
|
|
3134
2921
|
// Audio (and MIXED image+audio) input — A4 of
|
|
3135
2922
|
// docs/design/audio-input-plan.md. One buildMultimodalPrompt call
|
|
@@ -3139,8 +2926,7 @@ export function createServer(
|
|
|
3139
2926
|
// requests without the media, getVisionTower's contract).
|
|
3140
2927
|
const audioTower = getAudioTower(ctx);
|
|
3141
2928
|
if (!audioTower || !ctx.audioTokenIds) {
|
|
3142
|
-
|
|
3143
|
-
return Response.json(
|
|
2929
|
+
return rejectBeforeRun(Response.json(
|
|
3144
2930
|
{
|
|
3145
2931
|
error: {
|
|
3146
2932
|
message:
|
|
@@ -3150,7 +2936,7 @@ export function createServer(
|
|
|
3150
2936
|
},
|
|
3151
2937
|
},
|
|
3152
2938
|
{ status: 400 },
|
|
3153
|
-
);
|
|
2939
|
+
));
|
|
3154
2940
|
}
|
|
3155
2941
|
let visionSide:
|
|
3156
2942
|
| { tower: VisionEncoder; tokenIds: VisionTokenIds }
|
|
@@ -3158,10 +2944,9 @@ export function createServer(
|
|
|
3158
2944
|
if (hasImages) {
|
|
3159
2945
|
const tower = getVisionTower(ctx);
|
|
3160
2946
|
if (!tower) {
|
|
3161
|
-
|
|
3162
|
-
return Response.json(
|
|
2947
|
+
return rejectBeforeRun(Response.json(
|
|
3163
2948
|
{ error: { message: "model has no vision sidecar" } }, { status: 400 },
|
|
3164
|
-
);
|
|
2949
|
+
));
|
|
3165
2950
|
}
|
|
3166
2951
|
visionSide = { tower, tokenIds: ctx.visionTokenIds };
|
|
3167
2952
|
}
|
|
@@ -3196,37 +2981,62 @@ export function createServer(
|
|
|
3196
2981
|
// forwardEmbeddings path). v1 supports a single image.
|
|
3197
2982
|
const dm = ctx.model;
|
|
3198
2983
|
if (!dm.visionTower) {
|
|
3199
|
-
|
|
3200
|
-
return Response.json(
|
|
2984
|
+
return rejectBeforeRun(Response.json(
|
|
3201
2985
|
{ error: { message: "this checkpoint has no vision tower" } }, { status: 400 },
|
|
3202
|
-
);
|
|
2986
|
+
));
|
|
3203
2987
|
}
|
|
3204
2988
|
const { messages, images } = await extractImages(normalizeMessages(body.messages));
|
|
3205
2989
|
if (images.length !== 1) {
|
|
3206
|
-
|
|
3207
|
-
return Response.json(
|
|
2990
|
+
return rejectBeforeRun(Response.json(
|
|
3208
2991
|
{ error: { message: "DiffusionGemma image input supports exactly one image" } },
|
|
3209
2992
|
{ status: 400 },
|
|
3210
|
-
);
|
|
2993
|
+
));
|
|
3211
2994
|
}
|
|
3212
2995
|
const rendered = ctx.template.render(messages, { tools, addGenerationPrompt: true });
|
|
3213
2996
|
const rawIds = ctx.tokenizer.encode(rendered, /* addSpecialTokens */ false);
|
|
3214
2997
|
const { pixels, softTokens } = await dm.visionTower.preprocess(images[0]!);
|
|
2998
|
+
diffusionPixels = ownership.own(pixels);
|
|
3215
2999
|
promptIds = spliceImageTokens(rawIds, [softTokens], {
|
|
3216
3000
|
image: ctx.visionTokenIds.imageTokenId,
|
|
3217
3001
|
boi: ctx.visionTokenIds.boiTokenId,
|
|
3218
3002
|
eoi: ctx.visionTokenIds.eoiTokenId,
|
|
3219
3003
|
});
|
|
3220
|
-
|
|
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 };
|
|
3221
3032
|
} else if (hasImages) {
|
|
3222
3033
|
// Loads (and caches) the tower on first image request — text-only
|
|
3223
3034
|
// sessions never pay for it.
|
|
3224
3035
|
const tower = getVisionTower(ctx);
|
|
3225
3036
|
if (!tower) {
|
|
3226
|
-
|
|
3227
|
-
return Response.json(
|
|
3037
|
+
return rejectBeforeRun(Response.json(
|
|
3228
3038
|
{ error: { message: "model has no vision sidecar" } }, { status: 400 },
|
|
3229
|
-
);
|
|
3039
|
+
));
|
|
3230
3040
|
}
|
|
3231
3041
|
const { messages, images } = await extractImages(normalizeMessages(body.messages));
|
|
3232
3042
|
// The tower is only ever non-null for Gemma4 (sidecar gate in
|
|
@@ -3244,12 +3054,15 @@ export function createServer(
|
|
|
3244
3054
|
stableLen = -1; // marker: chat path, probe lazily below
|
|
3245
3055
|
}
|
|
3246
3056
|
} catch (e) {
|
|
3247
|
-
|
|
3248
|
-
return Response.json(
|
|
3057
|
+
return rejectBeforeRun(Response.json(
|
|
3249
3058
|
{ error: { message: `prompt build failed: ${(e as Error).message}` } },
|
|
3250
3059
|
{ status: 400 },
|
|
3251
|
-
);
|
|
3060
|
+
));
|
|
3252
3061
|
}
|
|
3062
|
+
ownership.own(vision?.embeddings);
|
|
3063
|
+
ownership.own(vision?.imageMask);
|
|
3064
|
+
ownership.own(vision?.multimodalMask);
|
|
3065
|
+
ownership.own(diffusionPixels);
|
|
3253
3066
|
let options: ReturnType<typeof toOptions>;
|
|
3254
3067
|
try {
|
|
3255
3068
|
options = toOptions(body);
|
|
@@ -3267,97 +3080,81 @@ export function createServer(
|
|
|
3267
3080
|
}
|
|
3268
3081
|
} catch (e) {
|
|
3269
3082
|
// bad logit_bias (non-numeric keys/values) — mlx-lm's coercion error
|
|
3270
|
-
|
|
3271
|
-
|
|
3083
|
+
return rejectBeforeRun(
|
|
3084
|
+
Response.json({ error: { message: (e as Error).message } }, { status: 400 }),
|
|
3085
|
+
);
|
|
3272
3086
|
}
|
|
3273
3087
|
if (diffusionPixels) options.visionPixels = diffusionPixels;
|
|
3274
3088
|
// Attach the compiled grammar controller (null when no constraint /
|
|
3275
3089
|
// degrade — generate() runs the unmasked fast pipelined loop).
|
|
3276
3090
|
if (grammarCtrl) options.grammar = grammarCtrl;
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
// the process — Phase 6 finding).
|
|
3280
|
-
const requiredCtx = promptIds.length + (options.maxTokens ?? 1024);
|
|
3281
|
-
if (requiredCtx > admission.maxSafeContext) {
|
|
3282
|
-
disposeRejected();
|
|
3283
|
-
return Response.json(
|
|
3284
|
-
{
|
|
3285
|
-
error: {
|
|
3286
|
-
message:
|
|
3287
|
-
`request needs ${requiredCtx} tokens of context ` +
|
|
3288
|
-
`(prompt ${promptIds.length} + max_tokens ${options.maxTokens}) but the ` +
|
|
3289
|
-
`memory budget caps safe context at ${admission.maxSafeContext} — ` +
|
|
3290
|
-
`shorten the prompt or lower max_tokens`,
|
|
3291
|
-
type: "memory_admission",
|
|
3292
|
-
code: "context_over_budget",
|
|
3293
|
-
},
|
|
3294
|
-
},
|
|
3295
|
-
{ status: 400 },
|
|
3296
|
-
);
|
|
3297
|
-
}
|
|
3091
|
+
const requestedMaxTokens = options.maxTokens ?? 1024;
|
|
3092
|
+
let adapterIds: string[];
|
|
3298
3093
|
try {
|
|
3299
3094
|
// A request's explicit `adapter` (incl. "none") wins over the
|
|
3300
3095
|
// startup default from `serve --adapter <dir>`.
|
|
3301
|
-
|
|
3302
|
-
if (adapterIds.length) options.adapters = adapterIds;
|
|
3096
|
+
adapterIds = ctx.adapters.resolveSpec(body.adapter ?? serverOptions.defaultAdapter);
|
|
3303
3097
|
} catch (e) {
|
|
3304
|
-
|
|
3305
|
-
|
|
3098
|
+
return rejectBeforeRun(
|
|
3099
|
+
Response.json({ error: { message: (e as Error).message } }, { status: 400 }),
|
|
3100
|
+
);
|
|
3306
3101
|
}
|
|
3307
3102
|
|
|
3308
|
-
// logprobs capture: non-stream only — stream chunks never carry
|
|
3309
|
-
// logprobs (mirroring mlx_lm.server, whose streaming generate_response
|
|
3310
|
-
// calls pass no token_logprobs/top_tokens), so streaming requests skip
|
|
3311
|
-
// the capture cost entirely and stay batchable.
|
|
3312
3103
|
const wantLogprobs = body.logprobs === true;
|
|
3313
3104
|
const topLogprobs =
|
|
3314
3105
|
typeof body.top_logprobs === "number" && body.top_logprobs > 0
|
|
3315
3106
|
? body.top_logprobs : 0;
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
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 });
|
|
3320
3156
|
}
|
|
3321
|
-
|
|
3322
|
-
// What lane this request takes (vision/audio media / adapters /
|
|
3323
|
-
// logprobs / seed / explicit kv-quant / a mounted draft → serial;
|
|
3324
|
-
// sampler extras, repetition penalty, and grammar all BATCH — see
|
|
3325
|
-
// willBatch). `vision` is the embeddings-prefill payload for BOTH
|
|
3326
|
-
// media kinds, so hasVision routes audio serial too — batching is a
|
|
3327
|
-
// mode, not a fallback, and batched audio has no oracle (§3.2).
|
|
3328
|
-
const shape = {
|
|
3329
|
-
hasVision: !!vision,
|
|
3330
|
-
hasAdapters: !!options.adapters?.length,
|
|
3331
|
-
hasRepetitionPenalty: !!options.repetitionPenalty,
|
|
3332
|
-
// Informational since 2026-07-02: per-row logits processors batch;
|
|
3333
|
-
// willBatch no longer gates on these fields.
|
|
3334
|
-
hasLogitsExtras: !!(
|
|
3335
|
-
options.minP || options.xtcProbability || options.logitBias ||
|
|
3336
|
-
options.presencePenalty || options.frequencyPenalty
|
|
3337
|
-
),
|
|
3338
|
-
wantsLogprobs: captureLogprobs,
|
|
3339
|
-
userSeed: body.seed !== undefined,
|
|
3340
|
-
kvQuant: !!(options.kvConfig?.length || options.kvBits),
|
|
3341
|
-
turboQuant: !!options.turboQuant,
|
|
3342
|
-
// grammarCtrl is null on the degrade path (prompt injection) —
|
|
3343
|
-
// those stay batchable. A real controller batches via per-row
|
|
3344
|
-
// matchers (MLX_BUN_GRAMMAR_BATCH=0 forces it serial).
|
|
3345
|
-
hasGrammar: !!grammarCtrl,
|
|
3346
|
-
hasDraft: !!ctx.draft,
|
|
3347
|
-
};
|
|
3348
|
-
const batched = gateway.willBatch(shape);
|
|
3349
|
-
if (process.env.MLX_BUN_LANE_DEBUG === "1")
|
|
3350
|
-
console.error(`[lane] batched=${batched} shape=${JSON.stringify(shape)} t=${Date.now() % 100000}`);
|
|
3351
|
-
// Per-turn lane (docs/design/web-chat-redesign.md §2.3 caveat / risk #5):
|
|
3352
|
-
// reported on usage AND recorded in the in-process lane registry (keyed
|
|
3353
|
-
// by this response's `id`) so pi-web's WS bridge — which the pi SDK's
|
|
3354
|
-
// usage parsing can't carry a custom field through — can correlate it
|
|
3355
|
-
// via AssistantMessage.responseId. batched wins over hasDraft (mutually
|
|
3356
|
-
// exclusive by construction: willBatch already excludes hasDraft), so
|
|
3357
|
-
// the only ambiguity is serial vs serial+spec, resolved once s.spec is
|
|
3358
|
-
// known post-generation.
|
|
3359
|
-
const lane: Lane = batched ? "batched" : shape.hasDraft ? "serial+spec" : "serial";
|
|
3360
|
-
recordLane(id, lane);
|
|
3157
|
+
closePromptPrepare?.();
|
|
3361
3158
|
|
|
3362
3159
|
if (body.stream) {
|
|
3363
3160
|
const streamAbort = new AbortController();
|
|
@@ -3370,6 +3167,13 @@ export function createServer(
|
|
|
3370
3167
|
if (!generationSignal.aborted)
|
|
3371
3168
|
controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`));
|
|
3372
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";
|
|
3373
3177
|
const chunk = (delta: Record<string, unknown>, finish: string | null) => ({
|
|
3374
3178
|
id, object: "chat.completion.chunk", created, model: ctx.modelId,
|
|
3375
3179
|
choices: [{ index: 0, delta, finish_reason: finish }],
|
|
@@ -3379,92 +3183,65 @@ export function createServer(
|
|
|
3379
3183
|
// The gateway owns lane selection + GPU exclusivity; this body
|
|
3380
3184
|
// runs per-request (concurrently in batched mode, each writing
|
|
3381
3185
|
// its own SSE stream — the per-row fan-out).
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
const
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
const parts = thinking.push(text);
|
|
3403
|
-
if (parts.reasoning) send(chunk({ reasoning: parts.reasoning }, null));
|
|
3404
|
-
if (parts.content) send(chunk({ content: parts.content }, null));
|
|
3405
|
-
if (stopper.stopped) return false; // halt generation
|
|
3406
|
-
if (!batched && (parts.content || parts.reasoning || rReason)) {
|
|
3407
|
-
const now = performance.now();
|
|
3408
|
-
if (now - lastFlush >= 25) {
|
|
3409
|
-
lastFlush = now;
|
|
3410
|
-
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));
|
|
3411
3206
|
}
|
|
3412
3207
|
}
|
|
3413
|
-
}
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
if (rReason) send(chunk({ reasoning: rReason }, null));
|
|
3422
|
-
tail = stopper.push(flushed);
|
|
3423
|
-
if (!stopper.stopped) tail += stopper.flush();
|
|
3424
|
-
}
|
|
3425
|
-
if (tail) {
|
|
3426
|
-
const parts = thinking.push(tail);
|
|
3427
|
-
if (parts.reasoning) send(chunk({ reasoning: parts.reasoning }, null));
|
|
3428
|
-
if (parts.content) send(chunk({ content: parts.content }, null));
|
|
3429
|
-
}
|
|
3430
|
-
{
|
|
3431
|
-
const parts = thinking.flush();
|
|
3432
|
-
if (parts.reasoning) send(chunk({ reasoning: parts.reasoning }, null));
|
|
3433
|
-
if (parts.content) send(chunk({ content: parts.content }, null));
|
|
3434
|
-
}
|
|
3435
|
-
const toolCalls = router.toolCalls();
|
|
3436
|
-
if (toolCalls.length) {
|
|
3437
|
-
send(chunk({
|
|
3438
|
-
tool_calls: toolCalls.map((tc, i) => ({ index: i, ...tc })),
|
|
3439
|
-
}, null));
|
|
3440
|
-
}
|
|
3441
|
-
const finish = toolCalls.length
|
|
3442
|
-
? "tool_calls"
|
|
3443
|
-
: stopper.stopped ? "stop"
|
|
3444
|
-
: s.generatedTokens >= (options.maxTokens ?? 1024) ? "length" : "stop";
|
|
3445
|
-
// Refine serial vs serial+spec now that s.spec is known (a mounted
|
|
3446
|
-
// draft can still decode zero speculative tokens on a very short
|
|
3447
|
-
// reply) and re-record for pi-web's WS correlation.
|
|
3448
|
-
const finalLane: Lane = batched ? "batched" : s.spec ? "serial+spec" : lane;
|
|
3449
|
-
if (finalLane !== lane) recordLane(id, finalLane);
|
|
3450
|
-
send({
|
|
3451
|
-
...chunk({}, finish),
|
|
3452
|
-
usage: {
|
|
3453
|
-
prompt_tokens: s.promptTokens,
|
|
3454
|
-
completion_tokens: s.generatedTokens,
|
|
3455
|
-
total_tokens: s.promptTokens + s.generatedTokens,
|
|
3456
|
-
prompt_tokens_details: { cached_tokens: s.cachedTokens },
|
|
3457
|
-
lane: finalLane,
|
|
3458
|
-
...(s.spec ? { speculation: s.spec } : {}),
|
|
3459
|
-
},
|
|
3208
|
+
};
|
|
3209
|
+
const summary = await completionExecutor.execute(prepared, {
|
|
3210
|
+
signal: generationSignal,
|
|
3211
|
+
trace,
|
|
3212
|
+
onEvents: sendEvents,
|
|
3213
|
+
...(streamProtocol
|
|
3214
|
+
? { onUsageProgress: (usage) => { latestUsage = usage; } }
|
|
3215
|
+
: {}),
|
|
3460
3216
|
});
|
|
3461
|
-
|
|
3462
|
-
|
|
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.
|
|
3463
3224
|
controller.enqueue(enc.encode("data: [DONE]\n\n"));
|
|
3225
|
+
}
|
|
3226
|
+
trace?.mark("response.final_write");
|
|
3464
3227
|
} catch (e) {
|
|
3465
|
-
|
|
3466
|
-
|
|
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
|
+
}
|
|
3467
3243
|
} finally {
|
|
3244
|
+
trace?.finish(traceOutcome);
|
|
3468
3245
|
if (!cancelled) {
|
|
3469
3246
|
if (generationSignal.aborted) controller.error(generationSignal.reason);
|
|
3470
3247
|
else controller.close();
|
|
@@ -3489,85 +3266,53 @@ export function createServer(
|
|
|
3489
3266
|
|
|
3490
3267
|
try {
|
|
3491
3268
|
{
|
|
3492
|
-
const
|
|
3493
|
-
|
|
3494
|
-
const
|
|
3495
|
-
// mlx-lm collects logprobs across EVERY generated token (reasoning
|
|
3496
|
-
// and tool tokens included), not just visible content — same here.
|
|
3497
|
-
const lpc = captureLogprobs
|
|
3498
|
-
? new LogprobsCollector(wantLogprobs, topLogprobs, (tid) => ctx.tokenizer.idToToken(tid))
|
|
3499
|
-
: null;
|
|
3500
|
-
let content = "";
|
|
3501
|
-
let reasoning = "";
|
|
3502
|
-
const s = await gateway.run(promptIds, options, (token, lpInfo) => {
|
|
3503
|
-
lpc?.push(token, lpInfo);
|
|
3504
|
-
const rawContent = router.push(token);
|
|
3505
|
-
reasoning += router.takeReasoning(); // gemma-channel thinking
|
|
3506
|
-
const parts = thinking.push(stopper.push(rawContent));
|
|
3507
|
-
content += parts.content;
|
|
3508
|
-
reasoning += parts.reasoning;
|
|
3509
|
-
if (stopper.stopped) return false; // halt generation
|
|
3510
|
-
}, vision, shape, signal);
|
|
3511
|
-
if (!stopper.stopped) {
|
|
3512
|
-
const flushed = router.flush();
|
|
3513
|
-
reasoning += router.takeReasoning();
|
|
3514
|
-
let tail = stopper.push(flushed);
|
|
3515
|
-
if (!stopper.stopped) tail += stopper.flush();
|
|
3516
|
-
const parts = thinking.push(tail);
|
|
3517
|
-
content += parts.content;
|
|
3518
|
-
reasoning += parts.reasoning;
|
|
3519
|
-
}
|
|
3520
|
-
{
|
|
3521
|
-
const parts = thinking.flush();
|
|
3522
|
-
content += parts.content;
|
|
3523
|
-
reasoning += parts.reasoning;
|
|
3524
|
-
}
|
|
3525
|
-
const toolCalls = router.toolCalls();
|
|
3526
|
-
const finish = toolCalls.length
|
|
3527
|
-
? "tool_calls"
|
|
3528
|
-
: stopper.stopped ? "stop"
|
|
3529
|
-
: s.generatedTokens >= (options.maxTokens ?? 1024) ? "length" : "stop";
|
|
3530
|
-
const logprobsBlock = lpc?.payload() ?? null;
|
|
3531
|
-
// Refine serial vs serial+spec now that s.spec is known; re-record
|
|
3532
|
-
// for pi-web's WS correlation (see the streaming branch above).
|
|
3533
|
-
const finalLane: Lane = batched ? "batched" : s.spec ? "serial+spec" : lane;
|
|
3534
|
-
if (finalLane !== lane) recordLane(id, finalLane);
|
|
3535
|
-
return Response.json({
|
|
3269
|
+
const summary = await completionExecutor.execute(prepared, { signal, trace });
|
|
3270
|
+
trace?.mark("response.final_write");
|
|
3271
|
+
const response = Response.json({
|
|
3536
3272
|
id, object: "chat.completion", created, model: ctx.modelId,
|
|
3537
3273
|
choices: [{
|
|
3538
3274
|
index: 0,
|
|
3539
3275
|
message: {
|
|
3540
3276
|
role: "assistant",
|
|
3541
|
-
content: content || (toolCalls.length ? null : ""),
|
|
3542
|
-
...(reasoning ? { reasoning } : {}),
|
|
3543
|
-
...(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 } : {}),
|
|
3544
3280
|
},
|
|
3545
|
-
...(
|
|
3546
|
-
finish_reason:
|
|
3281
|
+
...(summary.logprobs ? { logprobs: summary.logprobs } : {}),
|
|
3282
|
+
finish_reason: summary.finishReason,
|
|
3547
3283
|
}],
|
|
3548
|
-
usage:
|
|
3549
|
-
prompt_tokens: s.promptTokens,
|
|
3550
|
-
completion_tokens: s.generatedTokens,
|
|
3551
|
-
total_tokens: s.promptTokens + s.generatedTokens,
|
|
3552
|
-
prompt_tokens_details: { cached_tokens: s.cachedTokens },
|
|
3553
|
-
lane: finalLane,
|
|
3554
|
-
...(s.spec ? { speculation: s.spec } : {}),
|
|
3555
|
-
},
|
|
3284
|
+
usage: completionUsage(summary),
|
|
3556
3285
|
}, grammarWarning ? { headers: { Warning: grammarWarning } } : undefined);
|
|
3286
|
+
trace?.finish("success");
|
|
3287
|
+
return response;
|
|
3557
3288
|
}
|
|
3558
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}`);
|
|
3559
3294
|
return Response.json({ error: { message: (e as Error).message } }, { status: 500 });
|
|
3560
3295
|
}
|
|
3561
3296
|
};
|
|
3562
3297
|
|
|
3563
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");
|
|
3564
3306
|
let body: ChatRequest;
|
|
3565
3307
|
try {
|
|
3566
3308
|
body = (await request.json()) as ChatRequest;
|
|
3567
3309
|
} catch {
|
|
3310
|
+
closeBodyParse?.();
|
|
3311
|
+
trace?.finish("error", { stage: "body_parse" });
|
|
3568
3312
|
return Response.json({ error: { message: "invalid JSON body" } }, { status: 400 });
|
|
3569
3313
|
}
|
|
3570
|
-
|
|
3314
|
+
closeBodyParse?.();
|
|
3315
|
+
return handleChat(body, request.signal, undefined, id, trace);
|
|
3571
3316
|
}
|
|
3572
3317
|
|
|
3573
3318
|
// Raw text completion (mlx_lm.server's /v1/completions, request_type
|
|
@@ -3577,12 +3322,23 @@ export function createServer(
|
|
|
3577
3322
|
// GenerationGateway + admission + adapter path as chat; no tool router
|
|
3578
3323
|
// or thinking splitter (raw text in, raw text out).
|
|
3579
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");
|
|
3580
3332
|
let body: TextCompletionRequest;
|
|
3581
3333
|
try {
|
|
3582
3334
|
body = (await request.json()) as TextCompletionRequest;
|
|
3583
3335
|
} catch {
|
|
3336
|
+
closeBodyParse?.();
|
|
3337
|
+
trace?.finish("error", { stage: "body_parse" });
|
|
3584
3338
|
return Response.json({ error: { message: "invalid JSON body" } }, { status: 400 });
|
|
3585
3339
|
}
|
|
3340
|
+
closeBodyParse?.();
|
|
3341
|
+
const closePromptPrepare = trace?.begin("request.prompt_prepare");
|
|
3586
3342
|
if (typeof body.prompt !== "string" || body.prompt.length === 0)
|
|
3587
3343
|
return Response.json(
|
|
3588
3344
|
{
|
|
@@ -3597,9 +3353,9 @@ export function createServer(
|
|
|
3597
3353
|
const lpParamError = validateLogprobsParams(body);
|
|
3598
3354
|
if (lpParamError)
|
|
3599
3355
|
return Response.json({ error: { message: lpParamError } }, { status: 400 });
|
|
3600
|
-
const id = `cmpl-${crypto.randomUUID()}`;
|
|
3601
3356
|
const created = Math.floor(Date.now() / 1000);
|
|
3602
3357
|
const promptIds = ctx.tokenizer.encode(body.prompt);
|
|
3358
|
+
const ownership = new RequestOwnership();
|
|
3603
3359
|
let options: ReturnType<typeof toOptions>;
|
|
3604
3360
|
try {
|
|
3605
3361
|
options = toOptions(body as unknown as ChatRequest);
|
|
@@ -3619,77 +3375,64 @@ export function createServer(
|
|
|
3619
3375
|
body.structured_outputs != null;
|
|
3620
3376
|
if (textGrammarReq) {
|
|
3621
3377
|
const g = await compileGrammarForRequest(body as unknown as ChatRequest);
|
|
3622
|
-
if (g.controller) options.grammar = g.controller;
|
|
3378
|
+
if (g.controller) options.grammar = ownership.own(g.controller);
|
|
3623
3379
|
else if (g.degradeHint)
|
|
3624
3380
|
textGrammarWarning =
|
|
3625
3381
|
`grammar not enforced: ${g.degradeHint} - no prompt injection on /v1/completions`;
|
|
3626
3382
|
}
|
|
3627
|
-
// Mirrors the chat lane: a real controller (not the degrade path)
|
|
3628
|
-
// shapes the request for per-row grammar batching.
|
|
3629
|
-
const textGrammarCtrl = options.grammar ?? null;
|
|
3630
3383
|
// mlx_lm.server's default max_tokens is 512 (its --max-tokens CLI
|
|
3631
3384
|
// default). The chat lane's very generous default is wrong for raw
|
|
3632
3385
|
// completion: with no template an EOS may never come.
|
|
3633
|
-
|
|
3386
|
+
const requestedMaxTokens = body.max_completion_tokens ?? body.max_tokens ??
|
|
3634
3387
|
defaultGeneratedTokens ?? 512;
|
|
3635
|
-
|
|
3636
|
-
if (requiredCtx > admission.maxSafeContext) {
|
|
3637
|
-
// Pre-run reject: the gateway only takes grammar disposal ownership
|
|
3638
|
-
// once run — free the WASM matcher here (chat lane: disposeRejected).
|
|
3639
|
-
textGrammarCtrl?.dispose();
|
|
3640
|
-
return Response.json(
|
|
3641
|
-
{
|
|
3642
|
-
error: {
|
|
3643
|
-
message:
|
|
3644
|
-
`request needs ${requiredCtx} tokens of context ` +
|
|
3645
|
-
`(prompt ${promptIds.length} + max_tokens ${options.maxTokens}) but the ` +
|
|
3646
|
-
`memory budget caps safe context at ${admission.maxSafeContext} — ` +
|
|
3647
|
-
`shorten the prompt or lower max_tokens`,
|
|
3648
|
-
type: "memory_admission",
|
|
3649
|
-
code: "context_over_budget",
|
|
3650
|
-
},
|
|
3651
|
-
},
|
|
3652
|
-
{ status: 400 },
|
|
3653
|
-
);
|
|
3654
|
-
}
|
|
3388
|
+
let adapterIds: string[];
|
|
3655
3389
|
try {
|
|
3656
|
-
|
|
3657
|
-
if (adapterIds.length) options.adapters = adapterIds;
|
|
3390
|
+
adapterIds = ctx.adapters.resolveSpec(body.adapter ?? serverOptions.defaultAdapter);
|
|
3658
3391
|
} catch (e) {
|
|
3659
|
-
|
|
3392
|
+
ownership.dispose();
|
|
3660
3393
|
return Response.json({ error: { message: (e as Error).message } }, { status: 400 });
|
|
3661
3394
|
}
|
|
3662
|
-
// logprobs capture — same mlx-lm block as chat (generate_response is
|
|
3663
|
-
// shared in the reference too); non-stream only, stream chunks never
|
|
3664
|
-
// carry logprobs.
|
|
3665
3395
|
const wantLogprobs = body.logprobs === true;
|
|
3666
3396
|
const topLogprobs =
|
|
3667
3397
|
typeof body.top_logprobs === "number" && body.top_logprobs > 0
|
|
3668
3398
|
? body.top_logprobs : 0;
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
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 });
|
|
3673
3434
|
}
|
|
3674
|
-
|
|
3675
|
-
hasVision: false,
|
|
3676
|
-
hasAdapters: !!options.adapters?.length,
|
|
3677
|
-
hasRepetitionPenalty: !!options.repetitionPenalty,
|
|
3678
|
-
hasLogitsExtras: !!(
|
|
3679
|
-
options.minP || options.xtcProbability || options.logitBias ||
|
|
3680
|
-
options.presencePenalty || options.frequencyPenalty
|
|
3681
|
-
),
|
|
3682
|
-
wantsLogprobs: captureLogprobs,
|
|
3683
|
-
userSeed: body.seed !== undefined,
|
|
3684
|
-
kvQuant: !!(options.kvConfig?.length || options.kvBits),
|
|
3685
|
-
turboQuant: !!options.turboQuant,
|
|
3686
|
-
// /v1/completions grammar (textGrammarCtrl). Same null-on-degrade
|
|
3687
|
-
// contract as the chat lane.
|
|
3688
|
-
hasGrammar: !!textGrammarCtrl,
|
|
3689
|
-
hasDraft: !!ctx.draft,
|
|
3690
|
-
};
|
|
3691
|
-
const finishReason = (stopped: boolean, generated: number): "stop" | "length" =>
|
|
3692
|
-
stopped ? "stop" : generated >= (options.maxTokens ?? 512) ? "length" : "stop";
|
|
3435
|
+
closePromptPrepare?.();
|
|
3693
3436
|
|
|
3694
3437
|
if (body.stream) {
|
|
3695
3438
|
const streamAbort = new AbortController();
|
|
@@ -3706,49 +3449,39 @@ export function createServer(
|
|
|
3706
3449
|
id, object: "text_completion", created, model: ctx.modelId,
|
|
3707
3450
|
choices: [{ index: 0, text, finish_reason: finish }],
|
|
3708
3451
|
});
|
|
3452
|
+
let wroteFirstEvent = false;
|
|
3453
|
+
let traceOutcome: "success" | "error" | "abort" = "success";
|
|
3709
3454
|
void (async () => {
|
|
3710
3455
|
try {
|
|
3711
|
-
const
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
let lastFlush = performance.now();
|
|
3716
|
-
const s = await gateway.run(promptIds, options, (token) => {
|
|
3717
|
-
const text = stopper.push(decoder.push(token));
|
|
3718
|
-
if (text) send(chunk(text, null));
|
|
3719
|
-
if (stopper.stopped) return false; // halt generation
|
|
3720
|
-
if (text) {
|
|
3721
|
-
const now = performance.now();
|
|
3722
|
-
if (now - lastFlush >= 25) {
|
|
3723
|
-
lastFlush = now;
|
|
3724
|
-
return new Promise<void>((r) => setImmediate(r));
|
|
3725
|
-
}
|
|
3456
|
+
const sendEvents = (events: readonly CompletionEvent[]) => {
|
|
3457
|
+
if (events.length && !wroteFirstEvent) {
|
|
3458
|
+
wroteFirstEvent = true;
|
|
3459
|
+
trace?.mark("response.first_write");
|
|
3726
3460
|
}
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
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
|
+
});
|
|
3734
3470
|
// final chunk: finish_reason + usage (mlx-lm gates usage behind
|
|
3735
3471
|
// stream_options.include_usage; we always attach it, matching
|
|
3736
3472
|
// our chat lane — an additive superset OpenAI clients ignore)
|
|
3737
3473
|
send({
|
|
3738
|
-
...chunk("", finishReason
|
|
3739
|
-
usage:
|
|
3740
|
-
prompt_tokens: s.promptTokens,
|
|
3741
|
-
completion_tokens: s.generatedTokens,
|
|
3742
|
-
total_tokens: s.promptTokens + s.generatedTokens,
|
|
3743
|
-
prompt_tokens_details: { cached_tokens: s.cachedTokens },
|
|
3744
|
-
...(s.spec ? { speculation: s.spec } : {}),
|
|
3745
|
-
},
|
|
3474
|
+
...chunk("", summary.finishReason),
|
|
3475
|
+
usage: completionUsage(summary),
|
|
3746
3476
|
});
|
|
3747
3477
|
controller.enqueue(enc.encode("data: [DONE]\n\n"));
|
|
3478
|
+
trace?.mark("response.final_write");
|
|
3748
3479
|
} catch (e) {
|
|
3480
|
+
traceOutcome = generationSignal.aborted ? "abort" : "error";
|
|
3749
3481
|
if (!generationSignal.aborted)
|
|
3750
3482
|
send({ error: { message: (e as Error).message } });
|
|
3751
3483
|
} finally {
|
|
3484
|
+
trace?.finish(traceOutcome);
|
|
3752
3485
|
if (!cancelled) {
|
|
3753
3486
|
if (generationSignal.aborted) controller.error(generationSignal.reason);
|
|
3754
3487
|
else controller.close();
|
|
@@ -3772,39 +3505,24 @@ export function createServer(
|
|
|
3772
3505
|
}
|
|
3773
3506
|
|
|
3774
3507
|
try {
|
|
3775
|
-
const
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
const s = await gateway.run(promptIds, options, (token, lpInfo) => {
|
|
3782
|
-
lpc?.push(token, lpInfo);
|
|
3783
|
-
text += stopper.push(decoder.push(token));
|
|
3784
|
-
if (stopper.stopped) return false; // halt generation
|
|
3785
|
-
}, undefined, shape, request.signal);
|
|
3786
|
-
if (!stopper.stopped) {
|
|
3787
|
-
let tail = stopper.push(decoder.flush());
|
|
3788
|
-
if (!stopper.stopped) tail += stopper.flush();
|
|
3789
|
-
text += tail;
|
|
3790
|
-
}
|
|
3791
|
-
const logprobsBlock = lpc?.payload() ?? null;
|
|
3792
|
-
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({
|
|
3793
3514
|
id, object: "text_completion", created, model: ctx.modelId,
|
|
3794
3515
|
choices: [{
|
|
3795
|
-
index: 0, text,
|
|
3796
|
-
...(
|
|
3797
|
-
finish_reason: finishReason
|
|
3516
|
+
index: 0, text: summary.content,
|
|
3517
|
+
...(summary.logprobs ? { logprobs: summary.logprobs } : {}),
|
|
3518
|
+
finish_reason: summary.finishReason,
|
|
3798
3519
|
}],
|
|
3799
|
-
usage:
|
|
3800
|
-
prompt_tokens: s.promptTokens,
|
|
3801
|
-
completion_tokens: s.generatedTokens,
|
|
3802
|
-
total_tokens: s.promptTokens + s.generatedTokens,
|
|
3803
|
-
prompt_tokens_details: { cached_tokens: s.cachedTokens },
|
|
3804
|
-
...(s.spec ? { speculation: s.spec } : {}),
|
|
3805
|
-
},
|
|
3520
|
+
usage: completionUsage(summary),
|
|
3806
3521
|
}, textGrammarWarning ? { headers: { Warning: textGrammarWarning } } : undefined);
|
|
3522
|
+
trace?.finish("success");
|
|
3523
|
+
return response;
|
|
3807
3524
|
} catch (e) {
|
|
3525
|
+
trace?.finish(request.signal.aborted ? "abort" : "error");
|
|
3808
3526
|
return Response.json({ error: { message: (e as Error).message } }, { status: 500 });
|
|
3809
3527
|
}
|
|
3810
3528
|
}
|
|
@@ -3829,7 +3547,13 @@ export function createServer(
|
|
|
3829
3547
|
} catch (e) {
|
|
3830
3548
|
return anthropicError(400, "invalid_request_error", (e as Error).message);
|
|
3831
3549
|
}
|
|
3832
|
-
const resp = await handleChat(
|
|
3550
|
+
const resp = await handleChat(
|
|
3551
|
+
chatBody,
|
|
3552
|
+
request.signal,
|
|
3553
|
+
anthropicBody.stream
|
|
3554
|
+
? createAnthropicStreamProtocol(ctx.modelId)
|
|
3555
|
+
: undefined,
|
|
3556
|
+
);
|
|
3833
3557
|
if (!resp.ok) {
|
|
3834
3558
|
const err = (await resp.json().catch(() => null)) as
|
|
3835
3559
|
| { error?: { message?: string } }
|
|
@@ -3841,13 +3565,7 @@ export function createServer(
|
|
|
3841
3565
|
);
|
|
3842
3566
|
}
|
|
3843
3567
|
if (anthropicBody.stream) {
|
|
3844
|
-
return
|
|
3845
|
-
headers: {
|
|
3846
|
-
"content-type": "text/event-stream",
|
|
3847
|
-
"cache-control": "no-cache",
|
|
3848
|
-
connection: "keep-alive",
|
|
3849
|
-
},
|
|
3850
|
-
});
|
|
3568
|
+
return resp;
|
|
3851
3569
|
}
|
|
3852
3570
|
return Response.json(chatJsonToAnthropic(await resp.json(), ctx.modelId));
|
|
3853
3571
|
}
|
|
@@ -3910,7 +3628,22 @@ export function createServer(
|
|
|
3910
3628
|
} catch (e) {
|
|
3911
3629
|
return responsesError(400, (e as Error).message);
|
|
3912
3630
|
}
|
|
3913
|
-
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
|
+
);
|
|
3914
3647
|
if (!resp.ok) {
|
|
3915
3648
|
const err = (await resp.json().catch(() => null)) as
|
|
3916
3649
|
| { error?: { message?: string } }
|
|
@@ -3918,22 +3651,7 @@ export function createServer(
|
|
|
3918
3651
|
return responsesError(resp.status, err?.error?.message ?? "request failed");
|
|
3919
3652
|
}
|
|
3920
3653
|
if (responsesBody.stream) {
|
|
3921
|
-
|
|
3922
|
-
resp.body!, ctx.modelId, prevId,
|
|
3923
|
-
(final) =>
|
|
3924
|
-
responseStore.put(final.id as string, {
|
|
3925
|
-
input: capturedInput,
|
|
3926
|
-
output: final.output as unknown[],
|
|
3927
|
-
instructions: capturedInstructions,
|
|
3928
|
-
}),
|
|
3929
|
-
);
|
|
3930
|
-
return new Response(body, {
|
|
3931
|
-
headers: {
|
|
3932
|
-
"content-type": "text/event-stream",
|
|
3933
|
-
"cache-control": "no-cache",
|
|
3934
|
-
connection: "keep-alive",
|
|
3935
|
-
},
|
|
3936
|
-
});
|
|
3654
|
+
return resp;
|
|
3937
3655
|
}
|
|
3938
3656
|
const responses = chatJsonToResponses(await resp.json(), ctx.modelId, prevId);
|
|
3939
3657
|
responseStore.put(responses.id as string, {
|
|
@@ -3944,346 +3662,29 @@ export function createServer(
|
|
|
3944
3662
|
return Response.json(responses);
|
|
3945
3663
|
}
|
|
3946
3664
|
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
}
|
|
3952
|
-
if (
|
|
3953
|
-
const body = (await request.json().catch(() => ({}))) as {
|
|
3954
|
-
template_id?: string; inputs?: Record<string, unknown>; model_name?: string;
|
|
3955
|
-
};
|
|
3956
|
-
const { getTemplate } = await import("./dataset");
|
|
3957
|
-
if (!body.template_id || !getTemplate(body.template_id))
|
|
3958
|
-
return Response.json({ ok: false, error: `unknown template ${body.template_id}` }, { status: 400 });
|
|
3959
|
-
const store = await ensureJobs();
|
|
3960
|
-
const { submitInProcess } = await import("./jobs");
|
|
3961
|
-
const { homedir } = await import("node:os");
|
|
3962
|
-
const safe = body.template_id.replace(/[^a-z0-9_-]/gi, "");
|
|
3963
|
-
const outDir = `${homedir()}/.cache/mlx-bun/datasets/dataset-${safe}-${Date.now()}`;
|
|
3964
|
-
const { jobId } = submitInProcess(store, "dataset", {
|
|
3965
|
-
template_id: body.template_id, inputs: body.inputs ?? {},
|
|
3966
|
-
output_dir: outDir, api_url: `http://127.0.0.1:${server.port}`,
|
|
3967
|
-
model_name: body.model_name ?? "local",
|
|
3968
|
-
}, outDir);
|
|
3969
|
-
return Response.json({ ok: true, job_id: jobId, output_dir: outDir });
|
|
3970
|
-
}
|
|
3971
|
-
|
|
3972
|
-
if (url.pathname === "/api/quantize/inspect" && request.method === "POST") {
|
|
3973
|
-
const body = (await request.json().catch(() => ({}))) as { model_id?: string };
|
|
3974
|
-
const { inspectModel } = await import("./quantize");
|
|
3975
|
-
return Response.json(await inspectModel(body.model_id ?? ""));
|
|
3976
|
-
}
|
|
3977
|
-
// Turn an OS-picked folder into the absolute snapshot path on disk. The
|
|
3978
|
-
// browser can't reveal a filesystem path (security) and the HF cache
|
|
3979
|
-
// stores real bytes in blobs/ with symlinked snapshots — so we resolve by
|
|
3980
|
-
// the folder's NAME (which encodes the repo id), never by reading files.
|
|
3981
|
-
// No upload, no dependence on the cache's symlink layout, and a
|
|
3982
|
-
// just-downloaded model resolves before it's ever been indexed.
|
|
3983
|
-
if ((url.pathname === "/api/quantize/resolve-folder" || url.pathname === "/api/model/resolve-folder") && request.method === "POST") {
|
|
3984
|
-
const body = (await request.json().catch(() => ({}))) as { folder_name?: string; rel_path?: string };
|
|
3985
|
-
const { statSync, readdirSync, readFileSync } = await import("node:fs");
|
|
3986
|
-
const { join } = await import("node:path");
|
|
3987
|
-
const { homedir } = await import("node:os");
|
|
3988
|
-
const hubRoot = process.env.HF_HUB_CACHE
|
|
3989
|
-
?? (process.env.HF_HOME ? join(process.env.HF_HOME, "hub") : join(homedir(), ".cache/huggingface/hub"));
|
|
3990
|
-
const roots = [hubRoot, join(homedir(), ".cache/mlx-bun")];
|
|
3991
|
-
const hasConfig = (d: string) => { try { return statSync(join(d, "config.json")).isFile(); } catch { return false; } };
|
|
3992
|
-
// basename of the picked folder; the rel path's last-but-one segment is
|
|
3993
|
-
// the <hash> dir if they drilled into a snapshot.
|
|
3994
|
-
const folder = (body.folder_name ?? "").replace(/\\/g, "/").split("/").filter(Boolean).pop() ?? "";
|
|
3995
|
-
const relSegs = (body.rel_path ?? "").replace(/\\/g, "/").split("/").filter(Boolean);
|
|
3996
|
-
const configDir = relSegs.length >= 2 ? relSegs[relSegs.length - 2] : "";
|
|
3997
|
-
const repoIdOf = (modelsDir: string) => modelsDir.slice("models--".length).replaceAll("--", "/");
|
|
3998
|
-
// Pick a repo dir's canonical snapshot: the one refs/main points at, else
|
|
3999
|
-
// the first snapshot that actually has a config.json.
|
|
4000
|
-
const pickSnapshot = (repoDir: string): string | null => {
|
|
4001
|
-
const snaps = join(repoDir, "snapshots");
|
|
4002
|
-
let head = "";
|
|
4003
|
-
try { head = readFileSync(join(repoDir, "refs", "main"), "utf8").trim(); } catch {}
|
|
4004
|
-
if (head && hasConfig(join(snaps, head))) return join(snaps, head);
|
|
4005
|
-
try { for (const h of readdirSync(snaps)) if (hasConfig(join(snaps, h))) return join(snaps, h); } catch {}
|
|
4006
|
-
return null;
|
|
4007
|
-
};
|
|
4008
|
-
|
|
4009
|
-
// (1) HF-cache folder picked — its name IS the repo id.
|
|
4010
|
-
if (folder.startsWith("models--")) {
|
|
4011
|
-
const p = pickSnapshot(join(hubRoot, folder));
|
|
4012
|
-
if (p) return Response.json({ ok: true, path: p, repo_id: repoIdOf(folder) });
|
|
4013
|
-
}
|
|
4014
|
-
// (2) A bare <hash> snapshot folder picked directly (or carried in rel).
|
|
4015
|
-
const hashDir = configDir || folder;
|
|
4016
|
-
if (hashDir) {
|
|
4017
|
-
try {
|
|
4018
|
-
for (const repo of readdirSync(hubRoot)) {
|
|
4019
|
-
if (!repo.startsWith("models--")) continue;
|
|
4020
|
-
const cand = join(hubRoot, repo, "snapshots", hashDir);
|
|
4021
|
-
if (hasConfig(cand)) return Response.json({ ok: true, path: cand, repo_id: repoIdOf(repo) });
|
|
4022
|
-
}
|
|
4023
|
-
} catch {}
|
|
4024
|
-
}
|
|
4025
|
-
// (3) Flat local snapshot under a known root (folder name == model dir).
|
|
4026
|
-
for (const root of roots) {
|
|
4027
|
-
if (folder && hasConfig(join(root, folder))) return Response.json({ ok: true, path: join(root, folder) });
|
|
4028
|
-
}
|
|
4029
|
-
// (4) Registry rescan — last resort (also covers customized cache layouts).
|
|
4030
|
-
const { Registry } = await import("./registry");
|
|
4031
|
-
const reg = new Registry();
|
|
4032
|
-
try {
|
|
4033
|
-
await reg.scan();
|
|
4034
|
-
const all = reg.list();
|
|
4035
|
-
const rec = (folder.startsWith("models--")
|
|
4036
|
-
? all.find((m) => m.repoId === repoIdOf(folder))
|
|
4037
|
-
: undefined)
|
|
4038
|
-
?? all.find((m) => m.path.split("/").pop() === hashDir)
|
|
4039
|
-
?? all.find((m) => m.repoId.split("/").pop() === folder);
|
|
4040
|
-
if (rec) return Response.json({ ok: true, path: rec.path, repo_id: rec.repoId, model_type: rec.modelType });
|
|
4041
|
-
} finally {
|
|
4042
|
-
reg.close();
|
|
4043
|
-
}
|
|
4044
|
-
return Response.json({
|
|
4045
|
-
ok: false,
|
|
4046
|
-
error: "Couldn't locate this folder on disk — paste the path instead.",
|
|
4047
|
-
});
|
|
4048
|
-
}
|
|
4049
|
-
if (url.pathname === "/api/quantize/submit" && request.method === "POST") {
|
|
4050
|
-
const body = (await request.json().catch(() => ({}))) as {
|
|
4051
|
-
model_id?: string; bits?: number; group_size?: number;
|
|
4052
|
-
target_bpw?: number; candidate_bits?: number[]; reference?: string;
|
|
4053
|
-
calibration_mix?: string; n_calibration?: number;
|
|
4054
|
-
};
|
|
4055
|
-
if (!body.model_id)
|
|
4056
|
-
return Response.json({ ok: false, error: "model_id required" }, { status: 400 });
|
|
4057
|
-
const store = await ensureJobs();
|
|
4058
|
-
const { submitSubprocess } = await import("./jobs");
|
|
4059
|
-
const { homedir } = await import("node:os");
|
|
4060
|
-
const { join } = await import("node:path");
|
|
4061
|
-
const { mkdirSync, writeFileSync } = await import("node:fs");
|
|
4062
|
-
const { createHash } = await import("node:crypto");
|
|
4063
|
-
const bits = body.bits ?? 4, gs = body.group_size ?? 64;
|
|
4064
|
-
// Derive org/name from the source so the quant is named readably (the
|
|
4065
|
-
// source is usually an HF-cache snapshot path whose basename is a hash).
|
|
4066
|
-
const snapMatch = body.model_id.match(/(models--[^/]+)\/snapshots\//);
|
|
4067
|
-
let org = "local", name: string;
|
|
4068
|
-
if (snapMatch) {
|
|
4069
|
-
const parts = snapMatch[1]!.split("--"); // ["models", org, ...name]
|
|
4070
|
-
org = parts[1] ?? "local";
|
|
4071
|
-
name = parts.slice(2).join("--");
|
|
4072
|
-
} else if (body.model_id.includes("/") && !body.model_id.startsWith("/") && !body.model_id.startsWith("~")) {
|
|
4073
|
-
const seg = body.model_id.split("/"); // a repo id "org/name"
|
|
4074
|
-
org = seg[0]!; name = seg.slice(1).join("-");
|
|
4075
|
-
} else {
|
|
4076
|
-
name = body.model_id.split("/").filter(Boolean).at(-1) ?? "model"; // a bare path
|
|
4077
|
-
}
|
|
4078
|
-
name = (name || "model").replace(/[^a-z0-9_.-]/gi, "");
|
|
4079
|
-
org = (org || "local").replace(/[^a-z0-9_.-]/gi, "");
|
|
4080
|
-
const suffix = body.target_bpw ? `mixed-${body.target_bpw}bpw` : `${bits}bit`;
|
|
4081
|
-
// Write the quant INTO the HF hub cache as a normal models--org--name/
|
|
4082
|
-
// snapshots/<hash> entry, so the standard registry scan + every other
|
|
4083
|
-
// tool discovers it alongside downloaded models. refs/main makes it a
|
|
4084
|
-
// well-formed cache entry.
|
|
4085
|
-
const quantRepo = `${name}-OptiQ-${suffix}`;
|
|
4086
|
-
const hubRoot = process.env.HF_HUB_CACHE
|
|
4087
|
-
?? (process.env.HF_HOME ? join(process.env.HF_HOME, "hub") : join(homedir(), ".cache/huggingface/hub"));
|
|
4088
|
-
const repoDir = join(hubRoot, `models--${org}--${quantRepo}`);
|
|
4089
|
-
const snapHash = createHash("sha1").update(`${org}/${quantRepo}`).digest("hex");
|
|
4090
|
-
const outDir = join(repoDir, "snapshots", snapHash);
|
|
4091
|
-
try {
|
|
4092
|
-
mkdirSync(join(repoDir, "refs"), { recursive: true });
|
|
4093
|
-
writeFileSync(join(repoDir, "refs", "main"), snapHash);
|
|
4094
|
-
} catch {}
|
|
4095
|
-
const { jobId } = submitSubprocess(store, "quantize", {
|
|
4096
|
-
model_id: body.model_id, out_dir: outDir, bits, group_size: gs,
|
|
4097
|
-
// forwarded to the mixed-precision path when target_bpw is set
|
|
4098
|
-
target_bpw: body.target_bpw, candidate_bits: body.candidate_bits,
|
|
4099
|
-
reference: body.reference, calibration_mix: body.calibration_mix,
|
|
4100
|
-
n_calibration: body.n_calibration,
|
|
4101
|
-
}, outDir, {
|
|
4102
|
-
// The model is in the HF cache now; drop the Library cache so the next
|
|
4103
|
-
// poll rescans and shows it — no `mlx-bun scan`, no restart.
|
|
4104
|
-
onComplete: () => { libraryCache = null; },
|
|
4105
|
-
});
|
|
4106
|
-
return Response.json({ ok: true, job_id: jobId, output_dir: outDir });
|
|
4107
|
-
}
|
|
4108
|
-
|
|
4109
|
-
if (url.pathname === "/api/finetune/inspect-dataset" && request.method === "POST") {
|
|
4110
|
-
const body = (await request.json().catch(() => ({}))) as { path?: string };
|
|
4111
|
-
const { inspectDataset } = await import("./train");
|
|
4112
|
-
return Response.json(await inspectDataset(body.path ?? ""));
|
|
4113
|
-
}
|
|
4114
|
-
if (url.pathname === "/api/finetune/submit" && request.method === "POST") {
|
|
4115
|
-
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
|
4116
|
-
if (!body.model_dir || !body.data_dir)
|
|
4117
|
-
return Response.json({ ok: false, error: "model_dir and data_dir required" }, { status: 400 });
|
|
4118
|
-
const store = await ensureJobs();
|
|
4119
|
-
const { submitSubprocess } = await import("./jobs");
|
|
4120
|
-
const { homedir } = await import("node:os");
|
|
4121
|
-
const adapterPath = (body.adapter_path as string) ||
|
|
4122
|
-
`${homedir()}/.cache/mlx-bun/adapters/adapter-${Date.now()}`;
|
|
4123
|
-
const { jobId } = submitSubprocess(store, "finetune",
|
|
4124
|
-
{ ...body, adapter_path: adapterPath }, adapterPath);
|
|
4125
|
-
return Response.json({ ok: true, job_id: jobId, adapter_path: adapterPath });
|
|
4126
|
-
}
|
|
4127
|
-
if (url.pathname === "/api/finetune/merge" && request.method === "POST") {
|
|
4128
|
-
const body = (await request.json().catch(() => ({}))) as {
|
|
4129
|
-
adapter_a?: string; adapter_b?: string; scales?: number[];
|
|
4130
|
-
};
|
|
4131
|
-
if (!body.adapter_a || !body.adapter_b)
|
|
4132
|
-
return Response.json({ ok: false, error: "adapter_a and adapter_b required" }, { status: 400 });
|
|
4133
|
-
try {
|
|
4134
|
-
const { mergeAdapters } = await import("./train");
|
|
4135
|
-
const { homedir } = await import("node:os");
|
|
4136
|
-
const mergedPath = `${homedir()}/.cache/mlx-bun/adapters/merged-${Date.now()}`;
|
|
4137
|
-
const stats = await mergeAdapters([body.adapter_a, body.adapter_b], mergedPath, body.scales);
|
|
4138
|
-
return Response.json({ ok: true, merged_path: mergedPath, stats });
|
|
4139
|
-
} catch (e) {
|
|
4140
|
-
return Response.json({ ok: false, error: (e as Error).message }, { status: 400 });
|
|
4141
|
-
}
|
|
4142
|
-
}
|
|
4143
|
-
if (url.pathname === "/api/finetune/export" && request.method === "POST") {
|
|
4144
|
-
const body = (await request.json().catch(() => ({}))) as {
|
|
4145
|
-
base_model?: string; adapter_path?: string; method?: string;
|
|
4146
|
-
};
|
|
4147
|
-
if (!body.base_model || !body.adapter_path)
|
|
4148
|
-
return Response.json({ ok: false, error: "base_model and adapter_path required" }, { status: 400 });
|
|
4149
|
-
try {
|
|
4150
|
-
const { exportAdapter } = await import("./train");
|
|
4151
|
-
const { homedir } = await import("node:os");
|
|
4152
|
-
const exportPath = `${homedir()}/.cache/mlx-bun/exports/export-${Date.now()}`;
|
|
4153
|
-
const manifest = await exportAdapter(exportPath, body.base_model, body.adapter_path, body.method);
|
|
4154
|
-
return Response.json({ ok: true, export_path: exportPath, manifest });
|
|
4155
|
-
} catch (e) {
|
|
4156
|
-
return Response.json({ ok: false, error: (e as Error).message }, { status: 400 });
|
|
4157
|
-
}
|
|
4158
|
-
}
|
|
4159
|
-
|
|
4160
|
-
// --- HF token settings + push-to-hub (model & dataset repos) ------
|
|
4161
|
-
if (url.pathname === "/api/settings/hf-token" && request.method === "GET") {
|
|
4162
|
-
const { hasHfToken } = await import("./hf-push");
|
|
4163
|
-
return Response.json({ ok: true, hasToken: hasHfToken() });
|
|
4164
|
-
}
|
|
4165
|
-
if (url.pathname === "/api/settings/hf-token" && request.method === "POST") {
|
|
4166
|
-
const body = (await request.json().catch(() => ({}))) as { token?: string };
|
|
4167
|
-
if (!body.token) return Response.json({ ok: false, error: "token required" }, { status: 400 });
|
|
4168
|
-
const { saveHfToken } = await import("./hf-push");
|
|
4169
|
-
saveHfToken(body.token);
|
|
4170
|
-
return Response.json({ ok: true });
|
|
4171
|
-
}
|
|
4172
|
-
|
|
4173
|
-
// --- Durable tool-approvals settings (plan §5.4/§6.5/§9 Phase 2) ---
|
|
4174
|
-
// The web chat's own "always allow this tool" list
|
|
4175
|
-
// (~/.mlx-bun/tool-approvals.json, src/tool-approvals.ts). The
|
|
4176
|
-
// approval card itself grants an always-allow via the `approval` WS
|
|
4177
|
-
// frame (alwaysAllow:true) — these REST routes are read/forget only,
|
|
4178
|
-
// for the settings panel's list view (matches the hf-token GET/POST
|
|
4179
|
-
// pattern's separation of "the card that grants" vs "settings that
|
|
4180
|
-
// manage" — there is no POST here on purpose, granting always goes
|
|
4181
|
-
// through the approval card, never a bare settings form).
|
|
4182
|
-
if (url.pathname === "/api/settings/tool-approvals" && request.method === "GET") {
|
|
4183
|
-
const { listAlwaysAllowedTools } = await import("./tool-approvals");
|
|
4184
|
-
return Response.json({ ok: true, alwaysAllow: listAlwaysAllowedTools() });
|
|
4185
|
-
}
|
|
4186
|
-
if (url.pathname === "/api/settings/tool-approvals" && request.method === "DELETE") {
|
|
4187
|
-
const body = (await request.json().catch(() => ({}))) as { tool?: string };
|
|
4188
|
-
if (!body.tool) return Response.json({ ok: false, error: "tool required" }, { status: 400 });
|
|
4189
|
-
const { revokeToolAlwaysAllowed } = await import("./tool-approvals");
|
|
4190
|
-
const file = revokeToolAlwaysAllowed(body.tool);
|
|
4191
|
-
return Response.json({ ok: true, alwaysAllow: Object.keys(file.allows).sort() });
|
|
4192
|
-
}
|
|
4193
|
-
{
|
|
4194
|
-
const m = url.pathname.match(/^\/api\/(quantize|finetune|dataset)\/push$/);
|
|
4195
|
-
if (m && request.method === "POST") {
|
|
4196
|
-
const kind = m[1]!;
|
|
4197
|
-
const body = (await request.json().catch(() => ({}))) as {
|
|
4198
|
-
job_id?: string; repo_id?: string; private?: boolean; source_path?: string;
|
|
4199
|
-
};
|
|
4200
|
-
if (!body.repo_id) return Response.json({ ok: false, error: "repo_id required" }, { status: 400 });
|
|
4201
|
-
const { getHfToken, uploadFolder } = await import("./hf-push");
|
|
4202
|
-
const token = getHfToken();
|
|
4203
|
-
if (!token)
|
|
4204
|
-
return Response.json({ ok: false, error: "no HF token saved — add one in Settings → Hugging Face" }, { status: 400 });
|
|
4205
|
-
const store = await ensureJobs();
|
|
4206
|
-
let dir = body.source_path;
|
|
4207
|
-
if (!dir && body.job_id) dir = store.get(body.job_id)?.output_path ?? undefined;
|
|
4208
|
-
if (!dir) return Response.json({ ok: false, error: "no source dir (pass job_id or source_path)" }, { status: 400 });
|
|
4209
|
-
try {
|
|
4210
|
-
const r = await uploadFolder(dir, body.repo_id, {
|
|
4211
|
-
repoType: kind === "dataset" ? "dataset" : "model",
|
|
4212
|
-
private: !!body.private, token,
|
|
4213
|
-
});
|
|
4214
|
-
return Response.json({ ok: true, url: r.url });
|
|
4215
|
-
} catch (e) {
|
|
4216
|
-
return Response.json({ ok: false, error: (e as Error).message }, { status: 400 });
|
|
4217
|
-
}
|
|
4218
|
-
}
|
|
4219
|
-
}
|
|
4220
|
-
|
|
4221
|
-
// gc plan/execute (web-ui-pass-plan.md #9): thin wrappers over the CLI's
|
|
4222
|
-
// own `mlx-bun gc` planner/executor (src/registry.ts planGc/executeGc).
|
|
4223
|
-
// Loopback-served admin route — no auth beyond the server's own bind
|
|
4224
|
-
// (matches every other /api/* route here). GET plans (read-only, cheap:
|
|
4225
|
-
// config.json + safetensors index reads, no tensor bytes); POST executes
|
|
4226
|
-
// and requires an explicit {yes:true} body (mirrors the CLI's --yes gate)
|
|
4227
|
-
// so a stray call can't delete anything by accident.
|
|
4228
|
-
if (url.pathname === "/api/gc/plan" && request.method === "GET") {
|
|
4229
|
-
const { planGc } = await import("./registry");
|
|
4230
|
-
const plans = planGc().filter(
|
|
4231
|
-
(p) => p.pruneSnapshots.length || p.skippedSnapshots.length || p.deadBlobs.length,
|
|
4232
|
-
);
|
|
4233
|
-
const superseded = plans.map((p) => ({
|
|
4234
|
-
repo_id: p.repoId,
|
|
4235
|
-
prune_snapshots: p.pruneSnapshots.length,
|
|
4236
|
-
skipped_snapshots: p.skippedSnapshots.length,
|
|
4237
|
-
dead_blobs: p.deadBlobs.length,
|
|
4238
|
-
reclaim_bytes: p.reclaimBytes,
|
|
4239
|
-
}));
|
|
4240
|
-
const reclaim_bytes = plans.reduce((a, p) => a + p.reclaimBytes, 0);
|
|
4241
|
-
return Response.json({ ok: true, superseded, reclaim_bytes });
|
|
4242
|
-
}
|
|
4243
|
-
if (url.pathname === "/api/gc/execute" && request.method === "POST") {
|
|
4244
|
-
const body = (await request.json().catch(() => ({}))) as { yes?: boolean };
|
|
4245
|
-
if (body.yes !== true)
|
|
4246
|
-
return Response.json({ ok: false, error: "pass {\"yes\": true} to confirm deletion" }, { status: 400 });
|
|
4247
|
-
const { planGc, executeGc, Registry } = await import("./registry");
|
|
4248
|
-
const plans = planGc().filter(
|
|
4249
|
-
(p) => p.pruneSnapshots.length || p.skippedSnapshots.length || p.deadBlobs.length,
|
|
4250
|
-
);
|
|
4251
|
-
const res = executeGc(plans);
|
|
4252
|
-
const reg = new Registry();
|
|
4253
|
-
try {
|
|
4254
|
-
await reg.scan(); // reap deleted snapshots from the registry
|
|
4255
|
-
} finally {
|
|
4256
|
-
reg.close();
|
|
4257
|
-
}
|
|
4258
|
-
libraryCache = null; // force /library to reflect the deletion
|
|
4259
|
-
return Response.json({
|
|
4260
|
-
ok: true,
|
|
4261
|
-
snapshots: res.snapshots, blobs: res.blobs, reclaimed_bytes: res.reclaimedBytes,
|
|
4262
|
-
});
|
|
4263
|
-
}
|
|
3665
|
+
const labResponse = await handleLabRoute(url, request, {
|
|
3666
|
+
ensureJobs,
|
|
3667
|
+
serverPort: () => server.port,
|
|
3668
|
+
invalidateLibrary: () => discoveryRoutes.invalidateLibrary(),
|
|
3669
|
+
});
|
|
3670
|
+
if (labResponse) return labResponse;
|
|
4264
3671
|
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
}
|
|
4271
|
-
{
|
|
4272
|
-
const m = url.pathname.match(/^\/api\/jobs\/([^/]+?)(\/stream)?$/);
|
|
4273
|
-
if (m && request.method === "GET") {
|
|
4274
|
-
const store = await ensureJobs();
|
|
4275
|
-
if (m[2]) {
|
|
4276
|
-
const { streamJobResponse } = await import("./jobs");
|
|
4277
|
-
return streamJobResponse(store, m[1]!);
|
|
4278
|
-
}
|
|
4279
|
-
const job = store.get(m[1]!);
|
|
4280
|
-
if (!job) return Response.json({ ok: false, error: "job not found" }, { status: 404 });
|
|
4281
|
-
return Response.json({ ok: true, job });
|
|
4282
|
-
}
|
|
4283
|
-
}
|
|
3672
|
+
const adminResponse = await handleAdminRoute(url, request, {
|
|
3673
|
+
ensureJobs,
|
|
3674
|
+
invalidateLibrary: () => discoveryRoutes.invalidateLibrary(),
|
|
3675
|
+
});
|
|
3676
|
+
if (adminResponse) return adminResponse;
|
|
4284
3677
|
|
|
4285
3678
|
return Response.json({ error: { message: "not found" } }, { status: 404 });
|
|
4286
3679
|
},
|
|
4287
3680
|
});
|
|
3681
|
+
serverLifecycles.set(serverRef, {
|
|
3682
|
+
flush: flushDurability,
|
|
3683
|
+
stats: durabilityStats,
|
|
3684
|
+
stopTimers: () => {
|
|
3685
|
+
if (demoteTimer) clearInterval(demoteTimer);
|
|
3686
|
+
demoteTimer = null;
|
|
3687
|
+
},
|
|
3688
|
+
});
|
|
4288
3689
|
return serverRef;
|
|
4289
3690
|
}
|