mlx-bun 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +362 -0
  3. package/THIRD_PARTY_LICENSES.md +41 -0
  4. package/bin/mlx-bun.mjs +32 -0
  5. package/docs/curve-designer.html +303 -0
  6. package/docs/design/adapters-end-to-end.md +201 -0
  7. package/docs/design/compat-cli-surface-design.md +227 -0
  8. package/docs/design/docs-reorg-plan.md +152 -0
  9. package/docs/design/hlg-sampling.md +340 -0
  10. package/docs/design/memory-system.md +339 -0
  11. package/docs/design/optimization_plan.md +344 -0
  12. package/docs/design/parallel-slots.md +515 -0
  13. package/docs/design/runtime-isolation.md +247 -0
  14. package/docs/design/segmented-backward-training.md +508 -0
  15. package/docs/investigations/chunk-finetune-experiment.md +84 -0
  16. package/docs/investigations/curve-bisector-routing.md +123 -0
  17. package/docs/investigations/curve-runs/bisector-route.json +98 -0
  18. package/docs/investigations/curve-runs/bisector-trace.json +1393 -0
  19. package/docs/investigations/curve-runs/curve-terrain.json +1179 -0
  20. package/docs/investigations/curve-runs/curve-terrain.svg +42 -0
  21. package/docs/investigations/curve-terrain.html +105 -0
  22. package/docs/investigations/expert-offload-single-user-moe.md +292 -0
  23. package/docs/investigations/hlg-ab.html +34 -0
  24. package/docs/investigations/hlg-explorer.html +238 -0
  25. package/docs/investigations/hlg-figs/cliff.svg +1 -0
  26. package/docs/investigations/hlg-figs/cross.svg +1 -0
  27. package/docs/investigations/hlg-figs/edges.svg +1 -0
  28. package/docs/investigations/hlg-figs/k20-grids.svg +1 -0
  29. package/docs/investigations/hlg-figs/k20-peaks.svg +1 -0
  30. package/docs/investigations/hlg-figs/lw-curve.svg +1 -0
  31. package/docs/investigations/hlg-figs/terrain.svg +1 -0
  32. package/docs/investigations/hlg-report.html +262 -0
  33. package/docs/investigations/hlg-runs/hlg-ab.json +149 -0
  34. package/docs/investigations/hlg-runs/hlg-blind-prompts.md +87 -0
  35. package/docs/investigations/hlg-runs/hlg-blind.json +107 -0
  36. package/docs/investigations/hlg-runs/hlg-cliff.json +197 -0
  37. package/docs/investigations/hlg-runs/hlg-cross.json +505 -0
  38. package/docs/investigations/hlg-runs/hlg-edge.json +388 -0
  39. package/docs/investigations/hlg-runs/hlg-eval1.json +25 -0
  40. package/docs/investigations/hlg-runs/hlg-frontier.json +86 -0
  41. package/docs/investigations/hlg-runs/hlg-grid.json +242 -0
  42. package/docs/investigations/hlg-runs/hlg-grid2.json +343 -0
  43. package/docs/investigations/hlg-runs/hlg-lw-coarse.json +110 -0
  44. package/docs/investigations/hlg-runs/hlg-map.json +383 -0
  45. package/docs/investigations/hlg-runs/run0-original.log +159 -0
  46. package/docs/investigations/hlg-runs/runA-recommended.log +131 -0
  47. package/docs/investigations/hlg-runs/runAA-cliff-cpm5.log +26 -0
  48. package/docs/investigations/hlg-runs/runAB-grid2-e4b.log +18 -0
  49. package/docs/investigations/hlg-runs/runAC-peak-e4b-partial.log +25 -0
  50. package/docs/investigations/hlg-runs/runAD-ab-samples.log +8 -0
  51. package/docs/investigations/hlg-runs/runAE-edge-e4b.log +51 -0
  52. package/docs/investigations/hlg-runs/runAF-logit-calibration.log +10 -0
  53. package/docs/investigations/hlg-runs/runAG-eval-config2.log +16 -0
  54. package/docs/investigations/hlg-runs/runB-gain-variants.log +192 -0
  55. package/docs/investigations/hlg-runs/runC-pivots.log +162 -0
  56. package/docs/investigations/hlg-runs/runD-oetf.log +132 -0
  57. package/docs/investigations/hlg-runs/runE-eotf.log +162 -0
  58. package/docs/investigations/hlg-runs/runF-shaper-working.log +86 -0
  59. package/docs/investigations/hlg-runs/runG-shaper-sweep.log +101 -0
  60. package/docs/investigations/hlg-runs/runH-wsweep.log +176 -0
  61. package/docs/investigations/hlg-runs/runI-asweep.log +176 -0
  62. package/docs/investigations/hlg-runs/runJ-knobmap.log +49 -0
  63. package/docs/investigations/hlg-runs/runK-frontier.log +19 -0
  64. package/docs/investigations/hlg-runs/runL-widemap.log +65 -0
  65. package/docs/investigations/hlg-runs/runM-frontier-emb.log +15 -0
  66. package/docs/investigations/hlg-runs/runN-verify.log +116 -0
  67. package/docs/investigations/hlg-runs/runO-frontier-seed5000.log +15 -0
  68. package/docs/investigations/hlg-runs/runP-grid-AxSm.log +15 -0
  69. package/docs/investigations/hlg-runs/runQ-grid-tgW.log +15 -0
  70. package/docs/investigations/hlg-runs/runR-blind-transcript.log +79 -0
  71. package/docs/investigations/hlg-runs/runS-cross-coherence.log +33 -0
  72. package/docs/investigations/hlg-runs/runT-cross-cpm5.log +33 -0
  73. package/docs/investigations/hlg-runs/runU-cross-12b.log +33 -0
  74. package/docs/investigations/hlg-runs/runV-lw-e4b.log +22 -0
  75. package/docs/investigations/hlg-runs/runW-lw-12b.log +22 -0
  76. package/docs/investigations/hlg-runs/runX-lw-cpm5.log +22 -0
  77. package/docs/investigations/hlg-runs/runY-cliff-e4b.log +26 -0
  78. package/docs/investigations/hlg-runs/runZ-cliff-12b.log +26 -0
  79. package/docs/investigations/hlg-sampling-investigation.md +970 -0
  80. package/docs/investigations/lab-build-journal.md +157 -0
  81. package/docs/investigations/pi-builtin-investigation.md +308 -0
  82. package/docs/investigations/starter-model-port-handoff.md +323 -0
  83. package/docs/planning/IDEAS.md +26 -0
  84. package/docs/planning/PRODUCT_ROADMAP.md +426 -0
  85. package/docs/planning/ResearchTopics.md +119 -0
  86. package/docs/planning/chunk-task-roadmap.md +77 -0
  87. package/docs/planning/journal.md +155 -0
  88. package/docs/reference/distribution.md +121 -0
  89. package/docs/reference/embedding.md +168 -0
  90. package/docs/reference/library-api.md +153 -0
  91. package/docs/reference/server-api.md +224 -0
  92. package/docs/reference/server-config.md +243 -0
  93. package/docs/segmented-backward-handoff.md +148 -0
  94. package/package.json +68 -0
  95. package/src/anthropic.ts +491 -0
  96. package/src/chat-template.ts +186 -0
  97. package/src/cli.ts +1075 -0
  98. package/src/config.ts +229 -0
  99. package/src/curve-sampler.ts +122 -0
  100. package/src/dataset/generators.ts +1087 -0
  101. package/src/dataset/index.ts +12 -0
  102. package/src/dataset/job.ts +81 -0
  103. package/src/dataset/llm.ts +71 -0
  104. package/src/dataset/registry.ts +528 -0
  105. package/src/download.ts +283 -0
  106. package/src/eval/README.md +86 -0
  107. package/src/eval/kl-prompts.ts +29 -0
  108. package/src/eval/kl.ts +294 -0
  109. package/src/eval/quality-db.ts +92 -0
  110. package/src/eval/runner.ts +102 -0
  111. package/src/eval/score.ts +49 -0
  112. package/src/eval/tasks/bfcl.ts +500 -0
  113. package/src/eval/tasks/gsm8k.ts +85 -0
  114. package/src/eval/tasks/hashhop.ts +109 -0
  115. package/src/eval/tasks/humaneval.ts +252 -0
  116. package/src/eval/tasks/ifeval.ts +354 -0
  117. package/src/eval/tasks/mmlu.ts +145 -0
  118. package/src/evaldb.ts +199 -0
  119. package/src/expert-offload-build.ts +77 -0
  120. package/src/expert-offload.ts +61 -0
  121. package/src/expert-trace.ts +67 -0
  122. package/src/fit.ts +254 -0
  123. package/src/generate.ts +451 -0
  124. package/src/harness-pi.ts +177 -0
  125. package/src/hf-push.ts +628 -0
  126. package/src/index.ts +22 -0
  127. package/src/jobs/db.ts +139 -0
  128. package/src/jobs/index.ts +29 -0
  129. package/src/jobs/job-entry.ts +106 -0
  130. package/src/jobs/runner.ts +304 -0
  131. package/src/jobs/sse.ts +114 -0
  132. package/src/jobs/types.ts +58 -0
  133. package/src/kv-store.ts +185 -0
  134. package/src/lora.ts +314 -0
  135. package/src/md-modules.d.ts +7 -0
  136. package/src/mlx/array.ts +237 -0
  137. package/src/mlx/autograd.ts +267 -0
  138. package/src/mlx/checkpoint.ts +116 -0
  139. package/src/mlx/compile.ts +158 -0
  140. package/src/mlx/custom-vjp.ts +140 -0
  141. package/src/mlx/ffi.ts +433 -0
  142. package/src/mlx/metal-kernel.ts +130 -0
  143. package/src/mlx/ops.ts +707 -0
  144. package/src/mmap.ts +90 -0
  145. package/src/model/batched-mask.ts +204 -0
  146. package/src/model/batched-rotating.ts +300 -0
  147. package/src/model/compiled-decode.ts +681 -0
  148. package/src/model/factory.ts +27 -0
  149. package/src/model/fingerprint.ts +33 -0
  150. package/src/model/flash-attention.ts +674 -0
  151. package/src/model/fused-decode-kernel.ts +305 -0
  152. package/src/model/fused-geglu-kernel.ts +87 -0
  153. package/src/model/gemma4-base.ts +1616 -0
  154. package/src/model/gemma4.ts +1012 -0
  155. package/src/model/generated/gemma4-12b.ts +526 -0
  156. package/src/model/generated/gemma4-26b.ts +500 -0
  157. package/src/model/generated/gemma4-e4b.ts +757 -0
  158. package/src/model/generated/index.ts +18 -0
  159. package/src/model/minicpm5.ts +278 -0
  160. package/src/model/qwen3-delta.ts +224 -0
  161. package/src/model/qwen3_5.ts +429 -0
  162. package/src/model/support.ts +46 -0
  163. package/src/native-pack.ts +152 -0
  164. package/src/pi-provider.ts +95 -0
  165. package/src/pi-terminal.ts +264 -0
  166. package/src/pi-web.ts +924 -0
  167. package/src/preflight.ts +123 -0
  168. package/src/prompt-cache.ts +113 -0
  169. package/src/quantize/allocator.ts +627 -0
  170. package/src/quantize/calibration.ts +180 -0
  171. package/src/quantize/config-writer.ts +132 -0
  172. package/src/quantize/index.ts +57 -0
  173. package/src/quantize/job.ts +136 -0
  174. package/src/quantize/quantizer.ts +462 -0
  175. package/src/quantize/safetensors-writer.ts +171 -0
  176. package/src/quantize/sensitivity.ts +234 -0
  177. package/src/registry.ts +336 -0
  178. package/src/responses.ts +774 -0
  179. package/src/safetensors.ts +158 -0
  180. package/src/sampler.ts +517 -0
  181. package/src/serve/batch-scheduler.ts +362 -0
  182. package/src/serve/generation-gateway.ts +160 -0
  183. package/src/server.ts +1891 -0
  184. package/src/spec/drafter.ts +261 -0
  185. package/src/spec/generate.ts +221 -0
  186. package/src/status-page.html +497 -0
  187. package/src/tokenizer.ts +38 -0
  188. package/src/tool-call.ts +242 -0
  189. package/src/train/dataset.ts +461 -0
  190. package/src/train/export.ts +32 -0
  191. package/src/train/forward.ts +229 -0
  192. package/src/train/index.ts +43 -0
  193. package/src/train/job.ts +148 -0
  194. package/src/train/lora-params.ts +194 -0
  195. package/src/train/loss.ts +342 -0
  196. package/src/train/merge.ts +195 -0
  197. package/src/train/optimizer.ts +176 -0
  198. package/src/train/rank.ts +101 -0
  199. package/src/train/segmented.ts +520 -0
  200. package/src/train/trainer.ts +600 -0
  201. package/src/tui.ts +161 -0
  202. package/src/vision/embedder.ts +168 -0
  203. package/src/vision/preprocess.ts +247 -0
  204. package/src/vision/prompt.ts +158 -0
  205. package/src/vision/siglip.ts +599 -0
  206. package/src/web/app.html +2471 -0
  207. package/src/web/skills/web-research/SKILL.md +44 -0
  208. package/src/web/skills.ts +59 -0
  209. package/src/web-tools.ts +426 -0
  210. package/src/weights.ts +91 -0
