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.
- package/LICENSE +21 -0
- package/README.md +362 -0
- package/THIRD_PARTY_LICENSES.md +41 -0
- package/bin/mlx-bun.mjs +32 -0
- package/docs/curve-designer.html +303 -0
- package/docs/design/adapters-end-to-end.md +201 -0
- package/docs/design/compat-cli-surface-design.md +227 -0
- package/docs/design/docs-reorg-plan.md +152 -0
- package/docs/design/hlg-sampling.md +340 -0
- package/docs/design/memory-system.md +339 -0
- package/docs/design/optimization_plan.md +344 -0
- package/docs/design/parallel-slots.md +515 -0
- package/docs/design/runtime-isolation.md +247 -0
- package/docs/design/segmented-backward-training.md +508 -0
- package/docs/investigations/chunk-finetune-experiment.md +84 -0
- package/docs/investigations/curve-bisector-routing.md +123 -0
- package/docs/investigations/curve-runs/bisector-route.json +98 -0
- package/docs/investigations/curve-runs/bisector-trace.json +1393 -0
- package/docs/investigations/curve-runs/curve-terrain.json +1179 -0
- package/docs/investigations/curve-runs/curve-terrain.svg +42 -0
- package/docs/investigations/curve-terrain.html +105 -0
- package/docs/investigations/expert-offload-single-user-moe.md +292 -0
- package/docs/investigations/hlg-ab.html +34 -0
- package/docs/investigations/hlg-explorer.html +238 -0
- package/docs/investigations/hlg-figs/cliff.svg +1 -0
- package/docs/investigations/hlg-figs/cross.svg +1 -0
- package/docs/investigations/hlg-figs/edges.svg +1 -0
- package/docs/investigations/hlg-figs/k20-grids.svg +1 -0
- package/docs/investigations/hlg-figs/k20-peaks.svg +1 -0
- package/docs/investigations/hlg-figs/lw-curve.svg +1 -0
- package/docs/investigations/hlg-figs/terrain.svg +1 -0
- package/docs/investigations/hlg-report.html +262 -0
- package/docs/investigations/hlg-runs/hlg-ab.json +149 -0
- package/docs/investigations/hlg-runs/hlg-blind-prompts.md +87 -0
- package/docs/investigations/hlg-runs/hlg-blind.json +107 -0
- package/docs/investigations/hlg-runs/hlg-cliff.json +197 -0
- package/docs/investigations/hlg-runs/hlg-cross.json +505 -0
- package/docs/investigations/hlg-runs/hlg-edge.json +388 -0
- package/docs/investigations/hlg-runs/hlg-eval1.json +25 -0
- package/docs/investigations/hlg-runs/hlg-frontier.json +86 -0
- package/docs/investigations/hlg-runs/hlg-grid.json +242 -0
- package/docs/investigations/hlg-runs/hlg-grid2.json +343 -0
- package/docs/investigations/hlg-runs/hlg-lw-coarse.json +110 -0
- package/docs/investigations/hlg-runs/hlg-map.json +383 -0
- package/docs/investigations/hlg-runs/run0-original.log +159 -0
- package/docs/investigations/hlg-runs/runA-recommended.log +131 -0
- package/docs/investigations/hlg-runs/runAA-cliff-cpm5.log +26 -0
- package/docs/investigations/hlg-runs/runAB-grid2-e4b.log +18 -0
- package/docs/investigations/hlg-runs/runAC-peak-e4b-partial.log +25 -0
- package/docs/investigations/hlg-runs/runAD-ab-samples.log +8 -0
- package/docs/investigations/hlg-runs/runAE-edge-e4b.log +51 -0
- package/docs/investigations/hlg-runs/runAF-logit-calibration.log +10 -0
- package/docs/investigations/hlg-runs/runAG-eval-config2.log +16 -0
- package/docs/investigations/hlg-runs/runB-gain-variants.log +192 -0
- package/docs/investigations/hlg-runs/runC-pivots.log +162 -0
- package/docs/investigations/hlg-runs/runD-oetf.log +132 -0
- package/docs/investigations/hlg-runs/runE-eotf.log +162 -0
- package/docs/investigations/hlg-runs/runF-shaper-working.log +86 -0
- package/docs/investigations/hlg-runs/runG-shaper-sweep.log +101 -0
- package/docs/investigations/hlg-runs/runH-wsweep.log +176 -0
- package/docs/investigations/hlg-runs/runI-asweep.log +176 -0
- package/docs/investigations/hlg-runs/runJ-knobmap.log +49 -0
- package/docs/investigations/hlg-runs/runK-frontier.log +19 -0
- package/docs/investigations/hlg-runs/runL-widemap.log +65 -0
- package/docs/investigations/hlg-runs/runM-frontier-emb.log +15 -0
- package/docs/investigations/hlg-runs/runN-verify.log +116 -0
- package/docs/investigations/hlg-runs/runO-frontier-seed5000.log +15 -0
- package/docs/investigations/hlg-runs/runP-grid-AxSm.log +15 -0
- package/docs/investigations/hlg-runs/runQ-grid-tgW.log +15 -0
- package/docs/investigations/hlg-runs/runR-blind-transcript.log +79 -0
- package/docs/investigations/hlg-runs/runS-cross-coherence.log +33 -0
- package/docs/investigations/hlg-runs/runT-cross-cpm5.log +33 -0
- package/docs/investigations/hlg-runs/runU-cross-12b.log +33 -0
- package/docs/investigations/hlg-runs/runV-lw-e4b.log +22 -0
- package/docs/investigations/hlg-runs/runW-lw-12b.log +22 -0
- package/docs/investigations/hlg-runs/runX-lw-cpm5.log +22 -0
- package/docs/investigations/hlg-runs/runY-cliff-e4b.log +26 -0
- package/docs/investigations/hlg-runs/runZ-cliff-12b.log +26 -0
- package/docs/investigations/hlg-sampling-investigation.md +970 -0
- package/docs/investigations/lab-build-journal.md +157 -0
- package/docs/investigations/pi-builtin-investigation.md +308 -0
- package/docs/investigations/starter-model-port-handoff.md +323 -0
- package/docs/planning/IDEAS.md +26 -0
- package/docs/planning/PRODUCT_ROADMAP.md +426 -0
- package/docs/planning/ResearchTopics.md +119 -0
- package/docs/planning/chunk-task-roadmap.md +77 -0
- package/docs/planning/journal.md +155 -0
- package/docs/reference/distribution.md +121 -0
- package/docs/reference/embedding.md +168 -0
- package/docs/reference/library-api.md +153 -0
- package/docs/reference/server-api.md +224 -0
- package/docs/reference/server-config.md +243 -0
- package/docs/segmented-backward-handoff.md +148 -0
- package/package.json +68 -0
- package/src/anthropic.ts +491 -0
- package/src/chat-template.ts +186 -0
- package/src/cli.ts +1075 -0
- package/src/config.ts +229 -0
- package/src/curve-sampler.ts +122 -0
- package/src/dataset/generators.ts +1087 -0
- package/src/dataset/index.ts +12 -0
- package/src/dataset/job.ts +81 -0
- package/src/dataset/llm.ts +71 -0
- package/src/dataset/registry.ts +528 -0
- package/src/download.ts +283 -0
- package/src/eval/README.md +86 -0
- package/src/eval/kl-prompts.ts +29 -0
- package/src/eval/kl.ts +294 -0
- package/src/eval/quality-db.ts +92 -0
- package/src/eval/runner.ts +102 -0
- package/src/eval/score.ts +49 -0
- package/src/eval/tasks/bfcl.ts +500 -0
- package/src/eval/tasks/gsm8k.ts +85 -0
- package/src/eval/tasks/hashhop.ts +109 -0
- package/src/eval/tasks/humaneval.ts +252 -0
- package/src/eval/tasks/ifeval.ts +354 -0
- package/src/eval/tasks/mmlu.ts +145 -0
- package/src/evaldb.ts +199 -0
- package/src/expert-offload-build.ts +77 -0
- package/src/expert-offload.ts +61 -0
- package/src/expert-trace.ts +67 -0
- package/src/fit.ts +254 -0
- package/src/generate.ts +451 -0
- package/src/harness-pi.ts +177 -0
- package/src/hf-push.ts +628 -0
- package/src/index.ts +22 -0
- package/src/jobs/db.ts +139 -0
- package/src/jobs/index.ts +29 -0
- package/src/jobs/job-entry.ts +106 -0
- package/src/jobs/runner.ts +304 -0
- package/src/jobs/sse.ts +114 -0
- package/src/jobs/types.ts +58 -0
- package/src/kv-store.ts +185 -0
- package/src/lora.ts +314 -0
- package/src/md-modules.d.ts +7 -0
- package/src/mlx/array.ts +237 -0
- package/src/mlx/autograd.ts +267 -0
- package/src/mlx/checkpoint.ts +116 -0
- package/src/mlx/compile.ts +158 -0
- package/src/mlx/custom-vjp.ts +140 -0
- package/src/mlx/ffi.ts +433 -0
- package/src/mlx/metal-kernel.ts +130 -0
- package/src/mlx/ops.ts +707 -0
- package/src/mmap.ts +90 -0
- package/src/model/batched-mask.ts +204 -0
- package/src/model/batched-rotating.ts +300 -0
- package/src/model/compiled-decode.ts +681 -0
- package/src/model/factory.ts +27 -0
- package/src/model/fingerprint.ts +33 -0
- package/src/model/flash-attention.ts +674 -0
- package/src/model/fused-decode-kernel.ts +305 -0
- package/src/model/fused-geglu-kernel.ts +87 -0
- package/src/model/gemma4-base.ts +1616 -0
- package/src/model/gemma4.ts +1012 -0
- package/src/model/generated/gemma4-12b.ts +526 -0
- package/src/model/generated/gemma4-26b.ts +500 -0
- package/src/model/generated/gemma4-e4b.ts +757 -0
- package/src/model/generated/index.ts +18 -0
- package/src/model/minicpm5.ts +278 -0
- package/src/model/qwen3-delta.ts +224 -0
- package/src/model/qwen3_5.ts +429 -0
- package/src/model/support.ts +46 -0
- package/src/native-pack.ts +152 -0
- package/src/pi-provider.ts +95 -0
- package/src/pi-terminal.ts +264 -0
- package/src/pi-web.ts +924 -0
- package/src/preflight.ts +123 -0
- package/src/prompt-cache.ts +113 -0
- package/src/quantize/allocator.ts +627 -0
- package/src/quantize/calibration.ts +180 -0
- package/src/quantize/config-writer.ts +132 -0
- package/src/quantize/index.ts +57 -0
- package/src/quantize/job.ts +136 -0
- package/src/quantize/quantizer.ts +462 -0
- package/src/quantize/safetensors-writer.ts +171 -0
- package/src/quantize/sensitivity.ts +234 -0
- package/src/registry.ts +336 -0
- package/src/responses.ts +774 -0
- package/src/safetensors.ts +158 -0
- package/src/sampler.ts +517 -0
- package/src/serve/batch-scheduler.ts +362 -0
- package/src/serve/generation-gateway.ts +160 -0
- package/src/server.ts +1891 -0
- package/src/spec/drafter.ts +261 -0
- package/src/spec/generate.ts +221 -0
- package/src/status-page.html +497 -0
- package/src/tokenizer.ts +38 -0
- package/src/tool-call.ts +242 -0
- package/src/train/dataset.ts +461 -0
- package/src/train/export.ts +32 -0
- package/src/train/forward.ts +229 -0
- package/src/train/index.ts +43 -0
- package/src/train/job.ts +148 -0
- package/src/train/lora-params.ts +194 -0
- package/src/train/loss.ts +342 -0
- package/src/train/merge.ts +195 -0
- package/src/train/optimizer.ts +176 -0
- package/src/train/rank.ts +101 -0
- package/src/train/segmented.ts +520 -0
- package/src/train/trainer.ts +600 -0
- package/src/tui.ts +161 -0
- package/src/vision/embedder.ts +168 -0
- package/src/vision/preprocess.ts +247 -0
- package/src/vision/prompt.ts +158 -0
- package/src/vision/siglip.ts +599 -0
- package/src/web/app.html +2471 -0
- package/src/web/skills/web-research/SKILL.md +44 -0
- package/src/web/skills.ts +59 -0
- package/src/web-tools.ts +426 -0
- 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
|
+
}
|