package/src/server.ts ADDED
@@ -0,0 +1,1891 @@
1
+ // OpenAI-compatible HTTP server: /v1/chat/completions (+ SSE streaming)
2
+ // and /v1/models. Phase 4 core — tool calling, vision, and the
3
+ // byte-capped prompt cache land on top of this.
4
+ //
5
+ // Original code; behavioral reference: mlx-lm server.py (MIT) for the
6
+ // protocol surface and stop/finish semantics. No code ported — Bun.serve
7
+ // and the generation queue are structurally different.
8
+ //
9
+ // Generation is serialized through a single queue (one GPU, batch=1).
10
+
11
+ import type { Server } from "bun";
12
+ // Embedded web app — the unified SPA (chat / quantize / finetune /
13
+ // dataset / status). `with { type: "text" }` inlines the file in both
14
+ // `bun run` and the compiled single binary. bun-types types *.html
15
+ // imports as HTMLBundle (the html loader), but the text attribute makes
16
+ // the runtime value a string — hence the double cast.
17
+ import appHtml from "./web/app.html" with { type: "text" };
18
+ import curveDesignerHtml from "../docs/curve-designer.html" with { type: "text" };
19
+ import pkgJson from "../package.json" with { type: "json" };
20
+ import { readFileSync } from "node:fs";
21
+ const APP_PAGE = appHtml as unknown as string;
22
+ const pkgVersion = (pkgJson as { version: string }).version;
23
+ import { loadModelConfig, type KvQuantSpec, type ModelConfig } from "./config";
24
+ import { Weights } from "./weights";
25
+ import { Gemma4Model } from "./model/gemma4";
26
+ import { createModel, type RuntimeModel } from "./model/factory";
27
+ import { isMiniCPM5Config, isQwen35Config, isSupportedModelRecord } from "./model/support";
28
+ import { generate, type GenerateOptions } from "./generate";
29
+ import type { HlgConfig } from "./sampler";
30
+ import { isMonotone, CURVE_UMIN, type CurveParams } from "./curve-sampler";
31
+ const CURVE_PAGE = curveDesignerHtml as unknown as string;
32
+ import { GenerationGateway } from "./serve/generation-gateway";
33
+ import {
34
+ ChatTemplate, type ChatMessage, type ToolDefinition,
35
+ } from "./chat-template";
36
+ import { loadTokenizer, type LoadedTokenizer } from "./tokenizer";
37
+ import {
38
+ CHANNEL_END, CHANNEL_START, parseGeneratedToolCalls, parseToolCalls,
39
+ TOOL_CALL_END, TOOL_CALL_START,
40
+ } from "./tool-call";
41
+ import { PromptCache } from "./prompt-cache";
42
+ import {
43
+ anthropicToChatBody, chatJsonToAnthropic, translateOpenAiSse,
44
+ type AnthropicRequest,
45
+ } from "./anthropic";
46
+ import {
47
+ ResponseStore, chatJsonToResponses, outputItemsToInputItems,
48
+ responsesToChatBody, translateOpenAiSseToResponses,
49
+ type ResponsesRequest,
50
+ } from "./responses";
51
+ import { AdapterManager, listAvailableAdapters } from "./lora";
52
+ import { fit } from "./fit";
53
+ import { setMemoryLimit } from "./mlx/ffi";
54
+ import { VisionTower } from "./vision/embedder";
55
+ import { SiglipVisionTower, parseSiglipConfig } from "./vision/siglip";
56
+ import {
57
+ buildVisionPrompt, extractImages,
58
+ type VisionTokenIds, type VisionEncoder,
59
+ } from "./vision/prompt";
60
+ import { makePiWsHandler, type PiWsData } from "./pi-web";
61
+
62
+ export interface ServerOptions {
63
+ /** Byte cap for the prompt (KV) cache. Default 2 GB. */
64
+ promptCacheBytes?: number;
65
+ /** KV quantization override. When unset: apply ctx.kvConfig (mixed
66
+ * per-layer) for serial serving, but bf16 under `--batch N` (the batched
67
+ * engine is bf16-only — a mode switch, see `batch` below). "config" forces
68
+ * the model's kv_config even under batching (those requests then route to
69
+ * the serial path); "off" forces bf16; a number forces uniform bits
70
+ * (group size 64, start 0) ignoring the config file. */
71
+ kvQuant?: "off" | "config" | number;
72
+ /** Memory budget for the serving process (admission control — Phase 5).
73
+ * Requests whose prompt + max_tokens exceed the budget's max safe
74
+ * context are rejected with 400 instead of crashing the GPU: the OOM
75
+ * crash class is UNCATCHABLE (Phase 6 — mlx throws from a Metal
76
+ * completion handler ⇒ std::terminate; optiq serve died exactly this
77
+ * way loading the 26B). Also caps the mlx allocator
78
+ * (mlx_set_memory_limit) as defense in depth. Default: machine RAM ×
79
+ * WIRED_FRACTION, admission check only, allocator untouched. */
80
+ memoryBudgetBytes?: number;
81
+ /** Who owns this server's lifetime: "serve" (persistent, mlx-bun
82
+ * serve) or "pi-session" (dies with the pi session that started
83
+ * it). Exposed at /stats.server so other mlx-bun processes can
84
+ * warn before attaching to a server that may vanish. */
85
+ owner?: "serve" | "pi-session" | "embedded";
86
+ /** Interface to bind (Bun.serve hostname). Default: Bun's default
87
+ * (all interfaces); pass "127.0.0.1" for loopback-only. */
88
+ hostname?: string;
89
+ /** Server-wide default for the chat template's `enable_thinking`
90
+ * variable (MiniCPM5/CPM and other hybrid-reasoning models). A
91
+ * request's `chat_template_kwargs.enable_thinking` overrides it per
92
+ * call; undefined ⇒ fall back to the model's own default (false for
93
+ * MiniCPM5). Set via `--thinking true|false`. */
94
+ defaultThinking?: boolean;
95
+ /** Server-wide sampling defaults (set via --temperature/--top-p/--top-k).
96
+ * Precedence: an explicit per-request field wins, then these, then the
97
+ * model's generation_config.json, then the built-in fallback. Lets the
98
+ * browser chat (which sends no sampling fields) be steered from the CLI. */
99
+ defaultTemperature?: number;
100
+ defaultTopP?: number;
101
+ defaultTopK?: number;
102
+ /** Max concurrent requests batched through the mlx-lm-parity engine
103
+ * (`--batch N`). Default 1 = today's serialized single-queue path. >1 opts
104
+ * the WHOLE server into the batched engine (continuous batching, B floats
105
+ * 1..N, bit-parity with mlx-lm B=N) — a mode switch, not a load-dependent
106
+ * fallback. See docs/design/parallel-slots.md. NOTE: the batched executor
107
+ * is mid-build; until it lands, >1 warns and runs serially. */
108
+ batch?: number;
109
+ /** HLG tone-curve sampling default (set via --hlg-sampling on + sub-knobs).
110
+ * A per-request `hlg` object overrides it field-by-field. Off when unset. */
111
+ hlg?: HlgConfig;
112
+ }
113
+
114
+ export interface ServerContext {
115
+ model: RuntimeModel;
116
+ tokenizer: LoadedTokenizer;
117
+ template: ChatTemplate;
118
+ modelId: string;
119
+ /** Lazily-loaded vision tower cache — null until the first image request
120
+ * (see `getVisionTower`). The tower (SigLIP ~hundreds of MB, encoder-free
121
+ * smaller) is not loaded for text-only sessions. */
122
+ vision: VisionEncoder | null;
123
+ /** Loads + selects the vision tower on demand; null when the model has no
124
+ * (supported) vision sidecar. Invoked at most once, then cached in
125
+ * `vision`. */
126
+ loadVision: (() => VisionEncoder) | null;
127
+ visionTokenIds: VisionTokenIds;
128
+ adapters: AdapterManager;
129
+ /** Per-layer KV quantization from the repo's kv_config.json (null if
130
+ * absent). Applied by default — optiq serve's headline behavior;
131
+ * ServerOptions.kvQuant overrides ("off" | uniform bits). */
132
+ kvConfig: KvQuantSpec[] | null;
133
+ /** Model-author recommended sampling from generation_config.json —
134
+ * optiq serve injects these as server defaults (gen_config.py);
135
+ * explicit request fields always win. */
136
+ genDefaults: GenSamplingDefaults;
137
+ }
138
+
139
+ export interface GenSamplingDefaults {
140
+ temperature?: number;
141
+ topP?: number;
142
+ topK?: number;
143
+ repetitionPenalty?: number;
144
+ }
145
+
146
+ async function loadGenSamplingDefaults(modelDir: string): Promise<GenSamplingDefaults> {
147
+ const file = Bun.file(`${modelDir}/generation_config.json`);
148
+ if (!(await file.exists())) return {};
149
+ try {
150
+ const raw = (await file.json()) as Record<string, unknown>;
151
+ const num = (v: unknown) => (typeof v === "number" ? v : undefined);
152
+ return {
153
+ temperature: num(raw.temperature),
154
+ topP: num(raw.top_p),
155
+ topK: num(raw.top_k),
156
+ repetitionPenalty: num(raw.repetition_penalty),
157
+ };
158
+ } catch {
159
+ return {};
160
+ }
161
+ }
162
+
163
+ export async function loadContext(
164
+ modelDir: string, modelId?: string,
165
+ opts: { memoryBudgetBytes?: number } = {},
166
+ ): Promise<ServerContext> {
167
+ const config = await loadModelConfig(modelDir);
168
+ const weights = await Weights.open(modelDir);
169
+ // memoryBudget enforcement at load (Phase 5): Weights.open only mmaps
170
+ // (no GPU allocation yet), so a model whose weights can never serve
171
+ // within the budget is refused HERE — before any unified-memory
172
+ // commitment — with an actionable error instead of a Metal OOM later.
173
+ if (opts.memoryBudgetBytes) {
174
+ const weightsBytes = [...weights.shards.files.values()]
175
+ .reduce((a, f) => a + f.mmap.size, 0);
176
+ const report = fit(config, weightsBytes, 1, undefined, undefined, 0, opts.memoryBudgetBytes);
177
+ if (report.maxSafeContext < 1)
178
+ throw new Error(
179
+ `model does not fit the memory budget: weights ${(weightsBytes / 1e9).toFixed(2)} GB ` +
180
+ `+ prefill transient leave no room for any context within ` +
181
+ `${(opts.memoryBudgetBytes / 1e9).toFixed(2)} GB`,
182
+ );
183
+ }
184
+ // generated-specialization dispatch by config fingerprint (Phase C);
185
+ // unmatched configs run the monolith — slow, never broken
186
+ const model = createModel(weights, config);
187
+ const tokenizer = await loadTokenizer(modelDir);
188
+ // Generation must stop on the tokenizer's eos_token — the chat turn
189
+ // terminator (e.g. Qwen <|im_end|> = 248046). Some configs (Qwen3.5-4B)
190
+ // declare a different eos_token_id in config.json than the chat format
191
+ // emits, so without this a turn never ends and generation runs away,
192
+ // hallucinating both sides of the dialogue until max_tokens. mlx-lm stops on
193
+ // the tokenizer eos; union it in. No-op when already present (Gemma, 27B).
194
+ if (tokenizer.eosTokenId != null && !config.eosTokenIds.includes(tokenizer.eosTokenId))
195
+ config.eosTokenIds = [...config.eosTokenIds, tokenizer.eosTokenId];
196
+ return {
197
+ model,
198
+ adapters: new AdapterManager(model),
199
+ kvConfig: config.kvQuant,
200
+ genDefaults: await loadGenSamplingDefaults(modelDir),
201
+ tokenizer,
202
+ template: await ChatTemplate.load(modelDir),
203
+ modelId: modelId ?? modelDir.split("/").filter(Boolean).at(-1)!,
204
+ // Vision is loaded lazily (getVisionTower) — text-only sessions never
205
+ // pay for the tower. The loader picks the encoder-free gemma4_unified
206
+ // (12B) tower vs the SigLIP encoder (e2b/e4b/26B/31B) by the sidecar's
207
+ // vision_config.model_type. Vision sidecars are a Gemma4 feature;
208
+ // MiniCPM5 never ships one.
209
+ vision: null,
210
+ loadVision: makeVisionLoader(modelDir, model, config),
211
+ visionTokenIds: {
212
+ imageTokenId: (config.raw.image_token_id as number) ?? 258880,
213
+ boiTokenId: (config.raw.boi_token_id as number) ?? 255999,
214
+ eoiTokenId: (config.raw.eoi_token_id as number) ?? 258882,
215
+ },
216
+ };
217
+ }
218
+
219
+ /** Build the on-demand vision-tower loader, selecting the encoder-free
220
+ * (gemma4_unified, 12B) tower vs the SigLIP encoder (gemma4_vision:
221
+ * e2b/e4b/26B/31B) by the sidecar's vision_config.model_type. Returns null
222
+ * when the model has no usable vision sidecar. */
223
+ function makeVisionLoader(
224
+ modelDir: string, model: RuntimeModel, config: ModelConfig,
225
+ ): (() => VisionEncoder) | null {
226
+ if (!(config.hasVisionSidecar && model instanceof Gemma4Model)) return null;
227
+ const vc = config.raw.vision_config as Record<string, any> | undefined;
228
+ if (vc?.model_type === "gemma4_vision") {
229
+ const sigCfg = parseSiglipConfig(vc);
230
+ return () => SiglipVisionTower.load(modelDir, sigCfg, model.embedScale);
231
+ }
232
+ // gemma4_unified_vision (or unlabelled): the encoder-free patch embedder.
233
+ return () => VisionTower.load(modelDir, model.embedScale, config.text.rmsNormEps);
234
+ }
235
+
236
+ /** Lazily load + cache the vision tower on first use. A sidecar that fails
237
+ * to load is a capability gap, not a fatal error: returns null and the
238
+ * request is answered with a 400 (the loader is cleared so we don't retry
239
+ * a known-bad load every request). */
240
+ function getVisionTower(ctx: ServerContext): VisionEncoder | null {
241
+ if (ctx.vision) return ctx.vision;
242
+ if (!ctx.loadVision) return null;
243
+ try {
244
+ ctx.vision = ctx.loadVision();
245
+ return ctx.vision;
246
+ } catch (e) {
247
+ console.warn(`vision sidecar not loadable (${(e as Error).message}) — serving text-only`);
248
+ ctx.loadVision = null;
249
+ return null;
250
+ }
251
+ }
252
+
253
+ interface ChatRequest {
254
+ messages: ChatMessage[];
255
+ stream?: boolean;
256
+ max_tokens?: number;
257
+ max_completion_tokens?: number;
258
+ temperature?: number;
259
+ top_p?: number;
260
+ top_k?: number;
261
+ seed?: number;
262
+ repetition_penalty?: number;
263
+ /** OpenAI stop sequences: plain string or array (spec allows up to 4).
264
+ * Matched on DECODED text, not token ids — see StopMatcher. */
265
+ stop?: string | string[];
266
+ tools?: ToolDefinition[];
267
+ tool_choice?: "auto" | "none" | { type: string; function?: { name: string } };
268
+ /** Forwarded to HF chat templates, matching optiq serve. MiniCPM5 uses
269
+ * enable_thinking to select direct answers vs the <think> channel. */
270
+ chat_template_kwargs?: {
271
+ enable_thinking?: boolean;
272
+ [key: string]: unknown;
273
+ };
274
+ /** OpenAI reasoning control. For models with a switchable <think> channel
275
+ * (Qwen3.5/MiniCPM5) it gates enable_thinking: "none" → off, any level → on.
276
+ * This is what Pi sends when the provider advertises reasoning. */
277
+ reasoning_effort?: "none" | "minimal" | "low" | "medium" | "high";
278
+ /** Mounted LoRA adapter selection: "id", "a+b" (stacked), or "none". */
279
+ adapter?: string;
280
+ /** HLG tone-curve sampling override (per request). Snake_case wire fields,
281
+ * merged over the server's --hlg-sampling config. docs/design/hlg-sampling.md. */
282
+ hlg?: {
283
+ enabled?: boolean;
284
+ width?: number;
285
+ shoulder?: number;
286
+ toe?: number;
287
+ pivot_offset?: number;
288
+ };
289
+ }
290
+
291
+ /** Per-field default HLG knobs when enabling without specifying them. */
292
+ const HLG_DEFAULTS = { width: 4, shoulder: 4, toe: 6, pivotOffset: 6 } as const;
293
+
294
+ /** Resolve the effective HLG config: a per-request `hlg` object overrides the
295
+ * server's --hlg-sampling default field-by-field. Returns undefined (HLG off)
296
+ * unless enabled by the request or the server. */
297
+ function resolveHlg(
298
+ reqHlg: ChatRequest["hlg"],
299
+ serverHlg: HlgConfig | undefined,
300
+ ): HlgConfig | undefined {
301
+ const enabled = reqHlg?.enabled ?? serverHlg?.enabled ?? false;
302
+ if (!enabled) return undefined;
303
+ const base = serverHlg ?? HLG_DEFAULTS;
304
+ return {
305
+ enabled: true,
306
+ width: reqHlg?.width ?? base.width,
307
+ shoulder: reqHlg?.shoulder ?? base.shoulder,
308
+ toe: reqHlg?.toe ?? base.toe,
309
+ pivotOffset: reqHlg?.pivot_offset ?? base.pivotOffset,
310
+ pivot: "top",
311
+ };
312
+ }
313
+
314
+ interface OpenAIToolCall {
315
+ id: string;
316
+ type: "function";
317
+ function: { name: string; arguments: string };
318
+ }
319
+
320
+ type ToolStreamMode = "gemma-sentinel" | "plain" | "buffered-text";
321
+
322
+ /** Routes generated tokens. Gemma uses family-specific sentinel token ids;
323
+ * MiniCPM5 and other text-template models use decoded-text parsing so
324
+ * ordinary tokenizer ids like "<" are never swallowed globally. Exported for
325
+ * unit tests (gemma-channel reasoning split). */
326
+ export class ToolAwareStream {
327
+ readonly #decoder: StreamDecoder;
328
+ #inTool = false;
329
+ #toolTokens: number[] = [];
330
+ #text = "";
331
+ /** Chars of #text already returned as content. */
332
+ #sent = 0;
333
+ /** Index where tool markup starts; content emission stops there. */
334
+ #frozen = -1;
335
+ #textToolCalls: OpenAIToolCall[] | null = null;
336
+ #textToolParseFailed = false;
337
+ readonly toolSegments: number[][] = [];
338
+
339
+ /** Gemma reasoning-channel state (gemma-sentinel mode). The model wraps
340
+ * chain-of-thought as `<|channel>thought\n…<channel|>` using special tokens
341
+ * 100/101 that the content decoder strips, so reasoning is captured here at
342
+ * the token level. A SEPARATE decoder keeps the reasoning byte-stream's
343
+ * incremental state independent of content's. The `thought\n` channel-name
344
+ * line is stripped before the reasoning text (per the model's parser spec,
345
+ * it sits outside the captured thought). */
346
+ readonly #channelDecoder: StreamDecoder;
347
+ #inChannel = false;
348
+ #channelNamePending = "";
349
+ #channelNameDone = false;
350
+ #reasoning = "";
351
+
352
+ /** Decoded-text markers that open tool markup (oracle: the streaming
353
+ * parser buffers from `<tool_call`/`<function` on, never the whole
354
+ * response — content before a tool call still streams live). */
355
+ static readonly TOOL_MARKERS = ["<tool_call>", "<function"];
356
+
357
+ constructor(
358
+ readonly tokenizer: LoadedTokenizer,
359
+ readonly mode: ToolStreamMode,
360
+ readonly tools: ToolDefinition[] | null,
361
+ ) {
362
+ this.#decoder = new StreamDecoder(tokenizer, mode !== "buffered-text");
363
+ this.#channelDecoder = new StreamDecoder(tokenizer, true);
364
+ }
365
+
366
+ /** Feed decoded channel text, stripping the leading `thought\n` name line
367
+ * (which may arrive across tokens). Returns the reasoning delta. */
368
+ #feedChannel(text: string): string {
369
+ if (this.#channelNameDone) return text;
370
+ this.#channelNamePending += text;
371
+ const nl = this.#channelNamePending.indexOf("\n");
372
+ if (nl === -1) return ""; // still inside the channel-name line
373
+ this.#channelNameDone = true;
374
+ const rest = this.#channelNamePending.slice(nl + 1);
375
+ this.#channelNamePending = "";
376
+ return rest;
377
+ }
378
+
379
+ /** Drain reasoning captured since the last call (gemma-channel thinking). */
380
+ takeReasoning(): string {
381
+ const r = this.#reasoning;
382
+ this.#reasoning = "";
383
+ return r;
384
+ }
385
+
386
+ /** Emit the longest #text prefix that cannot be (the start of) tool
387
+ * markup; hold back ambiguous tails until disambiguated. */
388
+ #textDelta(): string {
389
+ if (this.#frozen >= 0) return "";
390
+ let markerAt = -1;
391
+ for (const mk of ToolAwareStream.TOOL_MARKERS) {
392
+ const i = this.#text.indexOf(mk, this.#sent);
393
+ if (i !== -1 && (markerAt === -1 || i < markerAt)) markerAt = i;
394
+ }
395
+ if (markerAt !== -1) {
396
+ this.#frozen = markerAt;
397
+ const out = this.#text.slice(this.#sent, markerAt);
398
+ this.#sent = markerAt;
399
+ return out;
400
+ }
401
+ let hold = 0;
402
+ for (const mk of ToolAwareStream.TOOL_MARKERS) {
403
+ const max = Math.min(mk.length - 1, this.#text.length - this.#sent);
404
+ for (let k = max; k > hold; k--) {
405
+ if (this.#text.endsWith(mk.slice(0, k))) { hold = k; break; }
406
+ }
407
+ }
408
+ const limit = this.#text.length - hold;
409
+ if (limit <= this.#sent) return "";
410
+ const out = this.#text.slice(this.#sent, limit);
411
+ this.#sent = limit;
412
+ return out;
413
+ }
414
+
415
+ /** Returns the content text delta for this token ("" while capturing). */
416
+ push(token: number): string {
417
+ if (this.mode !== "gemma-sentinel") {
418
+ this.#text += this.#decoder.push(token);
419
+ if (this.mode === "plain") {
420
+ const out = this.#text.slice(this.#sent);
421
+ this.#sent = this.#text.length;
422
+ return out;
423
+ }
424
+ return this.#textDelta();
425
+ }
426
+ if (this.#inTool) {
427
+ if (token === TOOL_CALL_END) {
428
+ this.#inTool = false;
429
+ this.toolSegments.push(this.#toolTokens);
430
+ this.#toolTokens = [];
431
+ } else {
432
+ this.#toolTokens.push(token);
433
+ }
434
+ return "";
435
+ }
436
+ // Reasoning channel: tokens between <|channel> and <channel|> are thought,
437
+ // captured to #reasoning (drained via takeReasoning), never content. An
438
+ // empty block (<|channel>thought\n<channel|>, emitted by larger Gemmas even
439
+ // with thinking off) yields no reasoning and leaks nothing.
440
+ if (this.#inChannel) {
441
+ if (token === CHANNEL_END) {
442
+ this.#inChannel = false;
443
+ this.#reasoning += this.#feedChannel(this.#channelDecoder.flush());
444
+ } else {
445
+ this.#reasoning += this.#feedChannel(this.#channelDecoder.push(token));
446
+ }
447
+ return "";
448
+ }
449
+ if (token === CHANNEL_START) {
450
+ this.#inChannel = true;
451
+ this.#channelNameDone = false;
452
+ this.#channelNamePending = "";
453
+ return "";
454
+ }
455
+ if (token === TOOL_CALL_START) {
456
+ this.#inTool = true;
457
+ return "";
458
+ }
459
+ return this.#decoder.push(token);
460
+ }
461
+
462
+ flush(): string {
463
+ if (this.mode !== "gemma-sentinel") {
464
+ this.#text += this.#decoder.flush();
465
+ if (this.mode === "buffered-text") {
466
+ const calls = this.toolCalls();
467
+ if (calls.length && !this.#textToolParseFailed && this.#frozen >= 0) {
468
+ // markup parsed into tool_calls — emit any prose still held
469
+ // before it; the markup itself never reaches content
470
+ const out = this.#text.slice(this.#sent, this.#frozen);
471
+ this.#sent = this.#text.length;
472
+ return out;
473
+ }
474
+ // no tool call (or parse fallback): release everything withheld
475
+ const out = this.#text.slice(this.#sent);
476
+ this.#sent = this.#text.length;
477
+ return out;
478
+ }
479
+ const out = this.#text.slice(this.#sent);
480
+ this.#sent = this.#text.length;
481
+ return out;
482
+ }
483
+ if (this.#inChannel) {
484
+ // truncated mid-reasoning (hit max_tokens); surface the partial thought
485
+ this.#reasoning += this.#feedChannel(this.#channelDecoder.flush());
486
+ this.#inChannel = false;
487
+ return "";
488
+ }
489
+ if (this.#inTool && this.#toolTokens.length) {
490
+ // truncated mid-tool-call (hit max_tokens); surface what we have
491
+ this.toolSegments.push(this.#toolTokens);
492
+ this.#toolTokens = [];
493
+ }
494
+ return this.#decoder.flush();
495
+ }
496
+
497
+ toolCalls(): OpenAIToolCall[] {
498
+ if (this.mode !== "gemma-sentinel") {
499
+ if (this.#textToolCalls) return this.#textToolCalls;
500
+ try {
501
+ this.#textToolCalls = parseGeneratedToolCalls(this.#text, this.tools ?? []).map((c) => ({
502
+ id: `call_${crypto.randomUUID().slice(0, 8)}`,
503
+ type: "function",
504
+ function: { name: c.name, arguments: JSON.stringify(c.arguments) },
505
+ }));
506
+ } catch {
507
+ this.#textToolParseFailed = true;
508
+ this.#textToolCalls = [];
509
+ }
510
+ return this.#textToolCalls;
511
+ }
512
+ const out: OpenAIToolCall[] = [];
513
+ for (const seg of this.toolSegments) {
514
+ const text = this.tokenizer.decode(seg, false); // keep <|"|> markers
515
+ for (const c of parseToolCalls(text)) {
516
+ out.push({
517
+ id: `call_${crypto.randomUUID().slice(0, 8)}`,
518
+ type: "function",
519
+ function: { name: c.name, arguments: JSON.stringify(c.arguments) },
520
+ });
521
+ }
522
+ }
523
+ return out;
524
+ }
525
+ }
526
+
527
+ /** Decoded-text stop-sequence matcher with streaming hold-back. Matching
528
+ * on text (not token ids) catches sequences that span token boundaries
529
+ * or tokenize differently in context; current mlx-lm uses token-id
530
+ * state machines and misses those. Text that could be the start of a
531
+ * stop sequence is withheld until disambiguated, so SSE clients never
532
+ * see any part of the stop sequence itself. */
533
+ export class StopMatcher {
534
+ #pending = "";
535
+ stopped = false;
536
+
537
+ constructor(readonly sequences: string[]) {}
538
+
539
+ /** Feed a text delta; returns the prefix that is now safe to emit.
540
+ * After a match fires (`stopped`), text before the match is returned
541
+ * and everything from the match on is discarded. */
542
+ push(text: string): string {
543
+ if (this.stopped) return "";
544
+ if (this.sequences.length === 0) return text;
545
+ this.#pending += text;
546
+ // earliest full match wins
547
+ let cut = -1;
548
+ for (const seq of this.sequences) {
549
+ const i = this.#pending.indexOf(seq);
550
+ if (i !== -1 && (cut === -1 || i < cut)) cut = i;
551
+ }
552
+ if (cut !== -1) {
553
+ this.stopped = true;
554
+ const out = this.#pending.slice(0, cut);
555
+ this.#pending = "";
556
+ return out;
557
+ }
558
+ // hold back the longest tail that is a proper prefix of any sequence
559
+ let hold = 0;
560
+ for (const seq of this.sequences) {
561
+ const max = Math.min(seq.length - 1, this.#pending.length);
562
+ for (let k = max; k > hold; k--) {
563
+ if (this.#pending.endsWith(seq.slice(0, k))) {
564
+ hold = k;
565
+ break;
566
+ }
567
+ }
568
+ }
569
+ if (hold === 0) {
570
+ const out = this.#pending;
571
+ this.#pending = "";
572
+ return out;
573
+ }
574
+ const out = this.#pending.slice(0, -hold);
575
+ this.#pending = this.#pending.slice(-hold);
576
+ return out;
577
+ }
578
+
579
+ /** Generation ended without a match — release any held-back text. */
580
+ flush(): string {
581
+ const out = this.#pending;
582
+ this.#pending = "";
583
+ return out;
584
+ }
585
+ }
586
+
587
+ /** Split Qwen-style inline <think>...</think> markup into OpenAI reasoning
588
+ * deltas/content. This keeps raw tags out of normal chat text while giving
589
+ * pi (TUI + web) proper thinking_delta events. It is streaming-safe: partial
590
+ * tag prefixes are held until disambiguated. */
591
+ export class ThinkingTagSplitter {
592
+ #pending = "";
593
+ #inThinking: boolean;
594
+ reasoning = "";
595
+ content = "";
596
+
597
+ /** `startInThinking` seeds the parser INSIDE a <think> block. Needed for
598
+ * templates (Qwen3.5, MiniCPM5) that prime an OPEN `<think>` in the
599
+ * generation prompt when thinking is enabled: the model's output then
600
+ * starts mid-reasoning and emits only the closing `</think>`, never an
601
+ * opening tag. Without this seed the whole chain-of-thought leaks into
602
+ * `content` and the `reasoning` field stays empty. */
603
+ constructor(private readonly enabled: boolean, startInThinking = false) {
604
+ this.#inThinking = startInThinking;
605
+ }
606
+
607
+ #safePrefixUntilTag(tag: string): string {
608
+ const i = this.#pending.indexOf(tag);
609
+ if (i !== -1) return this.#pending.slice(0, i);
610
+ let hold = 0;
611
+ for (let k = Math.min(tag.length - 1, this.#pending.length); k > 0; k--) {
612
+ if (this.#pending.endsWith(tag.slice(0, k))) { hold = k; break; }
613
+ }
614
+ return this.#pending.slice(0, this.#pending.length - hold);
615
+ }
616
+
617
+ push(text: string): { content: string; reasoning: string } {
618
+ if (!this.enabled) {
619
+ this.content += text;
620
+ return { content: text, reasoning: "" };
621
+ }
622
+ this.#pending += text;
623
+ let content = "";
624
+ let reasoning = "";
625
+ while (this.#pending) {
626
+ const tag = this.#inThinking ? "</think>" : "<think>";
627
+ const i = this.#pending.indexOf(tag);
628
+ const emit = i === -1 ? this.#safePrefixUntilTag(tag) : this.#pending.slice(0, i);
629
+ if (!emit && i === -1) break;
630
+ if (emit) {
631
+ if (this.#inThinking) reasoning += emit;
632
+ else content += emit;
633
+ this.#pending = this.#pending.slice(emit.length);
634
+ }
635
+ if (i !== -1 && this.#pending.startsWith(tag)) {
636
+ this.#pending = this.#pending.slice(tag.length);
637
+ this.#inThinking = !this.#inThinking;
638
+ continue;
639
+ }
640
+ if (i === -1) break;
641
+ }
642
+ this.content += content;
643
+ this.reasoning += reasoning;
644
+ return { content, reasoning };
645
+ }
646
+
647
+ flush(): { content: string; reasoning: string } {
648
+ if (!this.enabled) return { content: "", reasoning: "" };
649
+ const out = this.#inThinking
650
+ ? { content: "", reasoning: this.#pending }
651
+ : { content: this.#pending, reasoning: "" };
652
+ this.#pending = "";
653
+ this.content += out.content;
654
+ this.reasoning += out.reasoning;
655
+ return out;
656
+ }
657
+ }
658
+
659
+ /** True when a rendered prompt ends INSIDE an unclosed `<think>` block — the
660
+ * generation prompt primed reasoning (Qwen3.5 / MiniCPM5 with thinking on),
661
+ * so the model continues the chain-of-thought and emits only the closing
662
+ * `</think>`. Seeds ThinkingTagSplitter so reasoning is split out correctly.
663
+ * Thinking-off primes a CLOSED empty block (`<think>\n\n</think>`), and
664
+ * no-thinking templates have no `<think>` at all — both return false. */
665
+ export function promptEndsInOpenThink(rendered: string): boolean {
666
+ const open = rendered.lastIndexOf("<think>");
667
+ return open !== -1 && open > rendered.lastIndexOf("</think>");
668
+ }
669
+
670
+ /** OpenAI sends assistant tool_call arguments as JSON strings; the
671
+ * template renders the object form natively — normalize before render.
672
+ * Also maps the OpenAI reasoning-model "developer" role to "system": pi-ai
673
+ * (and OpenAI's own SDKs) rename the system prompt to `developer` whenever a
674
+ * model advertises reasoning, but our chat templates only know
675
+ * system/user/assistant/tool and raise "Unexpected message role." otherwise.
676
+ * This is exactly why Qwen3.5/MiniCPM5 chat got no messages while Gemma (a
677
+ * non-reasoning model, so pi keeps `system`) worked. `developer` IS the
678
+ * instruction/system prompt, so the remap is semantics-preserving. */
679
+ export function normalizeMessages(messages: ChatMessage[]): ChatMessage[] {
680
+ return messages.map((raw) => {
681
+ const m = raw.role === "developer" ? { ...raw, role: "system" } : raw;
682
+ if (!m.tool_calls) return m;
683
+ return {
684
+ ...m,
685
+ tool_calls: m.tool_calls.map((tc) => ({
686
+ ...tc,
687
+ function: {
688
+ ...tc.function,
689
+ arguments:
690
+ typeof tc.function.arguments === "string"
691
+ ? (JSON.parse(tc.function.arguments) as Record<string, unknown>)
692
+ : tc.function.arguments,
693
+ },
694
+ })),
695
+ };
696
+ });
697
+ }
698
+
699
+ /** Incremental detokenizer: emits the longest stable decoded prefix. */
700
+ class StreamDecoder {
701
+ #ids: number[] = [];
702
+ #emitted = "";
703
+
704
+ constructor(
705
+ readonly tokenizer: LoadedTokenizer,
706
+ readonly skipSpecialTokens = true,
707
+ ) {}
708
+
709
+ push(token: number): string {
710
+ this.#ids.push(token);
711
+ const full = this.tokenizer.decode(this.#ids, this.skipSpecialTokens);
712
+ // hold back a trailing replacement char (partial multi-byte sequence)
713
+ const stable = full.endsWith("�") ? full.slice(0, -1) : full;
714
+ if (!stable.startsWith(this.#emitted)) {
715
+ // decoder revised earlier text (rare); re-emit from scratch
716
+ const out = stable;
717
+ this.#emitted = stable;
718
+ return out;
719
+ }
720
+ const delta = stable.slice(this.#emitted.length);
721
+ this.#emitted = stable;
722
+ return delta;
723
+ }
724
+
725
+ flush(): string {
726
+ const full = this.tokenizer.decode(this.#ids, this.skipSpecialTokens);
727
+ const delta = full.slice(this.#emitted.length);
728
+ this.#emitted = full;
729
+ return delta;
730
+ }
731
+ }
732
+
733
+ // v2 curve designer: CORS-open so a file:// editor can call a localhost engine.
734
+ const CURVE_CORS: Record<string, string> = {
735
+ "access-control-allow-origin": "*",
736
+ "access-control-allow-methods": "POST, OPTIONS",
737
+ "access-control-allow-headers": "content-type",
738
+ };
739
+ /** coherence flag: non-Latin-letter ratio / replacement char / reserved token. */
740
+ function curveJunk(s: string): boolean {
741
+ const L = s.match(/\p{L}/gu) ?? [];
742
+ const nonLatin = L.length ? L.filter((c) => !/\p{Script=Latin}/u.test(c)).length / L.length : 0;
743
+ return nonLatin >= 0.02 || /�/.test(s) || /<unused\d+>/.test(s);
744
+ }
745
+
746
+ export function createServer(
747
+ ctx: ServerContext, port = 0, serverOptions: ServerOptions = {},
748
+ ): Server<unknown> {
749
+ // --batch N (mode switch): N===1 is the serialized path below; N>1 routes
750
+ // batchable requests through the continuous-batching scheduler (the
751
+ // GenerationGateway picks the lane). Both full-attention (CPM) and
752
+ // sliding-window (Gemma) models batch — the scheduler assembles each layer's
753
+ // cache by attention type. Non-batchable requests (vision / adapters /
754
+ // repetition penalty / user seed / explicit kv-quant) drain to the serial
755
+ // lane (see GenerationGateway.willBatch).
756
+ const batch = Math.max(1, Math.floor(serverOptions.batch ?? 1));
757
+
758
+ let queue: Promise<unknown> = Promise.resolve();
759
+ const enqueue = <T>(fn: () => Promise<T>): Promise<T> => {
760
+ const run = queue.then(fn, fn);
761
+ queue = run.catch(() => {});
762
+ return run;
763
+ };
764
+
765
+ const promptCache = new PromptCache(serverOptions.promptCacheBytes ?? 2e9);
766
+ // Responses-API store for previous_response_id resumption (Phase 11):
767
+ // TTL + byte-capped LRU, port of optiq/response_store.py. Pairs with
768
+ // the prompt cache: a resumed conversation re-renders the same prefix,
769
+ // so its KV prefill is already cached.
770
+ const responseStore = new ResponseStore();
771
+
772
+ /** Run one generation with prompt-cache reuse. Must be called inside
773
+ * the queue. onToken returning `false` halts generation early (stop
774
+ * sequence fired); the cache snapshot stays valid and is still kept.
775
+ * Vision requests bypass the prompt cache: image tokens are
776
+ * identical placeholder ids, so prefix matching across different
777
+ * images would false-hit. */
778
+ const runGeneration = async (
779
+ promptIds: number[],
780
+ options: GenerateOptions,
781
+ onToken: (token: number) => void | boolean | Promise<void | boolean>,
782
+ vision?: { embeddings: import("./mlx/array").MlxArray; imageMask: import("./mlx/array").MlxArray },
783
+ ) => {
784
+ // Cache entries are adapter-specific: KV computed under one adapter
785
+ // must never seed another's (or the base's) prefill.
786
+ const cacheNs = options.adapters?.join("+") ?? "";
787
+ const entry = vision ? null : promptCache.take(promptIds, cacheNs);
788
+ const caches = entry?.caches ?? ctx.model.makeCache();
789
+ try {
790
+ const gen = generate(ctx.model, promptIds, {
791
+ ...options,
792
+ cache: caches,
793
+ ...(vision ? { promptEmbeddings: vision.embeddings, imageMask: vision.imageMask } : {}),
794
+ });
795
+ for await (const t of gen) {
796
+ if ((await onToken(t.token)) === false) break;
797
+ }
798
+ const s = gen.stats!; // set on completion AND on early break
799
+ if (vision) {
800
+ for (const c of caches) c.dispose();
801
+ } else {
802
+ promptCache.put(s.cacheTokens, caches, cacheNs);
803
+ }
804
+ return s;
805
+ } catch (e) {
806
+ for (const c of caches) c.dispose();
807
+ throw e;
808
+ } finally {
809
+ vision?.embeddings.dispose();
810
+ vision?.imageMask.dispose();
811
+ }
812
+ };
813
+
814
+ // The lane picker: routes each request to the serial path (runGeneration,
815
+ // above) or the continuous-batching scheduler, keeping the two off the GPU
816
+ // (and shared loraState) at the same time. See src/serve/generation-gateway.ts.
817
+ const gateway = new GenerationGateway(ctx.model, batch, runGeneration);
818
+
819
+ // Admission ceiling, resolved once (Phase 5 memoryBudget enforcement).
820
+ // fit() solves max safe context from weights + KV growth + prefill
821
+ // transient; the KV term assumes bf16 (a kv-quant scheme stretches the
822
+ // real ceiling, never shrinks it — admission stays conservative).
823
+ const admission = fit(
824
+ ctx.model.config, ctx.model.weightsBytes, 1,
825
+ undefined, undefined, 0, serverOptions.memoryBudgetBytes,
826
+ );
827
+ if (admission.maxSafeContext < 1)
828
+ throw new Error(
829
+ `memory budget ${(admission.usableBytes / 1e9).toFixed(2)} GB cannot serve ` +
830
+ `${ctx.modelId} (weights ${(ctx.model.weightsBytes / 1e9).toFixed(2)} GB): ` +
831
+ `no context fits — raise the budget or pick a smaller model`,
832
+ );
833
+ if (serverOptions.memoryBudgetBytes) setMemoryLimit(serverOptions.memoryBudgetBytes);
834
+
835
+ // /library response cache (30 s) — registry + config reads only.
836
+ let libraryCache: { at: number; rows: unknown[] } | null = null;
837
+ const startedAt = Date.now();
838
+
839
+ // Captured so the WebSocket handler can resolve the bound (possibly
840
+ // ephemeral) port lazily for the loopback pi provider.
841
+ let serverRef!: Server<unknown>;
842
+
843
+ // Lab job system (quantize / finetune / dataset), lazily opened so a
844
+ // plain serve with no Lab activity pays nothing. markZombies() recovers
845
+ // rows orphaned by a crashed prior process. The dataset runner is
846
+ // in-process (pure JS + loopback /v1); quantize and finetune run as
847
+ // GPU-leased subprocesses via src/jobs/job-entry.ts.
848
+ let jobStore: import("./jobs").JobStore | null = null;
849
+ const ensureJobs = async () => {
850
+ if (!jobStore) {
851
+ const jobs = await import("./jobs");
852
+ const store = new jobs.JobStore();
853
+ store.markZombies();
854
+ try { (await import("./dataset")).registerDatasetRunner(); } catch {}
855
+ jobStore = store;
856
+ }
857
+ return jobStore;
858
+ };
859
+
860
+ // KV-quant scheme, resolved once: kv_config.json by default (optiq
861
+ // serve's headline behavior), overridable to uniform bits or off.
862
+ // KV quant scheme. The serial default is the model's mixed-precision config
863
+ // (optiq parity). But `--batch N` is a bf16 continuous-batching MODE (= mlx-lm
864
+ // B=N parity), so when KV quant is left UNSET under `--batch N` it defaults to
865
+ // bf16 — the batch path engages out of the box. An EXPLICIT --kv-quant
866
+ // (config / bits) is still honored, but those requests then route to the
867
+ // serial path (batched quantized KV is the L2 follow-up); warned below.
868
+ const configScheme = ctx.kvConfig?.length ? { kvConfig: ctx.kvConfig } : {};
869
+ const kvScheme: Pick<GenerateOptions, "kvBits" | "kvConfig" | "quantizedKvStart"> =
870
+ serverOptions.kvQuant === "off" ? {}
871
+ : serverOptions.kvQuant === "config" ? configScheme
872
+ : typeof serverOptions.kvQuant === "number"
873
+ ? { kvBits: serverOptions.kvQuant, quantizedKvStart: 0 }
874
+ : batch > 1 ? {} // unset + batching → bf16 mode (Option B)
875
+ : configScheme; // unset + serial → optiq-parity mixed-precision default
876
+ if (batch > 1 && (kvScheme.kvConfig?.length || kvScheme.kvBits))
877
+ console.warn(
878
+ `[batch] --batch ${batch} with explicit --kv-quant: batched serving (v1) is ` +
879
+ `bf16-only, so kv-quant requests route to the serial path (no batching for them). ` +
880
+ `Omit --kv-quant to batch in bf16. (docs/design/parallel-slots.md)`,
881
+ );
882
+
883
+ const toOptions = (req: ChatRequest): GenerateOptions & { stopSequences: string[] } => ({
884
+ maxTokens: req.max_completion_tokens ?? req.max_tokens ?? 1024,
885
+ temperature: req.temperature ?? serverOptions.defaultTemperature ?? ctx.genDefaults.temperature ?? 0.7,
886
+ topP: req.top_p ?? serverOptions.defaultTopP ?? ctx.genDefaults.topP ?? 0,
887
+ topK: req.top_k ?? serverOptions.defaultTopK ?? ctx.genDefaults.topK ?? 0,
888
+ seed: req.seed ?? (Date.now() & 0xffffffff),
889
+ repetitionPenalty: req.repetition_penalty ?? ctx.genDefaults.repetitionPenalty,
890
+ hlg: resolveHlg(req.hlg, serverOptions.hlg),
891
+ // generate() yields token ids; stop sequences match on decoded text
892
+ // (they can span token boundaries), so the StopMatcher sits at the
893
+ // decode layer below and halts the loop via onToken → false.
894
+ stopSequences: (typeof req.stop === "string" ? [req.stop] : req.stop ?? [])
895
+ .filter((s) => typeof s === "string" && s.length > 0),
896
+ ...kvScheme,
897
+ });
898
+
899
+ const templateOptionsFor = (req: ChatRequest, tools: ToolDefinition[] | null) => {
900
+ // Precedence: explicit per-request chat_template_kwargs wins; else the
901
+ // standard reasoning_effort (the OpenAI field Pi sends — "none" → no
902
+ // thinking, any level → thinking on); else the server-wide --thinking
903
+ // default; else the model's own default (false for MiniCPM5, otherwise
904
+ // leave the template's default). The Anthropic/Responses shims map their
905
+ // reasoning fields onto reasoning_effort before reaching here.
906
+ const explicit = req.chat_template_kwargs?.enable_thinking;
907
+ const effort = req.reasoning_effort;
908
+ return {
909
+ tools,
910
+ enableThinking: typeof explicit === "boolean"
911
+ ? explicit
912
+ : effort !== undefined
913
+ ? effort !== "none"
914
+ : serverOptions.defaultThinking !== undefined
915
+ ? serverOptions.defaultThinking
916
+ : isMiniCPM5Config(ctx.model.config) ? false : undefined,
917
+ };
918
+ };
919
+
920
+ const promptIdsFor = (
921
+ req: ChatRequest,
922
+ tools: ToolDefinition[] | null,
923
+ ): { ids: number[]; startInThinking: boolean } => {
924
+ const rendered = ctx.template.render(normalizeMessages(req.messages), templateOptionsFor(req, tools));
925
+ const ids = ctx.tokenizer.encode(rendered);
926
+ // template includes <bos>; tokenizer post-processor also prepends one
927
+ const trimmed = ids[0] === ids[1] && ids[0] === ctx.tokenizer.bosTokenId ? ids.slice(1) : ids;
928
+ return { ids: trimmed, startInThinking: promptEndsInOpenThink(rendered) };
929
+ };
930
+
931
+ const toolStreamMode = (tools: ToolDefinition[] | null): ToolStreamMode => {
932
+ // MiniCPM5 and Qwen3.5 emit tool calls as DECODED TEXT (Qwen:
933
+ // <tool_call><function=name><parameter=…>; both handled by
934
+ // parseGeneratedToolCalls), not Gemma's token sentinels — so they take the
935
+ // buffered-text path. Gemma keeps the token-level sentinel path.
936
+ if (isMiniCPM5Config(ctx.model.config) || isQwen35Config(ctx.model.config))
937
+ return tools?.length ? "buffered-text" : "plain";
938
+ return "gemma-sentinel";
939
+ };
940
+
941
+ const toolRouter = (tools: ToolDefinition[] | null): ToolAwareStream =>
942
+ new ToolAwareStream(ctx.tokenizer, toolStreamMode(tools), tools);
943
+
944
+ serverRef = Bun.serve({
945
+ port,
946
+ ...(serverOptions.hostname ? { hostname: serverOptions.hostname } : {}),
947
+ idleTimeout: 0,
948
+ // Web chat rides pi's AgentSession events over a WebSocket; the embedded
949
+ // pi provider points back at THIS server's own loopback /v1 (port
950
+ // resolved lazily — it may be ephemeral until serve() binds).
951
+ websocket: makePiWsHandler({
952
+ port: () => serverRef.port ?? port,
953
+ modelId: ctx.modelId,
954
+ contextWindow: ctx.model.config.text.maxPositionEmbeddings,
955
+ // capability flag — true if a tower is loaded or loadable (lazy)
956
+ vision: !!(ctx.vision || ctx.loadVision),
957
+ thinking: ctx.template.supportsThinking,
958
+ }),
959
+ async fetch(request, server) {
960
+ const url = new URL(request.url);
961
+
962
+ if (url.pathname === "/ws/chat") {
963
+ if (server.upgrade(request, { data: { sessionId: crypto.randomUUID() } as PiWsData }))
964
+ return undefined;
965
+ return new Response("expected websocket", { status: 426 });
966
+ }
967
+
968
+ // The unified SPA is served at "/"; legacy deep links redirect into
969
+ // the hash router so old bookmarks still land on the right section.
970
+ if (url.pathname === "/" && request.method === "GET") {
971
+ return new Response(APP_PAGE, {
972
+ headers: { "content-type": "text/html; charset=utf-8" },
973
+ });
974
+ }
975
+ // v2 HLG Curve Designer — served same-origin so /generate + /signal need no CORS.
976
+ // Read fresh from disk in dev (edits show on reload, no restart); fall back to the
977
+ // embedded copy when running as the compiled single binary.
978
+ if (url.pathname === "/curves" && request.method === "GET") {
979
+ let html = CURVE_PAGE;
980
+ try { html = readFileSync(new URL("../docs/curve-designer.html", import.meta.url), "utf8"); } catch { /* binary: use embedded */ }
981
+ return new Response(html, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
982
+ }
983
+ if (url.pathname === "/curve-terrain" && request.method === "GET") {
984
+ try {
985
+ const html = readFileSync(new URL("../docs/investigations/curve-terrain.html", import.meta.url), "utf8");
986
+ return new Response(html, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
987
+ } catch {
988
+ return new Response("curve terrain artifact not found; run scripts/curve-terrain.ts first", { status: 404 });
989
+ }
990
+ }
991
+ if (request.method === "GET" &&
992
+ ["/status", "/chat", "/quantize", "/finetune", "/dataset"].includes(url.pathname)) {
993
+ return Response.redirect(`/#${url.pathname}`, 302);
994
+ }
995
+
996
+ if (url.pathname === "/library" && request.method === "GET") {
997
+ // Everything on disk, each with a fit assessment for THIS machine
998
+ // (30 s cache — registry scan + config reads, no tensor bytes).
999
+ if (!libraryCache || Date.now() - libraryCache.at > 30_000) {
1000
+ const { Registry } = await import("./registry");
1001
+ const { loadModelConfig } = await import("./config");
1002
+ const reg = new Registry();
1003
+ if (reg.list().length === 0) await reg.scan();
1004
+ const rows = [];
1005
+ for (const m of reg.list()) {
1006
+ const supported = isSupportedModelRecord(m.modelType, m.repoId);
1007
+ let assessment = null;
1008
+ try {
1009
+ const config = await loadModelConfig(m.path);
1010
+ const r = fit(config, m.sizeBytes, 8192, undefined, undefined, m.expertsBytes);
1011
+ assessment = {
1012
+ fits: r.fits,
1013
+ max_safe_context: r.maxSafeContext,
1014
+ predicted_decode_tps: r.predictedDecodeTps,
1015
+ };
1016
+ } catch {}
1017
+ rows.push({
1018
+ repo_id: m.repoId, model_type: m.modelType,
1019
+ size_bytes: m.sizeBytes, quant_bits: m.quantBits,
1020
+ vision: m.hasVisionSidecar, supported,
1021
+ serving: m.repoId === ctx.modelId,
1022
+ assessment,
1023
+ });
1024
+ }
1025
+ libraryCache = { at: Date.now(), rows };
1026
+ }
1027
+ return Response.json({ models: libraryCache.rows });
1028
+ }
1029
+
1030
+ if (url.pathname === "/downloads" && request.method === "GET") {
1031
+ const { downloadsSnapshot } = await import("./download");
1032
+ return Response.json({ downloads: downloadsSnapshot() });
1033
+ }
1034
+
1035
+ if (url.pathname === "/fit" && request.method === "GET") {
1036
+ // Fit assessment for the status page: this-machine report at the
1037
+ // admission ceiling + the Apple SKU matrix at a fixed 32k.
1038
+ // expertsBytes comes from the registry so MoE models predict on
1039
+ // active bytes — same numbers as `mlx-bun fit` and the serve
1040
+ // banner (the three surfaces used to disagree). When the eval DB
1041
+ // has a real measurement for this snapshot, it rides along:
1042
+ // measured beats predicted.
1043
+ const { skuMatrix, thisMachine, detectChip } = await import("./fit");
1044
+ const machine = thisMachine();
1045
+ const chip = detectChip();
1046
+ let expertsBytes = 0;
1047
+ let measured: { decodeTps: number; ts: number } | null = null;
1048
+ try {
1049
+ const { Registry } = await import("./registry");
1050
+ const rec = new Registry().list().find((r) => r.repoId === ctx.modelId);
1051
+ if (rec) {
1052
+ expertsBytes = rec.expertsBytes;
1053
+ const { EvalDB } = await import("./evaldb");
1054
+ measured = new EvalDB().latestFor(rec.path);
1055
+ }
1056
+ } catch {}
1057
+ const report = fit(
1058
+ ctx.model.config, ctx.model.weightsBytes, admission.maxSafeContext,
1059
+ machine, undefined, expertsBytes, serverOptions.memoryBudgetBytes,
1060
+ );
1061
+ return Response.json({
1062
+ machine: { chip: chip.name, ram_bytes: machine.ramBytes, bandwidth_gbs: machine.bandwidthGBs },
1063
+ context_tokens: admission.maxSafeContext,
1064
+ // Headline number: prediction at a TYPICAL context (8k) — the
1065
+ // max-context report below is the bandwidth worst case (every
1066
+ // decode step re-reads the full KV), not the everyday speed.
1067
+ typical_context_tokens: Math.min(8192, admission.maxSafeContext),
1068
+ typical_decode_tps: fit(
1069
+ ctx.model.config, ctx.model.weightsBytes,
1070
+ Math.min(8192, admission.maxSafeContext),
1071
+ machine, undefined, expertsBytes, serverOptions.memoryBudgetBytes,
1072
+ ).predictedDecodeTps,
1073
+ measured_decode_tps: measured?.decodeTps ?? null,
1074
+ measured_at: measured?.ts ?? null,
1075
+ report: {
1076
+ fits: report.fits,
1077
+ weights_bytes: report.weightsBytes,
1078
+ kv_bytes: report.kvBytes,
1079
+ transient_bytes: report.transientBytes,
1080
+ total_bytes: report.totalBytes,
1081
+ usable_bytes: report.usableBytes,
1082
+ max_safe_context: report.maxSafeContext,
1083
+ predicted_decode_tps: report.predictedDecodeTps,
1084
+ },
1085
+ sku_matrix_ctx: 32768,
1086
+ sku_matrix: skuMatrix(ctx.model.config, ctx.model.weightsBytes, 32768, expertsBytes).map((r) => ({
1087
+ sku: r.sku, ram_gb: r.ramGB, fits: r.fits,
1088
+ max_context: r.maxContext, decode_tps: r.decodeTps,
1089
+ })),
1090
+ });
1091
+ }
1092
+
1093
+ if (url.pathname === "/v1" && request.method === "GET") {
1094
+ return Response.json({
1095
+ name: "mlx-bun", version: pkgVersion, model: ctx.modelId,
1096
+ endpoints: [
1097
+ "POST /v1/chat/completions", "POST /v1/messages", "POST /v1/responses",
1098
+ "GET /v1/models", "GET/POST/DELETE /v1/adapters",
1099
+ "GET /stats", "GET /fit", "GET /library", "GET /downloads",
1100
+ ],
1101
+ });
1102
+ }
1103
+
1104
+ if (url.pathname === "/stats" && request.method === "GET") {
1105
+ // Active KV scheme across ALL layers. Since Phase 9 rotating
1106
+ // (sliding-window) caches quantize too, so every layer the
1107
+ // scheme names counts — the old display filtered to
1108
+ // full_attention and silently undercounted (e.g. 26B showed
1109
+ // 5/30 quantized when its kv_config.json covers all 30).
1110
+ const layerTypes = ctx.model.config.text.layerTypes;
1111
+ const kvLayers: Record<string, number> = {};
1112
+ let kvMode = "bf16";
1113
+ if (kvScheme.kvBits) {
1114
+ kvMode = `uniform-kv${kvScheme.kvBits}`;
1115
+ kvLayers[`kv${kvScheme.kvBits}`] = layerTypes.length;
1116
+ } else if (kvScheme.kvConfig) {
1117
+ kvMode = "mixed (kv_config.json)";
1118
+ for (const e of kvScheme.kvConfig)
1119
+ kvLayers[`kv${e.bits}`] = (kvLayers[`kv${e.bits}`] ?? 0) + 1;
1120
+ }
1121
+ const bf16Layers = layerTypes.length - Object.values(kvLayers).reduce((a, b) => a + b, 0);
1122
+ const slidingLayers = layerTypes.filter((l) => l === "sliding_attention").length;
1123
+ return Response.json({
1124
+ server: {
1125
+ owner: serverOptions.owner ?? "embedded",
1126
+ model: ctx.modelId,
1127
+ started_at: startedAt,
1128
+ },
1129
+ prompt_cache: {
1130
+ entries: promptCache.size,
1131
+ bytes: promptCache.totalBytes,
1132
+ max_bytes: promptCache.maxBytes,
1133
+ hits: promptCache.hits,
1134
+ misses: promptCache.misses,
1135
+ },
1136
+ response_store: {
1137
+ entries: responseStore.size,
1138
+ bytes: responseStore.totalBytes,
1139
+ max_bytes: responseStore.maxBytes,
1140
+ ttl_ms: responseStore.ttlMs,
1141
+ },
1142
+ kv_quant: {
1143
+ mode: kvMode,
1144
+ layers: { ...kvLayers, ...(bf16Layers > 0 ? { bf16: bf16Layers } : {}) },
1145
+ attention: {
1146
+ global: layerTypes.length - slidingLayers,
1147
+ sliding_window: slidingLayers,
1148
+ },
1149
+ },
1150
+ admission: {
1151
+ max_safe_context: admission.maxSafeContext,
1152
+ memory_budget_bytes: serverOptions.memoryBudgetBytes ?? null,
1153
+ usable_bytes: admission.usableBytes,
1154
+ weights_bytes: ctx.model.weightsBytes,
1155
+ },
1156
+ // --batch: configured cap, whether batching is live for this model,
1157
+ // and rows currently decoding in the batch.
1158
+ batch: {
1159
+ configured: batch,
1160
+ batched: gateway.batchingEnabled,
1161
+ active_rows: gateway.activeRows,
1162
+ },
1163
+ });
1164
+ }
1165
+
1166
+ if (url.pathname === "/v1/models" && request.method === "GET") {
1167
+ return Response.json({
1168
+ object: "list",
1169
+ data: [{
1170
+ id: ctx.modelId, object: "model", created: 0, owned_by: "mlx-bun",
1171
+ context_window: ctx.model.config.text.maxPositionEmbeddings,
1172
+ // Capability flags for clients (CLI/external pi) that build a
1173
+ // provider from discovery — `reasoning` gates the thinking toggle.
1174
+ reasoning: ctx.template.supportsThinking,
1175
+ }],
1176
+ });
1177
+ }
1178
+
1179
+ // Adapter admin (port of optiq registry semantics): list / mount /
1180
+ // unmount. Mount and unmount go through the generation queue so
1181
+ // they never race an in-flight forward pass.
1182
+ if (url.pathname === "/v1/adapters/available" && request.method === "GET") {
1183
+ // On-disk adapters that can be mounted (the chat selector's source),
1184
+ // FILTERED to ones compatible with the currently-served model — a
1185
+ // MiniCPM5 adapter can't mount on Gemma, so it must not appear in the
1186
+ // picker. Compatibility = the adapter's recorded base repo id matches the
1187
+ // served model (compared on the bare name, lenient about the org); an
1188
+ // adapter with no recorded base is kept (mount validates it). `mounted`
1189
+ // flags ones already loaded so the UI auto-loads on select only if needed.
1190
+ const { homedir } = await import("node:os");
1191
+ const stores = [
1192
+ `${homedir()}/.cache/mlx-bun-finetunes`,
1193
+ `${homedir()}/.cache/mlx-bun/adapters`,
1194
+ ];
1195
+ const mounted = new Set(ctx.adapters.list().map((a) => a.id));
1196
+ const bareName = (s: string) => s.split("/").pop()!.toLowerCase();
1197
+ const servedName = bareName(ctx.modelId);
1198
+ const adapters = (await listAvailableAdapters(stores))
1199
+ .filter((a) => a.baseModel == null || bareName(a.baseModel) === servedName)
1200
+ .map((a) => ({
1201
+ id: a.id, path: a.path, rank: a.rank, scale: a.scale,
1202
+ base_model: a.baseModel, mounted: mounted.has(a.id),
1203
+ }));
1204
+ return Response.json({ adapters });
1205
+ }
1206
+ if (url.pathname === "/v1/adapters" && request.method === "GET") {
1207
+ return Response.json({
1208
+ adapters: ctx.adapters.list().map((a) => ({
1209
+ id: a.id, path: a.path, rank: a.rank, scale: a.scale,
1210
+ size_bytes: a.sizeBytes, mounted_layers: a.mountedLayers,
1211
+ })),
1212
+ });
1213
+ }
1214
+ if (url.pathname === "/v1/adapters" && request.method === "POST") {
1215
+ let body: { id?: string; path?: string };
1216
+ try {
1217
+ body = (await request.json()) as typeof body;
1218
+ } catch {
1219
+ return Response.json({ error: { message: "invalid JSON body" } }, { status: 400 });
1220
+ }
1221
+ if (!body.id || !body.path)
1222
+ return Response.json({ error: { message: "id and path required" } }, { status: 400 });
1223
+ try {
1224
+ const info = await enqueue(() => ctx.adapters.mount(body.id!, body.path!));
1225
+ return Response.json({
1226
+ id: info.id, mounted_layers: info.mountedLayers,
1227
+ rank: info.rank, scale: info.scale,
1228
+ });
1229
+ } catch (e) {
1230
+ return Response.json({ error: { message: (e as Error).message } }, { status: 400 });
1231
+ }
1232
+ }
1233
+ if (url.pathname.startsWith("/v1/adapters/") && request.method === "DELETE") {
1234
+ const id = decodeURIComponent(url.pathname.slice("/v1/adapters/".length));
1235
+ const removed = await enqueue(async () => ctx.adapters.unmount(id));
1236
+ return removed > 0
1237
+ ? Response.json({ id, removed_layers: removed })
1238
+ : Response.json({ error: { message: `adapter ${id} not mounted` } }, { status: 404 });
1239
+ }
1240
+
1241
+ // ---- Curve Designer: POST /signal {prompt} → next-token histogram over the curve's x-axis ----
1242
+ // One prefill forward; bins the real log-prob distribution so the editor can draw the
1243
+ // signal under the curve (you shape where the tokens actually are).
1244
+ if (url.pathname === "/signal" && request.method === "OPTIONS")
1245
+ return new Response(null, { headers: CURVE_CORS });
1246
+ if (url.pathname === "/signal" && request.method === "POST") {
1247
+ let sbody: { prompt?: string };
1248
+ try { sbody = (await request.json()) as typeof sbody; }
1249
+ catch { return Response.json({ error: "invalid JSON" }, { status: 400, headers: CURVE_CORS }); }
1250
+ let sids = ctx.tokenizer.encode(ctx.template.render([{ role: "user", content: typeof sbody.prompt === "string" ? sbody.prompt : "" }], templateOptionsFor({} as ChatRequest, null)));
1251
+ if (sids[0] === sids[1] && sids[0] === ctx.tokenizer.bosTokenId) sids = sids.slice(1);
1252
+ try {
1253
+ const NB = 80;
1254
+ const result = await enqueue(async () => {
1255
+ const cache = ctx.model.makeCache();
1256
+ try {
1257
+ const logits = ctx.model.forward(sids, cache); // [1, L, V]
1258
+ const [, Ln, V] = logits.shape as [number, number, number];
1259
+ const last = logits.slice([0, Ln - 1, 0], [1, Ln, V]);
1260
+ const f = last.toFloat32(); logits.dispose(); last.dispose();
1261
+ let mx = -Infinity; for (const v of f) if (v > mx) mx = v;
1262
+ let Z = 0; for (const v of f) Z += Math.exp(v - mx); const lse = mx + Math.log(Z);
1263
+ const bins = new Array<number>(NB).fill(0);
1264
+ for (const v of f) { const t = Math.max(0, Math.min(1, (v - lse - CURVE_UMIN) / (-CURVE_UMIN))); const bi = Math.min(NB - 1, Math.floor(t * NB)); bins[bi] = (bins[bi] ?? 0) + 1; }
1265
+ return { bins, vocab: V };
1266
+ } finally { for (const c of cache) c.dispose(); }
1267
+ });
1268
+ return Response.json(result, { headers: CURVE_CORS });
1269
+ } catch (e) {
1270
+ return Response.json({ error: `signal failed: ${(e as Error).message}` }, { status: 500, headers: CURVE_CORS });
1271
+ }
1272
+ }
1273
+
1274
+ // ---- v2 HLG Curve Designer: POST /generate {prompt, curve, n, max_tokens, seed} ----
1275
+ // The drawn log-prob transfer curve REPLACES temperature+softmax entirely
1276
+ // (src/curve-sampler.ts). The browser editor calls this; same curve object the
1277
+ // tool's "Copy values" emits is the one the sampler consumes — one contract.
1278
+ if (url.pathname === "/generate" && request.method === "OPTIONS")
1279
+ return new Response(null, { headers: CURVE_CORS });
1280
+ if (url.pathname === "/generate" && request.method === "POST") {
1281
+ let body: { prompt?: string; curve?: CurveParams; n?: number; max_tokens?: number; seed?: number; default?: boolean };
1282
+ try { body = (await request.json()) as typeof body; }
1283
+ catch { return Response.json({ error: "invalid JSON" }, { status: 400, headers: CURVE_CORS }); }
1284
+ const curve = body.curve;
1285
+ // Identity / no shaped curve → fall back to the model's DEFAULT chat recipe
1286
+ // (temp + top-p + top-k) — the honest "what you'd get chatting" baseline, which
1287
+ // a smooth curve can't replicate (top-p/top-k are hard truncations).
1288
+ const useCurve = body.default !== true && Array.isArray(curve?.points) && curve.points.length >= 2;
1289
+ if (useCurve && !isMonotone(curve!))
1290
+ return Response.json({ error: "curve is not monotone — all segment slopes must be ≥ 0" }, { status: 400, headers: CURVE_CORS });
1291
+ const recipe = {
1292
+ temperature: serverOptions.defaultTemperature ?? ctx.genDefaults.temperature ?? 0.7,
1293
+ topP: serverOptions.defaultTopP ?? ctx.genDefaults.topP ?? 0,
1294
+ topK: serverOptions.defaultTopK ?? ctx.genDefaults.topK ?? 0,
1295
+ };
1296
+ const prompt = typeof body.prompt === "string" ? body.prompt : "";
1297
+ const n = Math.max(1, Math.min(8, Math.floor(Number(body.n) || 3)));
1298
+ const maxTokens = Math.max(1, Math.min(256, Math.floor(Number(body.max_tokens) || 80)));
1299
+ const baseSeed = Number.isFinite(body.seed) ? Number(body.seed) >>> 0 : (Date.now() & 0xffffffff);
1300
+ let ids = ctx.tokenizer.encode(ctx.template.render([{ role: "user", content: prompt }], templateOptionsFor({} as ChatRequest, null)));
1301
+ if (ids[0] === ids[1] && ids[0] === ctx.tokenizer.bosTokenId) ids = ids.slice(1);
1302
+ const samples: { text: string; junk: boolean }[] = [];
1303
+ try {
1304
+ for (let i = 0; i < n; i++) {
1305
+ const toks: number[] = [];
1306
+ const genOpts: GenerateOptions = useCurve
1307
+ ? { curve, seed: baseSeed + i, maxTokens, ...kvScheme }
1308
+ : { temperature: recipe.temperature, topP: recipe.topP, topK: recipe.topK, seed: baseSeed + i, maxTokens, ...kvScheme };
1309
+ await enqueue(() => runGeneration(ids, genOpts, (t) => { toks.push(t); }));
1310
+ const text = ctx.tokenizer.decode(toks, true).trim();
1311
+ samples.push({ text, junk: curveJunk(text) });
1312
+ }
1313
+ } catch (e) {
1314
+ return Response.json({ error: `generation failed: ${(e as Error).message}` }, { status: 500, headers: CURVE_CORS });
1315
+ }
1316
+ return Response.json({ mode: useCurve ? "curve" : "default", recipe: useCurve ? undefined : recipe, n, seed: baseSeed, samples }, { headers: CURVE_CORS });
1317
+ }
1318
+
1319
+ // The chat-completions core, shared by both protocol surfaces:
1320
+ // /v1/chat/completions calls it directly; /v1/messages (Anthropic)
1321
+ // translates its body into this shape and the Response back —
1322
+ // generation, tools, vision, stop sequences, prompt cache, and
1323
+ // admission control all live here exactly once.
1324
+ const handleChat = async (body: ChatRequest): Promise<Response> => {
1325
+ if (!Array.isArray(body.messages) || body.messages.length === 0)
1326
+ return Response.json({ error: { message: "messages required" } }, { status: 400 });
1327
+
1328
+ const id = `chatcmpl-${crypto.randomUUID()}`;
1329
+ const created = Math.floor(Date.now() / 1000);
1330
+ const tools =
1331
+ body.tool_choice === "none" ? null : (body.tools?.length ? body.tools : null);
1332
+ const hasImages = body.messages.some(
1333
+ (m) => Array.isArray(m.content) &&
1334
+ m.content.some((p: any) => p.type === "image_url" || p.type === "image"),
1335
+ );
1336
+ let promptIds: number[];
1337
+ // Whether the prompt primed an open <think> (Qwen3.5/MiniCPM5 thinking
1338
+ // on) so the model's output starts mid-reasoning — seeds the splitter.
1339
+ // Vision is Gemma4-only (no switchable thinking channel), so it's false.
1340
+ let startInThinking = false;
1341
+ let vision: Parameters<typeof runGeneration>[3];
1342
+ try {
1343
+ if (hasImages) {
1344
+ // Loads (and caches) the tower on first image request — text-only
1345
+ // sessions never pay for it.
1346
+ const tower = getVisionTower(ctx);
1347
+ if (!tower)
1348
+ return Response.json(
1349
+ { error: { message: "model has no vision sidecar" } }, { status: 400 },
1350
+ );
1351
+ const { messages, images } = await extractImages(normalizeMessages(body.messages));
1352
+ // The tower is only ever non-null for Gemma4 (sidecar gate in
1353
+ // makeVisionLoader), so the model narrow is safe here.
1354
+ const vp = await buildVisionPrompt(
1355
+ ctx.model as Gemma4Model, tower, ctx.tokenizer, ctx.template,
1356
+ messages, images, ctx.visionTokenIds, tools,
1357
+ );
1358
+ promptIds = vp.ids;
1359
+ vision = { embeddings: vp.embeddings, imageMask: vp.imageMask };
1360
+ } else {
1361
+ const built = promptIdsFor(body, tools);
1362
+ promptIds = built.ids;
1363
+ startInThinking = built.startInThinking;
1364
+ }
1365
+ } catch (e) {
1366
+ return Response.json(
1367
+ { error: { message: `prompt build failed: ${(e as Error).message}` } },
1368
+ { status: 400 },
1369
+ );
1370
+ }
1371
+ const options = toOptions(body);
1372
+ // Admission: reject what cannot finish within the memory budget
1373
+ // (the GPU OOM it would otherwise hit is uncatchable and kills
1374
+ // the process — Phase 6 finding).
1375
+ const requiredCtx = promptIds.length + (options.maxTokens ?? 1024);
1376
+ if (requiredCtx > admission.maxSafeContext) {
1377
+ vision?.embeddings.dispose();
1378
+ vision?.imageMask.dispose();
1379
+ return Response.json(
1380
+ {
1381
+ error: {
1382
+ message:
1383
+ `request needs ${requiredCtx} tokens of context ` +
1384
+ `(prompt ${promptIds.length} + max_tokens ${options.maxTokens}) but the ` +
1385
+ `memory budget caps safe context at ${admission.maxSafeContext} — ` +
1386
+ `shorten the prompt or lower max_tokens`,
1387
+ type: "memory_admission",
1388
+ code: "context_over_budget",
1389
+ },
1390
+ },
1391
+ { status: 400 },
1392
+ );
1393
+ }
1394
+ try {
1395
+ const adapterIds = ctx.adapters.resolveSpec(body.adapter);
1396
+ if (adapterIds.length) options.adapters = adapterIds;
1397
+ } catch (e) {
1398
+ return Response.json({ error: { message: (e as Error).message } }, { status: 400 });
1399
+ }
1400
+
1401
+ // What lane this request takes (vision / adapters / repetition penalty /
1402
+ // a user-fixed seed → serial; everything else batches when --batch N).
1403
+ const shape = {
1404
+ hasVision: !!vision,
1405
+ hasAdapters: !!options.adapters?.length,
1406
+ hasRepetitionPenalty: !!options.repetitionPenalty,
1407
+ userSeed: body.seed !== undefined,
1408
+ kvQuant: !!(options.kvConfig?.length || options.kvBits),
1409
+ };
1410
+ const batched = gateway.willBatch(shape);
1411
+
1412
+ if (body.stream) {
1413
+ const stream = new ReadableStream<Uint8Array>({
1414
+ async start(controller) {
1415
+ const enc = new TextEncoder();
1416
+ const send = (obj: unknown) =>
1417
+ controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`));
1418
+ const chunk = (delta: Record<string, unknown>, finish: string | null) => ({
1419
+ id, object: "chat.completion.chunk", created, model: ctx.modelId,
1420
+ choices: [{ index: 0, delta, finish_reason: finish }],
1421
+ });
1422
+ try {
1423
+ // The gateway owns lane selection + GPU exclusivity; this body
1424
+ // runs per-request (concurrently in batched mode, each writing
1425
+ // its own SSE stream — the per-row fan-out).
1426
+ send(chunk({ role: "assistant", content: "" }, null));
1427
+ const router = toolRouter(tools);
1428
+ const stopper = new StopMatcher(options.stopSequences);
1429
+ // <think>-text splitting is only for text-marker models; gemma's
1430
+ // reasoning is already split at the token level by the router.
1431
+ const thinking = new ThinkingTagSplitter(ctx.template.thinkingFormat === "think-tag", startInThinking);
1432
+ // Serial decode is an unbroken microtask chain (FFI + generator
1433
+ // resumes) — without a macrotask hop, Bun never services the
1434
+ // socket and the whole SSE response flushes in one burst at the
1435
+ // end (Phase 15: "687k tok/s decode"). Hopping EVERY token cost
1436
+ // ~23% decode; rate-limited to ≥25 ms keeps the flush smooth and
1437
+ // hides behind the next GPU step. Batched mode doesn't need it —
1438
+ // the scheduler yields to the event loop between steps.
1439
+ let lastFlush = performance.now();
1440
+ const s = await gateway.run(promptIds, options, (token) => {
1441
+ const rawContent = router.push(token);
1442
+ // gemma-channel reasoning, split at the token level by the router
1443
+ const rReason = router.takeReasoning();
1444
+ if (rReason) send(chunk({ reasoning: rReason }, null));
1445
+ const text = stopper.push(rawContent);
1446
+ const parts = thinking.push(text);
1447
+ if (parts.reasoning) send(chunk({ reasoning: parts.reasoning }, null));
1448
+ if (parts.content) send(chunk({ content: parts.content }, null));
1449
+ if (stopper.stopped) return false; // halt generation
1450
+ if (!batched && (parts.content || parts.reasoning || rReason)) {
1451
+ const now = performance.now();
1452
+ if (now - lastFlush >= 25) {
1453
+ lastFlush = now;
1454
+ return new Promise<void>((r) => setImmediate(r));
1455
+ }
1456
+ }
1457
+ }, vision, shape);
1458
+ // a stop match discards everything from the match on,
1459
+ // including text still held by the decoders
1460
+ let tail = "";
1461
+ if (!stopper.stopped) {
1462
+ const flushed = router.flush();
1463
+ const rReason = router.takeReasoning();
1464
+ if (rReason) send(chunk({ reasoning: rReason }, null));
1465
+ tail = stopper.push(flushed);
1466
+ if (!stopper.stopped) tail += stopper.flush();
1467
+ }
1468
+ if (tail) {
1469
+ const parts = thinking.push(tail);
1470
+ if (parts.reasoning) send(chunk({ reasoning: parts.reasoning }, null));
1471
+ if (parts.content) send(chunk({ content: parts.content }, null));
1472
+ }
1473
+ {
1474
+ const parts = thinking.flush();
1475
+ if (parts.reasoning) send(chunk({ reasoning: parts.reasoning }, null));
1476
+ if (parts.content) send(chunk({ content: parts.content }, null));
1477
+ }
1478
+ const toolCalls = router.toolCalls();
1479
+ if (toolCalls.length) {
1480
+ send(chunk({
1481
+ tool_calls: toolCalls.map((tc, i) => ({ index: i, ...tc })),
1482
+ }, null));
1483
+ }
1484
+ const finish = toolCalls.length
1485
+ ? "tool_calls"
1486
+ : stopper.stopped ? "stop"
1487
+ : s.generatedTokens >= (options.maxTokens ?? 1024) ? "length" : "stop";
1488
+ send({
1489
+ ...chunk({}, finish),
1490
+ usage: {
1491
+ prompt_tokens: s.promptTokens,
1492
+ completion_tokens: s.generatedTokens,
1493
+ total_tokens: s.promptTokens + s.generatedTokens,
1494
+ prompt_tokens_details: { cached_tokens: s.cachedTokens },
1495
+ },
1496
+ });
1497
+ // bare sentinel per the OpenAI spec — JSON.stringify would
1498
+ // quote it and strict SDK clients never see the terminator
1499
+ controller.enqueue(enc.encode("data: [DONE]\n\n"));
1500
+ } catch (e) {
1501
+ send({ error: { message: (e as Error).message } });
1502
+ } finally {
1503
+ controller.close();
1504
+ }
1505
+ },
1506
+ });
1507
+ return new Response(stream, {
1508
+ headers: {
1509
+ "content-type": "text/event-stream",
1510
+ "cache-control": "no-cache",
1511
+ connection: "keep-alive",
1512
+ },
1513
+ });
1514
+ }
1515
+
1516
+ try {
1517
+ {
1518
+ const router = toolRouter(tools);
1519
+ const stopper = new StopMatcher(options.stopSequences);
1520
+ const thinking = new ThinkingTagSplitter(ctx.template.thinkingFormat === "think-tag", startInThinking);
1521
+ let content = "";
1522
+ let reasoning = "";
1523
+ const s = await gateway.run(promptIds, options, (token) => {
1524
+ const rawContent = router.push(token);
1525
+ reasoning += router.takeReasoning(); // gemma-channel thinking
1526
+ const parts = thinking.push(stopper.push(rawContent));
1527
+ content += parts.content;
1528
+ reasoning += parts.reasoning;
1529
+ if (stopper.stopped) return false; // halt generation
1530
+ }, vision, shape);
1531
+ if (!stopper.stopped) {
1532
+ const flushed = router.flush();
1533
+ reasoning += router.takeReasoning();
1534
+ let tail = stopper.push(flushed);
1535
+ if (!stopper.stopped) tail += stopper.flush();
1536
+ const parts = thinking.push(tail);
1537
+ content += parts.content;
1538
+ reasoning += parts.reasoning;
1539
+ }
1540
+ {
1541
+ const parts = thinking.flush();
1542
+ content += parts.content;
1543
+ reasoning += parts.reasoning;
1544
+ }
1545
+ const toolCalls = router.toolCalls();
1546
+ const finish = toolCalls.length
1547
+ ? "tool_calls"
1548
+ : stopper.stopped ? "stop"
1549
+ : s.generatedTokens >= (options.maxTokens ?? 1024) ? "length" : "stop";
1550
+ return Response.json({
1551
+ id, object: "chat.completion", created, model: ctx.modelId,
1552
+ choices: [{
1553
+ index: 0,
1554
+ message: {
1555
+ role: "assistant",
1556
+ content: content || (toolCalls.length ? null : ""),
1557
+ ...(reasoning ? { reasoning } : {}),
1558
+ ...(toolCalls.length ? { tool_calls: toolCalls } : {}),
1559
+ },
1560
+ finish_reason: finish,
1561
+ }],
1562
+ usage: {
1563
+ prompt_tokens: s.promptTokens,
1564
+ completion_tokens: s.generatedTokens,
1565
+ total_tokens: s.promptTokens + s.generatedTokens,
1566
+ prompt_tokens_details: { cached_tokens: s.cachedTokens },
1567
+ },
1568
+ });
1569
+ }
1570
+ } catch (e) {
1571
+ return Response.json({ error: { message: (e as Error).message } }, { status: 500 });
1572
+ }
1573
+ };
1574
+
1575
+ if (url.pathname === "/v1/chat/completions" && request.method === "POST") {
1576
+ let body: ChatRequest;
1577
+ try {
1578
+ body = (await request.json()) as ChatRequest;
1579
+ } catch {
1580
+ return Response.json({ error: { message: "invalid JSON body" } }, { status: 400 });
1581
+ }
1582
+ return handleChat(body);
1583
+ }
1584
+
1585
+ // Anthropic Messages API (Phase 11) — on by default, mirroring
1586
+ // optiq serve (--anthropic defaults True; the drop-in claim
1587
+ // depends on it). Oracle: optiq/anthropic_shim.py, ported in
1588
+ // src/anthropic.ts. Point Claude Code at this port via
1589
+ // ANTHROPIC_BASE_URL for a fully local backend.
1590
+ if (url.pathname === "/v1/messages" && request.method === "POST") {
1591
+ const anthropicError = (status: number, type: string, message: string) =>
1592
+ Response.json({ type: "error", error: { type, message } }, { status });
1593
+ let anthropicBody: AnthropicRequest;
1594
+ try {
1595
+ anthropicBody = (await request.json()) as AnthropicRequest;
1596
+ } catch {
1597
+ return anthropicError(400, "invalid_request_error", "invalid JSON body");
1598
+ }
1599
+ let chatBody: ChatRequest;
1600
+ try {
1601
+ chatBody = anthropicToChatBody(anthropicBody) as unknown as ChatRequest;
1602
+ } catch (e) {
1603
+ return anthropicError(400, "invalid_request_error", (e as Error).message);
1604
+ }
1605
+ const resp = await handleChat(chatBody);
1606
+ if (!resp.ok) {
1607
+ const err = (await resp.json().catch(() => null)) as
1608
+ | { error?: { message?: string } }
1609
+ | null;
1610
+ return anthropicError(
1611
+ resp.status,
1612
+ resp.status >= 500 ? "api_error" : "invalid_request_error",
1613
+ err?.error?.message ?? "request failed",
1614
+ );
1615
+ }
1616
+ if (anthropicBody.stream) {
1617
+ return new Response(translateOpenAiSse(resp.body!, ctx.modelId), {
1618
+ headers: {
1619
+ "content-type": "text/event-stream",
1620
+ "cache-control": "no-cache",
1621
+ connection: "keep-alive",
1622
+ },
1623
+ });
1624
+ }
1625
+ return Response.json(chatJsonToAnthropic(await resp.json(), ctx.modelId));
1626
+ }
1627
+
1628
+ // OpenAI Responses API (Phase 11) — Codex/Cursor/Continue speak
1629
+ // this now. Oracle: optiq/responses_shim.py + responses_server.py,
1630
+ // ported in src/responses.ts. previous_response_id resumes a prior
1631
+ // conversation from the in-process store (TTL + byte-capped LRU).
1632
+ if (url.pathname === "/v1/responses" && request.method === "POST") {
1633
+ const responsesError = (status: number, message: string) =>
1634
+ Response.json(
1635
+ {
1636
+ error: {
1637
+ message,
1638
+ type: status >= 500 ? "server_error" : "invalid_request_error",
1639
+ param: null, code: null,
1640
+ },
1641
+ },
1642
+ { status },
1643
+ );
1644
+ let responsesBody: ResponsesRequest;
1645
+ try {
1646
+ responsesBody = (await request.json()) as ResponsesRequest;
1647
+ } catch {
1648
+ return responsesError(400, "invalid JSON body");
1649
+ }
1650
+
1651
+ // previous_response_id: prepend the prior conversation's input
1652
+ // + output (as input items); carry instructions forward only if
1653
+ // the new request omits them (oracle semantics).
1654
+ const prevId = responsesBody.previous_response_id ?? null;
1655
+ if (prevId) {
1656
+ const prior = responseStore.get(prevId);
1657
+ if (!prior)
1658
+ return responsesError(404, `previous_response_id '${prevId}' not found or expired`);
1659
+ const prepended = [...prior.input, ...outputItemsToInputItems(prior.output)];
1660
+ const newInput =
1661
+ typeof responsesBody.input === "string"
1662
+ ? responsesBody.input
1663
+ ? [{ type: "message", role: "user", content: responsesBody.input }]
1664
+ : []
1665
+ : responsesBody.input ?? [];
1666
+ responsesBody = {
1667
+ ...responsesBody,
1668
+ input: [...prepended, ...newInput] as Array<Record<string, unknown>>,
1669
+ instructions: responsesBody.instructions ?? prior.instructions ?? undefined,
1670
+ };
1671
+ }
1672
+ // Remember the effective input so a later follow-up that chains
1673
+ // off THIS response sees the full history.
1674
+ const capturedInput: unknown[] =
1675
+ typeof responsesBody.input === "string"
1676
+ ? [{ type: "message", role: "user", content: responsesBody.input }]
1677
+ : [...(responsesBody.input ?? [])];
1678
+ const capturedInstructions = responsesBody.instructions ?? null;
1679
+
1680
+ let chatBody: ChatRequest;
1681
+ try {
1682
+ chatBody = responsesToChatBody(responsesBody) as unknown as ChatRequest;
1683
+ } catch (e) {
1684
+ return responsesError(400, (e as Error).message);
1685
+ }
1686
+ const resp = await handleChat(chatBody);
1687
+ if (!resp.ok) {
1688
+ const err = (await resp.json().catch(() => null)) as
1689
+ | { error?: { message?: string } }
1690
+ | null;
1691
+ return responsesError(resp.status, err?.error?.message ?? "request failed");
1692
+ }
1693
+ if (responsesBody.stream) {
1694
+ const body = translateOpenAiSseToResponses(
1695
+ resp.body!, ctx.modelId, prevId,
1696
+ (final) =>
1697
+ responseStore.put(final.id as string, {
1698
+ input: capturedInput,
1699
+ output: final.output as unknown[],
1700
+ instructions: capturedInstructions,
1701
+ }),
1702
+ );
1703
+ return new Response(body, {
1704
+ headers: {
1705
+ "content-type": "text/event-stream",
1706
+ "cache-control": "no-cache",
1707
+ connection: "keep-alive",
1708
+ },
1709
+ });
1710
+ }
1711
+ const responses = chatJsonToResponses(await resp.json(), ctx.modelId, prevId);
1712
+ responseStore.put(responses.id as string, {
1713
+ input: capturedInput,
1714
+ output: responses.output as unknown[],
1715
+ instructions: capturedInstructions,
1716
+ });
1717
+ return Response.json(responses);
1718
+ }
1719
+
1720
+ // --- Lab API: dataset builder + quantize + finetune + jobs -------
1721
+ if (url.pathname === "/api/dataset/templates" && request.method === "GET") {
1722
+ const { TEMPLATES } = await import("./dataset");
1723
+ return Response.json({ templates: TEMPLATES });
1724
+ }
1725
+ if (url.pathname === "/api/dataset/submit" && request.method === "POST") {
1726
+ const body = (await request.json().catch(() => ({}))) as {
1727
+ template_id?: string; inputs?: Record<string, unknown>; model_name?: string;
1728
+ };
1729
+ const { getTemplate } = await import("./dataset");
1730
+ if (!body.template_id || !getTemplate(body.template_id))
1731
+ return Response.json({ ok: false, error: `unknown template ${body.template_id}` }, { status: 400 });
1732
+ const store = await ensureJobs();
1733
+ const { submitInProcess } = await import("./jobs");
1734
+ const { homedir } = await import("node:os");
1735
+ const safe = body.template_id.replace(/[^a-z0-9_-]/gi, "");
1736
+ const outDir = `${homedir()}/.cache/mlx-bun/datasets/dataset-${safe}-${Date.now()}`;
1737
+ const { jobId } = submitInProcess(store, "dataset", {
1738
+ template_id: body.template_id, inputs: body.inputs ?? {},
1739
+ output_dir: outDir, api_url: `http://127.0.0.1:${server.port}`,
1740
+ model_name: body.model_name ?? "local",
1741
+ }, outDir);
1742
+ return Response.json({ ok: true, job_id: jobId, output_dir: outDir });
1743
+ }
1744
+
1745
+ if (url.pathname === "/api/quantize/inspect" && request.method === "POST") {
1746
+ const body = (await request.json().catch(() => ({}))) as { model_id?: string };
1747
+ const { inspectModel } = await import("./quantize");
1748
+ return Response.json(await inspectModel(body.model_id ?? ""));
1749
+ }
1750
+ if (url.pathname === "/api/quantize/submit" && request.method === "POST") {
1751
+ const body = (await request.json().catch(() => ({}))) as {
1752
+ model_id?: string; bits?: number; group_size?: number;
1753
+ target_bpw?: number; candidate_bits?: number[]; reference?: string;
1754
+ calibration_mix?: string; n_calibration?: number;
1755
+ };
1756
+ if (!body.model_id)
1757
+ return Response.json({ ok: false, error: "model_id required" }, { status: 400 });
1758
+ const store = await ensureJobs();
1759
+ const { submitSubprocess } = await import("./jobs");
1760
+ const { homedir } = await import("node:os");
1761
+ const bits = body.bits ?? 4, gs = body.group_size ?? 64;
1762
+ const base = body.model_id.split("/").filter(Boolean).at(-1)!.replace(/[^a-z0-9_.-]/gi, "");
1763
+ // Mixed-precision (sensitivity+knapsack) names the dir by target bpw.
1764
+ const suffix = body.target_bpw ? `mixed-${body.target_bpw}bpw` : `${bits}bit`;
1765
+ const outDir = `${homedir()}/.cache/mlx-bun/quants/${base}-OptiQ-${suffix}`;
1766
+ const { jobId } = submitSubprocess(store, "quantize", {
1767
+ model_id: body.model_id, out_dir: outDir, bits, group_size: gs,
1768
+ // forwarded to the mixed-precision path when target_bpw is set
1769
+ target_bpw: body.target_bpw, candidate_bits: body.candidate_bits,
1770
+ reference: body.reference, calibration_mix: body.calibration_mix,
1771
+ n_calibration: body.n_calibration,
1772
+ }, outDir);
1773
+ return Response.json({ ok: true, job_id: jobId, output_dir: outDir });
1774
+ }
1775
+
1776
+ if (url.pathname === "/api/finetune/inspect-dataset" && request.method === "POST") {
1777
+ const body = (await request.json().catch(() => ({}))) as { path?: string };
1778
+ const { inspectDataset } = await import("./train");
1779
+ return Response.json(await inspectDataset(body.path ?? ""));
1780
+ }
1781
+ if (url.pathname === "/api/finetune/submit" && request.method === "POST") {
1782
+ const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
1783
+ if (!body.model_dir || !body.data_dir)
1784
+ return Response.json({ ok: false, error: "model_dir and data_dir required" }, { status: 400 });
1785
+ const store = await ensureJobs();
1786
+ const { submitSubprocess } = await import("./jobs");
1787
+ const { homedir } = await import("node:os");
1788
+ const adapterPath = (body.adapter_path as string) ||
1789
+ `${homedir()}/.cache/mlx-bun/adapters/adapter-${Date.now()}`;
1790
+ const { jobId } = submitSubprocess(store, "finetune",
1791
+ { ...body, adapter_path: adapterPath }, adapterPath);
1792
+ return Response.json({ ok: true, job_id: jobId, adapter_path: adapterPath });
1793
+ }
1794
+ if (url.pathname === "/api/finetune/merge" && request.method === "POST") {
1795
+ const body = (await request.json().catch(() => ({}))) as {
1796
+ adapter_a?: string; adapter_b?: string; scales?: number[];
1797
+ };
1798
+ if (!body.adapter_a || !body.adapter_b)
1799
+ return Response.json({ ok: false, error: "adapter_a and adapter_b required" }, { status: 400 });
1800
+ try {
1801
+ const { mergeAdapters } = await import("./train");
1802
+ const { homedir } = await import("node:os");
1803
+ const mergedPath = `${homedir()}/.cache/mlx-bun/adapters/merged-${Date.now()}`;
1804
+ const stats = await mergeAdapters([body.adapter_a, body.adapter_b], mergedPath, body.scales);
1805
+ return Response.json({ ok: true, merged_path: mergedPath, stats });
1806
+ } catch (e) {
1807
+ return Response.json({ ok: false, error: (e as Error).message }, { status: 400 });
1808
+ }
1809
+ }
1810
+ if (url.pathname === "/api/finetune/export" && request.method === "POST") {
1811
+ const body = (await request.json().catch(() => ({}))) as {
1812
+ base_model?: string; adapter_path?: string; method?: string;
1813
+ };
1814
+ if (!body.base_model || !body.adapter_path)
1815
+ return Response.json({ ok: false, error: "base_model and adapter_path required" }, { status: 400 });
1816
+ try {
1817
+ const { exportAdapter } = await import("./train");
1818
+ const { homedir } = await import("node:os");
1819
+ const exportPath = `${homedir()}/.cache/mlx-bun/exports/export-${Date.now()}`;
1820
+ const manifest = await exportAdapter(exportPath, body.base_model, body.adapter_path, body.method);
1821
+ return Response.json({ ok: true, export_path: exportPath, manifest });
1822
+ } catch (e) {
1823
+ return Response.json({ ok: false, error: (e as Error).message }, { status: 400 });
1824
+ }
1825
+ }
1826
+
1827
+ // --- HF token settings + push-to-hub (model & dataset repos) ------
1828
+ if (url.pathname === "/api/settings/hf-token" && request.method === "GET") {
1829
+ const { hasHfToken } = await import("./hf-push");
1830
+ return Response.json({ ok: true, hasToken: hasHfToken() });
1831
+ }
1832
+ if (url.pathname === "/api/settings/hf-token" && request.method === "POST") {
1833
+ const body = (await request.json().catch(() => ({}))) as { token?: string };
1834
+ if (!body.token) return Response.json({ ok: false, error: "token required" }, { status: 400 });
1835
+ const { saveHfToken } = await import("./hf-push");
1836
+ saveHfToken(body.token);
1837
+ return Response.json({ ok: true });
1838
+ }
1839
+ {
1840
+ const m = url.pathname.match(/^\/api\/(quantize|finetune|dataset)\/push$/);
1841
+ if (m && request.method === "POST") {
1842
+ const kind = m[1]!;
1843
+ const body = (await request.json().catch(() => ({}))) as {
1844
+ job_id?: string; repo_id?: string; private?: boolean; source_path?: string;
1845
+ };
1846
+ if (!body.repo_id) return Response.json({ ok: false, error: "repo_id required" }, { status: 400 });
1847
+ const { getHfToken, uploadFolder } = await import("./hf-push");
1848
+ const token = getHfToken();
1849
+ if (!token)
1850
+ return Response.json({ ok: false, error: "no HF token saved — add one in Settings → Hugging Face" }, { status: 400 });
1851
+ const store = await ensureJobs();
1852
+ let dir = body.source_path;
1853
+ if (!dir && body.job_id) dir = store.get(body.job_id)?.output_path ?? undefined;
1854
+ if (!dir) return Response.json({ ok: false, error: "no source dir (pass job_id or source_path)" }, { status: 400 });
1855
+ try {
1856
+ const r = await uploadFolder(dir, body.repo_id, {
1857
+ repoType: kind === "dataset" ? "dataset" : "model",
1858
+ private: !!body.private, token,
1859
+ });
1860
+ return Response.json({ ok: true, url: r.url });
1861
+ } catch (e) {
1862
+ return Response.json({ ok: false, error: (e as Error).message }, { status: 400 });
1863
+ }
1864
+ }
1865
+ }
1866
+
1867
+ if (url.pathname === "/api/jobs" && request.method === "GET") {
1868
+ const store = await ensureJobs();
1869
+ const limit = Number(url.searchParams.get("limit") ?? "50");
1870
+ const kind = url.searchParams.get("kind") ?? undefined;
1871
+ return Response.json({ ok: true, jobs: store.recent(limit, kind) });
1872
+ }
1873
+ {
1874
+ const m = url.pathname.match(/^\/api\/jobs\/([^/]+?)(\/stream)?$/);
1875
+ if (m && request.method === "GET") {
1876
+ const store = await ensureJobs();
1877
+ if (m[2]) {
1878
+ const { streamJobResponse } = await import("./jobs");
1879
+ return streamJobResponse(store, m[1]!);
1880
+ }
1881
+ const job = store.get(m[1]!);
1882
+ if (!job) return Response.json({ ok: false, error: "job not found" }, { status: 404 });
1883
+ return Response.json({ ok: true, job });
1884
+ }
1885
+ }
1886
+
1887
+ return Response.json({ error: { message: "not found" } }, { status: 404 });
1888
+ },
1889
+ });
1890
+ return serverRef;
1891
+ }