mlx-bun 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -32
- package/docs/README.md +55 -42
- package/docs/archive/investigations/benchmark-ledger-through-v0.4.0.md +3617 -0
- package/docs/archive/planning/release-notes-v0.5.0.md +37 -0
- package/docs/design/batching.md +220 -7
- package/docs/design/decode-speed-program.md +121 -66
- package/docs/design/generic-model-support.md +116 -0
- package/docs/design/kv-cache.md +380 -211
- package/docs/design/speculative-decoding.md +165 -26
- package/docs/design/turboquant.md +20 -7
- package/docs/design/unified-engine-frontier-plan.md +120 -13
- package/docs/planning/PRODUCT_ROADMAP.md +5 -6
- package/docs/planning/public-readiness-handoff.md +244 -0
- package/docs/planning/public-readiness.md +149 -0
- package/docs/reference/benchmarks.md +3463 -3489
- package/docs/reference/cli.md +62 -1
- package/docs/reference/distribution.md +29 -4
- package/docs/reference/environment.md +104 -8
- package/docs/reference/library-api.md +18 -4
- package/docs/reference/memory.md +1 -1
- package/docs/reference/models.md +17 -5
- package/docs/reference/server-api.md +181 -11
- package/docs/reference/server-config.md +232 -105
- package/package.json +2 -2
- package/src/audio/audiotoolbox.ts +112 -0
- package/src/audio/conformer.ts +8 -0
- package/src/audio/mic-capture.ts +98 -0
- package/src/audio/silero-vad.ts +379 -0
- package/src/audio/whisper-decode.ts +774 -0
- package/src/audio/whisper-fast.ts +732 -0
- package/src/audio/whisper-format.ts +73 -0
- package/src/audio/whisper-mel.ts +185 -0
- package/src/audio/whisper-timing.ts +274 -0
- package/src/audio/whisper-tokenizer.ts +239 -0
- package/src/audio/whisper-transcribe.ts +410 -0
- package/src/backends/mlx/autoregressive.ts +22 -1
- package/src/backends/mlx/batch-group.ts +111 -27
- package/src/backends/mlx/cache-layout.ts +3 -3
- package/src/backends/mlx/continuation-persistence.ts +5 -4
- package/src/backends/mlx/encoder-cache.ts +25 -0
- package/src/backends/mlx/fill-append.ts +21 -8
- package/src/backends/mlx/fill-group.ts +497 -0
- package/src/backends/mlx/gateway-binding.ts +56 -12
- package/src/backends/mlx/kv-maintenance.ts +30 -2
- package/src/backends/mlx/mixed-iteration.ts +62 -0
- package/src/backends/mlx/model-prompt.ts +46 -32
- package/src/backends/mlx/model-serving.ts +4 -3
- package/src/backends/mlx/paged-cache-codec.ts +27 -0
- package/src/backends/mlx/prefill-cohort.ts +15 -4
- package/src/backends/mlx/prefill-policy.ts +26 -0
- package/src/backends/mlx/prefill-rows.ts +57 -13
- package/src/backends/mlx/prompt-input.ts +29 -0
- package/src/backends/mlx/qwen-prompt-input.ts +40 -0
- package/src/backends/mlx/request-state-policy.ts +18 -5
- package/src/backends/mlx/rollback.ts +6 -3
- package/src/backends/mlx/serial-executor.ts +16 -7
- package/src/backends/mlx/speculative-group.ts +105 -26
- package/src/backends/mlx/speculative.ts +24 -7
- package/src/backends/mlx/target-layout-capability.ts +1 -1
- package/src/backends/mlx/transitioning-kv-rows.ts +8 -0
- package/src/cli.ts +322 -0
- package/src/contracts/execution.ts +10 -0
- package/src/contracts/object-cache.ts +8 -0
- package/src/contracts/pi-provider.ts +1 -1
- package/src/contracts/pi.ts +3 -1
- package/src/contracts/prefix-cache.ts +13 -3
- package/src/contracts/scheduling.ts +9 -0
- package/src/engine/execution-plan.ts +23 -12
- package/src/engine/execution-tasks.ts +44 -0
- package/src/engine/namespaced-cache.ts +13 -0
- package/src/engine/scheduler.ts +22 -2
- package/src/fill/fill-session.ts +14 -3
- package/src/generate.ts +28 -22
- package/src/grammar.ts +13 -0
- package/src/inference/prefill.ts +6 -0
- package/src/kv-scheme.ts +22 -0
- package/src/kv-store.ts +115 -79
- package/src/lab/README.md +5 -1
- package/src/lab/paged-kv/paged-attention.ts +117 -0
- package/src/lab/paged-kv/paged-kv-rows.ts +33 -5
- package/src/lab/paged-kv/paged-kv.ts +108 -42
- package/src/md-modules.d.ts +5 -0
- package/src/mlx/array.ts +37 -0
- package/src/mlx/ffi.ts +19 -0
- package/src/mlx/normalized-argmax.ts +116 -0
- package/src/mlx/ops.ts +97 -10
- package/src/model/batched-turboquant-kv.ts +6 -5
- package/src/model/delayed-quantized-kv.ts +3 -3
- package/src/model/delayed-rotating-quantized-kv.ts +1 -1
- package/src/model/delayed-turboquant-kv.ts +6 -4
- package/src/model/factory.ts +26 -0
- package/src/model/fingerprint.ts +12 -0
- package/src/model/full-prefill-row.ts +1 -1
- package/src/model/gemma4-base.ts +25 -6
- package/src/model/gemma4.ts +82 -2
- package/src/model/kv-attention-view.ts +4 -1
- package/src/model/profile.ts +29 -4
- package/src/model/quantized-append-attention.ts +31 -0
- package/src/model/qwen3-mrope.ts +7 -9
- package/src/model/qwen3_5.ts +99 -37
- package/src/model/support.ts +8 -0
- package/src/model/token-groups.ts +66 -0
- package/src/model/trellis-linear.ts +2 -2
- package/src/model/turboquant-codec.ts +6 -0
- package/src/model/whisper.ts +435 -0
- package/src/native/mic_capture.swift +92 -0
- package/src/native-pack.ts +6 -5
- package/src/pi-web.ts +9 -1
- package/src/prompt-cache.ts +265 -43
- package/src/runtime-config.ts +1 -5
- package/src/safetensors.ts +9 -2
- package/src/sampler.ts +5 -4
- package/src/serve/audio-routes.ts +323 -0
- package/src/serve/chat-request.ts +2 -0
- package/src/serve/chat-stage.ts +17 -12
- package/src/serve/completion-executor.ts +9 -1
- package/src/serve/discovery-routes.ts +17 -1
- package/src/serve/generated-token-history.ts +57 -0
- package/src/serve/generation-gateway.ts +52 -11
- package/src/serve/model-binding.ts +7 -1
- package/src/serve/preparation.ts +3 -3
- package/src/serve/prompt-response-trace.ts +14 -0
- package/src/serve/request-plan.ts +2 -0
- package/src/serve/request-prep.ts +38 -21
- package/src/serve/transcription-server.ts +64 -0
- package/src/serve/transcription-service.ts +502 -0
- package/src/server.ts +132 -164
- package/src/spec/assistant-rows.ts +1 -0
- package/src/spec/deepspec-rows.ts +1 -1
- package/src/spec/dflash-source.ts +1 -1
- package/src/spec/glm52-mtp-source.ts +1 -0
- package/src/spec/ngram-source.ts +27 -5
- package/src/spec/projected-draft-rows.ts +2 -2
- package/src/spec/qwen-mtp-rows.ts +11 -0
- package/src/spec/qwen-mtp-source.ts +3 -0
- package/src/spec/serve-loop.ts +5 -3
- package/src/spec/source.ts +14 -1
- package/src/spec/two-model-rows.ts +1 -0
- package/src/spec/two-model.ts +2 -2
- package/src/ssd-cache.ts +76 -17
- package/src/ssd-durability.ts +20 -22
- package/src/storage/host-buffer.ts +24 -0
- package/src/storage/kv-writer.ts +83 -0
- package/src/storage/kv-writer.worker.js +264 -0
- package/src/storage/retention-policy.ts +37 -0
- package/src/tiered-prompt-cache.ts +45 -0
- package/src/vision/embedder.ts +8 -0
- package/src/vision/prompt.ts +101 -65
- package/src/vision/qwen3vl-prompt.ts +101 -64
- package/src/vision/qwen3vl-tower.ts +11 -0
- package/src/vision/siglip.ts +8 -0
- package/src/web/app.html +12 -0
- package/src/web/app.js +160 -0
- package/src/web/src/chat.ts +4 -1
- package/src/web/src/composer.ts +3 -0
- package/src/web/src/voice.ts +141 -0
- package/src/web/sw.js +1 -1
- /package/docs/{planning → archive/planning}/release-notes-v0.4.0.md +0 -0
package/src/cli.ts
CHANGED
|
@@ -74,6 +74,8 @@ Commands:
|
|
|
74
74
|
generate One-shot raw text generation from a local model (no server)
|
|
75
75
|
benchmark Measure decode/prefill speed of OUR stack on this machine
|
|
76
76
|
embed Text embeddings from a local embedding model (Qwen3-Embedding)
|
|
77
|
+
transcribe Speech-to-text from an audio file with a local Whisper model
|
|
78
|
+
dictate Push-to-talk: stream your microphone into Whisper, print/copy/type the text
|
|
77
79
|
evals Show recent benchmark runs (all stacks)
|
|
78
80
|
help Show help for a command (also: mlx-bun <command> --help)
|
|
79
81
|
|
|
@@ -164,6 +166,19 @@ Model & quality:
|
|
|
164
166
|
request's \`adapter\` field, incl. "none", wins).
|
|
165
167
|
--adapter-path is accepted as the mlx_lm.server
|
|
166
168
|
alias. Hot-swap via POST /v1/adapters unchanged.
|
|
169
|
+
--whisper-model <p|query> Whisper checkpoint for /v1/audio/transcriptions
|
|
170
|
+
[default: the first downloaded whisper model,
|
|
171
|
+
resolved on the first request]. Serving a whisper
|
|
172
|
+
model ALONE starts a transcription-only server.
|
|
173
|
+
--preload Transcription-only server: load the Whisper
|
|
174
|
+
weights before listening instead of on the first
|
|
175
|
+
request.
|
|
176
|
+
--whisper-idle-unload <s> Keep the Whisper weights for this many idle seconds
|
|
177
|
+
after a take before disposing them. [default: 0 —
|
|
178
|
+
released right after every take, so the chat model
|
|
179
|
+
has the memory back before it prefills; a cold
|
|
180
|
+
take costs ~150 ms more than a warm one]
|
|
181
|
+
--whisper-resident Never dispose the Whisper weights (sotto-style).
|
|
167
182
|
--draft-model <query> Speculative decoding: a drafter proposes tokens
|
|
168
183
|
the main model verifies (exact results, faster
|
|
169
184
|
decode when drafts land). The artifact's kind is
|
|
@@ -268,6 +283,74 @@ Kill switches (bit-exact A/B levers — each selects a slower same-parity path):
|
|
|
268
283
|
bit-exact with the resident path`;
|
|
269
284
|
|
|
270
285
|
const HELP: Record<string, string> = {
|
|
286
|
+
dictate: `mlx-bun dictate — push-to-talk dictation from your microphone
|
|
287
|
+
|
|
288
|
+
Usage: mlx-bun dictate [query] [options]
|
|
289
|
+
|
|
290
|
+
Captures the mic through the AVAudioEngine sidecar (16 kHz mono; macOS asks
|
|
291
|
+
for Microphone permission on first use) and streams it into a Whisper
|
|
292
|
+
transcription session while you speak — every finished 30 s window is
|
|
293
|
+
transcribed during capture, so the result lands about one window after you
|
|
294
|
+
stop. Silence never runs Whisper (Silero VAD).
|
|
295
|
+
|
|
296
|
+
Modes:
|
|
297
|
+
(default) Press Enter to start a take, Enter again to stop;
|
|
298
|
+
q + Enter or Ctrl-C quits
|
|
299
|
+
--hotkey [keycode] Hold a key instead (default 61 = Right Option, like
|
|
300
|
+
sotto); needs Input Monitoring for your terminal
|
|
301
|
+
Backend:
|
|
302
|
+
[query] / --model <p> Whisper model for in-process transcription (default:
|
|
303
|
+
the first downloaded whisper checkpoint)
|
|
304
|
+
--server <url> Use a running mlx-bun server's /v1/audio/sessions
|
|
305
|
+
instead of loading the model here
|
|
306
|
+
--idle-unload <s> Release the weights after this many idle seconds
|
|
307
|
+
(0 = right after each take; default 30); --resident
|
|
308
|
+
keeps them loaded
|
|
309
|
+
Decoding:
|
|
310
|
+
--language <code> default en; "auto" detects
|
|
311
|
+
--beam-size <n> beam search (default greedy)
|
|
312
|
+
--prompt "…" initial prompt / --vocabulary "a,b,c" hint terms
|
|
313
|
+
--no-vad skip the Silero gate
|
|
314
|
+
Output (any combination; default prints):
|
|
315
|
+
--copy copy the transcript to the clipboard (pbcopy)
|
|
316
|
+
--type type it into the frontmost app via System Events
|
|
317
|
+
keystrokes (needs Accessibility for your terminal);
|
|
318
|
+
--type-delay <s> waits first so you can Cmd-Tab
|
|
319
|
+
(default 1 in Enter mode, 0 with --hotkey)`,
|
|
320
|
+
transcribe: `mlx-bun transcribe — speech-to-text with a local Whisper model (no server)
|
|
321
|
+
|
|
322
|
+
Usage: mlx-bun transcribe <audio-file> [query] [options]
|
|
323
|
+
|
|
324
|
+
Loads a Whisper checkpoint (mlx-community/whisper-*) and prints the
|
|
325
|
+
transcript. WAV is decoded natively; any other container CoreAudio reads
|
|
326
|
+
(mp3/m4a/flac/ogg/aiff/…) is transcoded with afconvert first.
|
|
327
|
+
|
|
328
|
+
Model:
|
|
329
|
+
[query] / --model <p> Whisper model; omitted = the first downloaded
|
|
330
|
+
\`whisper\` checkpoint (errors with an mlx-bun get
|
|
331
|
+
suggestion when none)
|
|
332
|
+
Decoding:
|
|
333
|
+
--language <code> ISO code or name ("en", "japanese"); "auto" or omitted
|
|
334
|
+
detects the language from the first 30 s
|
|
335
|
+
--task translate Translate to English instead of transcribing
|
|
336
|
+
--beam-size <n> Beam search width (default: greedy)
|
|
337
|
+
--temperature <t> Sampling temperature; the default runs the
|
|
338
|
+
(0, 0.2, …, 1.0) fallback ladder like mlx-whisper
|
|
339
|
+
--no-fallback Temperature 0 only, no retry ladder
|
|
340
|
+
--prompt "…" Initial prompt (vocabulary hints / style)
|
|
341
|
+
--no-timestamps Decode text only (<|notimestamps|>)
|
|
342
|
+
--no-condition Do not condition each window on the previous text
|
|
343
|
+
--vad Silero VAD gate: skip Whisper when no speech is
|
|
344
|
+
detected (weights: ggml-org/whisper-vad in the HF cache
|
|
345
|
+
or --vad-model <path>); --vad-trim crops to speech
|
|
346
|
+
--word-timestamps Word-level timestamps (verbose_json / verbose output)
|
|
347
|
+
--faithful Run the oracle-parity graph instead of the fast path
|
|
348
|
+
--audio-ctx <n> Lab: encode only n of the 1500 encoder positions
|
|
349
|
+
(whisper.cpp -ac); cheaper for short clips, degrades
|
|
350
|
+
below ~1024 — off by default
|
|
351
|
+
Output:
|
|
352
|
+
--format <f> text (default) · json · verbose_json · srt · vtt
|
|
353
|
+
--verbose Print each segment with its time range as it decodes`,
|
|
271
354
|
pi: `mlx-bun pi — drop into a coding-agent session on a local model
|
|
272
355
|
|
|
273
356
|
Usage: mlx-bun pi [options] [message...]
|
|
@@ -1023,6 +1106,17 @@ function serverRuntimeFlags(): { port: number; serverOptions: import("./server")
|
|
|
1023
1106
|
// `mlx-bun pi` attach both go through localhost, so loopback-only is
|
|
1024
1107
|
// transparent to them.
|
|
1025
1108
|
serverOptions.hostname = opt("host", "127.0.0.1")!;
|
|
1109
|
+
// Speech-to-text companion: explicit checkpoint + residency policy.
|
|
1110
|
+
const whisperIdleRaw = opt("whisper-idle-unload");
|
|
1111
|
+
if (whisperIdleRaw !== null) {
|
|
1112
|
+
const n = Number(whisperIdleRaw);
|
|
1113
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
1114
|
+
console.error(`--whisper-idle-unload expects seconds >= 0 (got "${whisperIdleRaw}")`);
|
|
1115
|
+
process.exit(1);
|
|
1116
|
+
}
|
|
1117
|
+
serverOptions.whisperIdleUnloadSec = n;
|
|
1118
|
+
}
|
|
1119
|
+
if (flag("whisper-resident")) serverOptions.whisperResident = true;
|
|
1026
1120
|
// Server-wide default for the chat template's enable_thinking variable
|
|
1027
1121
|
// (CPM/MiniCPM5 and other hybrid-reasoning models). Unset ⇒ the model's
|
|
1028
1122
|
// own default (false for MiniCPM5). onOff accepts true|false|on|off|1|0.
|
|
@@ -1637,6 +1731,40 @@ switch (cmd) {
|
|
|
1637
1731
|
// straight from that path; anything else resolves like the positional
|
|
1638
1732
|
// fuzzy query. Precedence: --model > positional > --query > auto-pick.
|
|
1639
1733
|
const { m, picked } = await resolveModelAuto(opt("model") ?? positional(0) ?? opt("query"));
|
|
1734
|
+
if (m.modelType === "whisper") {
|
|
1735
|
+
// Transcription-only server: no chat model, weights paged in on demand.
|
|
1736
|
+
const sNative0 = step("native runtime");
|
|
1737
|
+
await ensureNative(sNative0);
|
|
1738
|
+
sNative0.done("native runtime ready");
|
|
1739
|
+
const { createTranscriptionServer } = await import("./serve/transcription-server");
|
|
1740
|
+
const { server, service } = await createTranscriptionServer({
|
|
1741
|
+
modelDir: m.path, modelId: m.repoId, port: rt.port,
|
|
1742
|
+
...(rt.serverOptions.hostname ? { hostname: rt.serverOptions.hostname } : {}),
|
|
1743
|
+
...(rt.serverOptions.unixSocket ? { unixSocket: rt.serverOptions.unixSocket } : {}),
|
|
1744
|
+
idleUnloadSec: rt.serverOptions.whisperIdleUnloadSec,
|
|
1745
|
+
resident: rt.serverOptions.whisperResident,
|
|
1746
|
+
preload: flag("preload"),
|
|
1747
|
+
});
|
|
1748
|
+
const where = rt.serverOptions.unixSocket ?? `http://${rt.serverOptions.hostname === "0.0.0.0" ? "localhost" : rt.serverOptions.hostname}:${server.port}`;
|
|
1749
|
+
box([
|
|
1750
|
+
`${style.bold(m.repoId)} ${style.dim("· transcription-only server")}`,
|
|
1751
|
+
`POST ${where}/v1/audio/transcriptions ${style.dim(`· ${service.stats.idle_unload_sec === null ? "always resident" : service.idleUnloadSec === 0 ? "released after every take" : `idle unload ${service.idleUnloadSec}s`}${service.resident ? " · loaded" : " · loads on first request"}`)}`,
|
|
1752
|
+
]);
|
|
1753
|
+
const stop = () => { service.dispose(); server.stop(true); process.exit(0); };
|
|
1754
|
+
process.once("SIGINT", stop);
|
|
1755
|
+
process.once("SIGTERM", stop);
|
|
1756
|
+
break;
|
|
1757
|
+
}
|
|
1758
|
+
const whisperQuery = opt("whisper-model");
|
|
1759
|
+
if (whisperQuery !== null) {
|
|
1760
|
+
const { m: wm } = await resolveModelAuto(whisperQuery);
|
|
1761
|
+
if (wm.modelType !== "whisper") {
|
|
1762
|
+
console.error(`--whisper-model ${whisperQuery} resolved to ${wm.repoId} (model_type ${wm.modelType}), not a Whisper checkpoint`);
|
|
1763
|
+
process.exit(1);
|
|
1764
|
+
}
|
|
1765
|
+
rt.serverOptions.whisperModelDir = wm.path;
|
|
1766
|
+
rt.serverOptions.whisperModelId = wm.repoId;
|
|
1767
|
+
}
|
|
1640
1768
|
const sFit = step("assessing fit");
|
|
1641
1769
|
const config = await loadModelConfig(m.path);
|
|
1642
1770
|
const report = fit(config, m.sizeBytes, 8192, undefined, undefined, m.expertsBytes);
|
|
@@ -1883,6 +2011,200 @@ switch (cmd) {
|
|
|
1883
2011
|
break;
|
|
1884
2012
|
}
|
|
1885
2013
|
|
|
2014
|
+
case "transcribe": {
|
|
2015
|
+
// Speech-to-text, one-shot, no server: audio file → Whisper → text.
|
|
2016
|
+
const { openWhisperModel } = await import("./model/factory");
|
|
2017
|
+
const { loadWhisperTokenizer } = await import("./audio/whisper-tokenizer");
|
|
2018
|
+
const { WhisperTranscriber } = await import("./audio/whisper-transcribe");
|
|
2019
|
+
const { formatTranscription, TRANSCRIPTION_FORMATS } = await import("./audio/whisper-format");
|
|
2020
|
+
const { TranscriptionService } = await import("./serve/transcription-service");
|
|
2021
|
+
const { formatTimestamp } = await import("./audio/whisper-format");
|
|
2022
|
+
|
|
2023
|
+
const audioPath = positional(0);
|
|
2024
|
+
if (!audioPath) {
|
|
2025
|
+
console.error("usage: mlx-bun transcribe <audio-file> [query] [--language en] [--format text|json|verbose_json|srt|vtt]");
|
|
2026
|
+
process.exit(1);
|
|
2027
|
+
}
|
|
2028
|
+
const format = (opt("format") ?? "text") as import("./audio/whisper-format").TranscriptionFormat;
|
|
2029
|
+
if (!TRANSCRIPTION_FORMATS.includes(format)) {
|
|
2030
|
+
console.error(`--format must be one of ${TRANSCRIPTION_FORMATS.join(", ")}`);
|
|
2031
|
+
process.exit(1);
|
|
2032
|
+
}
|
|
2033
|
+
const bytes = new Uint8Array(await Bun.file(audioPath).arrayBuffer());
|
|
2034
|
+
const samples = await TranscriptionService.decodeAudio(bytes);
|
|
2035
|
+
const durationSeconds = samples.length / 16_000;
|
|
2036
|
+
|
|
2037
|
+
const modelQuery = opt("model") ?? positional(1) ?? opt("query");
|
|
2038
|
+
let modelDir: string;
|
|
2039
|
+
if (modelQuery && existsSync(join(modelQuery, "config.json"))) modelDir = modelQuery;
|
|
2040
|
+
else if (modelQuery) modelDir = (await resolveModelAuto(modelQuery)).m.path;
|
|
2041
|
+
else {
|
|
2042
|
+
const reg = new Registry();
|
|
2043
|
+
if (reg.list().length === 0) await reg.scan();
|
|
2044
|
+
const w = reg.list().find((r) => r.modelType === "whisper");
|
|
2045
|
+
if (!w) {
|
|
2046
|
+
console.error("no Whisper model downloaded — try: mlx-bun get mlx-community/whisper-large-v3-turbo");
|
|
2047
|
+
process.exit(1);
|
|
2048
|
+
}
|
|
2049
|
+
modelDir = w.path;
|
|
2050
|
+
}
|
|
2051
|
+
let vadGate: { segments: { start: number; end: number }[] } | null = null;
|
|
2052
|
+
if (flag("vad")) {
|
|
2053
|
+
const { SileroVad } = await import("./audio/silero-vad");
|
|
2054
|
+
const tv = performance.now();
|
|
2055
|
+
const vad = SileroVad.load(opt("vad-model"));
|
|
2056
|
+
const { segments } = vad.detect(samples, { threshold: Number(opt("vad-threshold") ?? 0.5) });
|
|
2057
|
+
vadGate = { segments };
|
|
2058
|
+
if (flag("verbose")) console.error(`vad: ${segments.length} speech segment(s) in ${(performance.now() - tv).toFixed(0)} ms`);
|
|
2059
|
+
if (segments.length === 0) { process.stdout.write(format === "text" ? "\n" : JSON.stringify({ text: "", vad: { speech: false, segments: [] } }) + "\n"); break; }
|
|
2060
|
+
}
|
|
2061
|
+
const { model } = await openWhisperModel(modelDir);
|
|
2062
|
+
const tok = await loadWhisperTokenizer(modelDir, model.dims.nVocab);
|
|
2063
|
+
const transcriber = new WhisperTranscriber(model, tok);
|
|
2064
|
+
void vadGate;
|
|
2065
|
+
const langOpt = opt("language");
|
|
2066
|
+
const task = opt("task") === "translate" ? "translate" as const : "transcribe" as const;
|
|
2067
|
+
const beam = opt("beam-size");
|
|
2068
|
+
const temperature = opt("temperature");
|
|
2069
|
+
const verbose = flag("verbose");
|
|
2070
|
+
const t0 = performance.now();
|
|
2071
|
+
const result = await transcriber.transcribe(samples, {
|
|
2072
|
+
language: !langOpt || langOpt === "auto" ? null : langOpt,
|
|
2073
|
+
task,
|
|
2074
|
+
beamSize: beam ? Number(beam) : null,
|
|
2075
|
+
...(temperature ? { temperature: Number(temperature) } : flag("no-fallback") ? { temperature: 0 } : {}),
|
|
2076
|
+
initialPrompt: opt("prompt"),
|
|
2077
|
+
withoutTimestamps: flag("no-timestamps"),
|
|
2078
|
+
conditionOnPreviousText: !flag("no-condition"),
|
|
2079
|
+
fast: !flag("faithful"),
|
|
2080
|
+
wordTimestamps: flag("word-timestamps"),
|
|
2081
|
+
audioCtx: opt("audio-ctx") ? Number(opt("audio-ctx")) : null,
|
|
2082
|
+
onSegment: verbose
|
|
2083
|
+
? (s) => console.error(`[${formatTimestamp(s.start, ".")} --> ${formatTimestamp(s.end, ".")}] ${s.text.trim()}`)
|
|
2084
|
+
: undefined,
|
|
2085
|
+
});
|
|
2086
|
+
const elapsed = (performance.now() - t0) / 1000;
|
|
2087
|
+
if (verbose)
|
|
2088
|
+
console.error(`${durationSeconds.toFixed(2)} s audio in ${elapsed.toFixed(3)} s (${(durationSeconds / elapsed).toFixed(1)}× realtime), language ${result.language}`);
|
|
2089
|
+
process.stdout.write(formatTranscription(result, format, durationSeconds, task));
|
|
2090
|
+
transcriber.dispose();
|
|
2091
|
+
model.dispose();
|
|
2092
|
+
break;
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
case "dictate": {
|
|
2096
|
+
const { startMicCapture } = await import("./audio/mic-capture");
|
|
2097
|
+
const { TranscriptionService, defaultWhisperRecord } = await import("./serve/transcription-service");
|
|
2098
|
+
const { style } = await import("./tui");
|
|
2099
|
+
const serverUrl = opt("server");
|
|
2100
|
+
const hotkeyFlag = argv.includes("--hotkey");
|
|
2101
|
+
const hotkey = hotkeyFlag ? Number(opt("hotkey") ?? 61) || 61 : null;
|
|
2102
|
+
const language = opt("language") ?? "en";
|
|
2103
|
+
const beam = opt("beam-size");
|
|
2104
|
+
const vocabulary = opt("vocabulary")?.split(",").map((s) => s.trim()).filter(Boolean);
|
|
2105
|
+
const params = {
|
|
2106
|
+
language: language === "auto" ? null : language,
|
|
2107
|
+
beamSize: beam ? Number(beam) : null,
|
|
2108
|
+
temperature: 0,
|
|
2109
|
+
prompt: opt("prompt"),
|
|
2110
|
+
vocabulary,
|
|
2111
|
+
vad: flag("no-vad") ? null : { threshold: 0.5, minSpeechMs: 120 },
|
|
2112
|
+
};
|
|
2113
|
+
const typeDelay = Number(opt("type-delay") ?? (hotkey ? 0 : 1));
|
|
2114
|
+
|
|
2115
|
+
// backend: in-process service or a running server's sessions
|
|
2116
|
+
let service: InstanceType<typeof TranscriptionService> | null = null;
|
|
2117
|
+
if (!serverUrl) {
|
|
2118
|
+
const q = opt("model") ?? positional(0) ?? opt("query");
|
|
2119
|
+
let modelDir: string; let modelId: string;
|
|
2120
|
+
if (q && existsSync(join(q, "config.json"))) { modelDir = q; modelId = q; }
|
|
2121
|
+
else if (q) { const { m } = await resolveModelAuto(q); modelDir = m.path; modelId = m.repoId; }
|
|
2122
|
+
else {
|
|
2123
|
+
const rec = await defaultWhisperRecord();
|
|
2124
|
+
if (!rec) { console.error("no Whisper model downloaded — try: mlx-bun get mlx-community/whisper-large-v3-turbo"); process.exit(1); }
|
|
2125
|
+
modelDir = rec.path; modelId = rec.repoId;
|
|
2126
|
+
}
|
|
2127
|
+
const idle = opt("idle-unload");
|
|
2128
|
+
service = new TranscriptionService({ modelDir, modelId, idleUnloadSec: idle !== null ? Number(idle) : 30, resident: flag("resident"), log: () => {} });
|
|
2129
|
+
process.stderr.write(style.dim("loading whisper… "));
|
|
2130
|
+
await service.ensureLoaded();
|
|
2131
|
+
service.vad();
|
|
2132
|
+
process.stderr.write(style.dim("ready\n"));
|
|
2133
|
+
}
|
|
2134
|
+
const base = serverUrl?.replace(/\/$/, "");
|
|
2135
|
+
interface Take { feed(pcm: Float32Array): Promise<void>; finish(): Promise<{ text: string; ms: number }>; }
|
|
2136
|
+
const startTake = async (): Promise<Take> => {
|
|
2137
|
+
if (service) {
|
|
2138
|
+
const session = await service.createSession(params);
|
|
2139
|
+
return {
|
|
2140
|
+
feed: async (pcm) => { await session.append(pcm); },
|
|
2141
|
+
finish: async () => { const t0 = performance.now(); const o = await session.finish(); return { text: o.result.text.trim(), ms: performance.now() - t0 }; },
|
|
2142
|
+
};
|
|
2143
|
+
}
|
|
2144
|
+
const r = await fetch(`${base}/v1/audio/sessions`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ language: params.language, beam_size: params.beamSize, temperature: 0, prompt: params.prompt, vocabulary, vad: !!params.vad, vad_min_speech_ms: 120 }) });
|
|
2145
|
+
if (!r.ok) throw new Error(`session create failed: ${r.status} ${await r.text()}`);
|
|
2146
|
+
const { id } = await r.json() as { id: string };
|
|
2147
|
+
return {
|
|
2148
|
+
feed: async (pcm) => { await fetch(`${base}/v1/audio/sessions/${id}/audio`, { method: "POST", headers: { "content-type": "audio/pcm;rate=16000" }, body: new Uint8Array(pcm.buffer.slice(pcm.byteOffset, pcm.byteOffset + pcm.byteLength) as ArrayBuffer) }); },
|
|
2149
|
+
finish: async () => { const t0 = performance.now(); const f = await fetch(`${base}/v1/audio/sessions/${id}/finish`, { method: "POST" }); const b = await f.json() as { text: string }; return { text: (b.text ?? "").trim(), ms: performance.now() - t0 }; },
|
|
2150
|
+
};
|
|
2151
|
+
};
|
|
2152
|
+
|
|
2153
|
+
const deliver = async (text: string) => {
|
|
2154
|
+
if (!text) { console.log(style.dim("(no speech)")); return; }
|
|
2155
|
+
console.log(text);
|
|
2156
|
+
if (flag("copy")) { const p = Bun.spawn(["pbcopy"], { stdin: "pipe" }); p.stdin.write(text); p.stdin.end(); await p.exited; }
|
|
2157
|
+
if (flag("type")) {
|
|
2158
|
+
if (typeDelay > 0) await Bun.sleep(typeDelay * 1000);
|
|
2159
|
+
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
2160
|
+
const r = Bun.spawnSync(["osascript", "-e", `tell application "System Events" to keystroke "${escaped}"`], { stderr: "pipe" });
|
|
2161
|
+
if (r.exitCode !== 0) console.error(style.dim(`typing failed (grant Accessibility to your terminal): ${r.stderr.toString().trim()}`));
|
|
2162
|
+
}
|
|
2163
|
+
};
|
|
2164
|
+
|
|
2165
|
+
const mic = await startMicCapture({ hotkey });
|
|
2166
|
+
let take: Take | null = null;
|
|
2167
|
+
let pending: Float32Array[] = []; let pendingLen = 0;
|
|
2168
|
+
let feeding: Promise<void> = Promise.resolve();
|
|
2169
|
+
const flush = () => {
|
|
2170
|
+
if (!take || pendingLen === 0) return;
|
|
2171
|
+
const buf = new Float32Array(pendingLen); let o = 0; for (const p of pending) { buf.set(p, o); o += p.length; }
|
|
2172
|
+
pending = []; pendingLen = 0;
|
|
2173
|
+
const t = take;
|
|
2174
|
+
feeding = feeding.then(() => t.feed(buf)).catch((e) => console.error(style.dim(`feed failed: ${(e as Error).message}`)));
|
|
2175
|
+
};
|
|
2176
|
+
const begin = async () => { if (take) return; take = await startTake(); pending = []; pendingLen = 0; process.stderr.write(style.bold("● recording") + style.dim(hotkey ? " (release to stop)\n" : " (Enter to stop)\n")); };
|
|
2177
|
+
const end = async () => {
|
|
2178
|
+
if (!take) return;
|
|
2179
|
+
flush();
|
|
2180
|
+
await feeding;
|
|
2181
|
+
const t = take; take = null;
|
|
2182
|
+
process.stderr.write(style.dim("transcribing… "));
|
|
2183
|
+
try { const { text, ms } = await t.finish(); process.stderr.write(style.dim(`${ms.toFixed(0)} ms\n`)); await deliver(text); }
|
|
2184
|
+
catch (e) { console.error(`transcription failed: ${(e as Error).message}`); }
|
|
2185
|
+
};
|
|
2186
|
+
if (!hotkey) {
|
|
2187
|
+
console.error(style.dim("Enter: start/stop a take · q: quit"));
|
|
2188
|
+
(async () => {
|
|
2189
|
+
for await (const line of console) {
|
|
2190
|
+
if (line.trim() === "q") { mic.stop(); process.exit(0); }
|
|
2191
|
+
if (take) await end(); else await begin();
|
|
2192
|
+
}
|
|
2193
|
+
})();
|
|
2194
|
+
}
|
|
2195
|
+
process.once("SIGINT", () => { mic.stop(); process.exit(0); });
|
|
2196
|
+
for await (const ev of mic.events) {
|
|
2197
|
+
if (ev.kind === "ready") { if (hotkey) console.error(style.dim(`hold key ${hotkey} to talk · Ctrl-C quits · mic ${ev.info}`)); }
|
|
2198
|
+
else if (ev.kind === "error") { console.error(ev.message); mic.stop(); process.exit(1); }
|
|
2199
|
+
else if (ev.kind === "hotkey") { if (ev.down) await begin(); else await end(); }
|
|
2200
|
+
else if (ev.kind === "pcm" && take) {
|
|
2201
|
+
pending.push(ev.samples); pendingLen += ev.samples.length;
|
|
2202
|
+
if (pendingLen >= 16_000 / 4) flush(); // every 250 ms of audio
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
break;
|
|
2206
|
+
}
|
|
2207
|
+
|
|
1886
2208
|
case "bench":
|
|
1887
2209
|
case "benchmark": {
|
|
1888
2210
|
const { banner, step, box, style } = await import("./tui");
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Request facts only. Native resources remain with the preparation owner. */
|
|
2
2
|
export interface ExecutionRequirements {
|
|
3
3
|
readonly hasVision: boolean;
|
|
4
|
+
readonly hasPreparedPrefixIdentity?: boolean;
|
|
4
5
|
readonly hasAdapters: boolean;
|
|
5
6
|
readonly hasRepetitionPenalty: boolean;
|
|
6
7
|
readonly userSeed: boolean;
|
|
@@ -18,6 +19,9 @@ export interface ExecutionCapabilities {
|
|
|
18
19
|
readonly quantizedBatch: boolean;
|
|
19
20
|
readonly grammarBatch: boolean;
|
|
20
21
|
readonly adapterBatch?: boolean;
|
|
22
|
+
/** Prepared media can enter the ordinary shared execution group. */
|
|
23
|
+
readonly mediaBatch?: boolean;
|
|
24
|
+
readonly mediaPrefixCache?: boolean;
|
|
21
25
|
readonly pagedBatch?: boolean;
|
|
22
26
|
readonly checkpoints: boolean;
|
|
23
27
|
/** Bound ordinary driver can restore/capture this request configuration. */
|
|
@@ -31,6 +35,12 @@ export interface ExecutionCapabilities {
|
|
|
31
35
|
readonly sharedSpeculativeAdapters?: boolean;
|
|
32
36
|
/** Methods supplied by the model's shared execution binding. */
|
|
33
37
|
readonly groupedMethods?: readonly string[];
|
|
38
|
+
/** A shared method accepts request-owned grammar continuation proposals. */
|
|
39
|
+
readonly sharedGrammarProposals?: boolean;
|
|
40
|
+
/** A grouped method can consume this request's strict known continuations. */
|
|
41
|
+
readonly sharedFill?: boolean;
|
|
42
|
+
/** A mounted provider can consume verified external continuations. */
|
|
43
|
+
readonly sharedSpeculativeEcho?: boolean;
|
|
34
44
|
/** A graph-owned compiled step exists; cache geometry can still decline it. */
|
|
35
45
|
readonly compiledDecode?: boolean;
|
|
36
46
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Exact reusable work, independent of token-prefix matching. The same cache
|
|
2
|
+
* owner chooses residency and persistence for objects and conversation state. */
|
|
3
|
+
export interface ObjectCache<Value> {
|
|
4
|
+
/** Borrow immutable data through an owned lease. A miss permits recompute. */
|
|
5
|
+
take(key: string): Promise<{ value: Value; dispose(): void } | null>;
|
|
6
|
+
/** Successful return transfers ownership, including immediate eviction. */
|
|
7
|
+
put(key: string, value: Value): void;
|
|
8
|
+
}
|
|
@@ -13,7 +13,7 @@ export function piModelDefinition(options: PiModelOptions = {}) {
|
|
|
13
13
|
name: options.name ?? "mlx-bun (local)",
|
|
14
14
|
api: "openai-completions" as const,
|
|
15
15
|
reasoning: options.reasoning ?? false,
|
|
16
|
-
compat: { supportsDeveloperRole: false,
|
|
16
|
+
compat: { supportsDeveloperRole: false, sendSessionAffinityHeaders: true,
|
|
17
17
|
...(options.reasoning ? { thinkingFormat: "qwen-chat-template" as const } : {}) },
|
|
18
18
|
input: (options.vision ? ["text", "image"] : ["text"]) as ("text" | "image")[],
|
|
19
19
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
package/src/contracts/pi.ts
CHANGED
|
@@ -160,7 +160,9 @@ export type ServerMessage =
|
|
|
160
160
|
// provider declaration — clients use it instead of probing for a 400.
|
|
161
161
|
// `thinking`: whether the model has a switchable reasoning channel (drives
|
|
162
162
|
// the UI's thinking on/off toggle; false hides it).
|
|
163
|
-
|
|
163
|
+
// transcription: a Whisper checkpoint is configured or resolvable for
|
|
164
|
+
// /v1/audio/sessions — drives the composer's hold-to-talk mic button.
|
|
165
|
+
| { type: "ready"; model: string; vision: boolean; audio: boolean; thinking: boolean; genDefaults: ReadyGenDefaults; transcription?: boolean }
|
|
164
166
|
| { type: "turn_start" }
|
|
165
167
|
| { type: "text_delta"; delta: string }
|
|
166
168
|
| { type: "thinking_delta"; delta: string }
|
|
@@ -13,11 +13,21 @@ export interface PrefixCacheHit<State, Attachment = never> {
|
|
|
13
13
|
* State is opaque here; its backend/method defines layout and alignment.
|
|
14
14
|
* Implementations own prefix selection, retention and storage tiers. */
|
|
15
15
|
export interface PrefixCache<State, Attachment = never> {
|
|
16
|
-
/**
|
|
16
|
+
/** Give optional retained state back to the allocator at an execution
|
|
17
|
+
* boundary. This never rejects or changes the active request. */
|
|
18
|
+
reclaim?(): void;
|
|
19
|
+
/** Drop session affinity without deleting reusable checkpoints. */
|
|
20
|
+
closeSession?(sessionId: string): void;
|
|
21
|
+
/** Prepare a reusable prefix before execution. The returned release ends
|
|
22
|
+
* request interest; cache residency and IO remain implementation-owned. */
|
|
23
|
+
prefetch?(prompt: number[], ns?: string, sessionId?: string): Promise<() => void>;
|
|
24
|
+
/** Return owned state for a usable session continuation, otherwise the
|
|
25
|
+
* longest usable prefix, or null on a miss. Session identity is an affinity
|
|
26
|
+
* hint and never changes the content or numerical namespace.
|
|
17
27
|
* Namespace distinguishes incompatible artifacts, methods and adapters. */
|
|
18
|
-
take(prompt: number[], ns?: string): PrefixCacheHit<State, Attachment> | null;
|
|
28
|
+
take(prompt: number[], ns?: string, sessionId?: string): PrefixCacheHit<State, Attachment> | null;
|
|
19
29
|
/** Transfer state and its optional backing release to storage. If this
|
|
20
30
|
* throws, ownership stays with the caller; successful return transfers
|
|
21
31
|
* ownership even when storage immediately spills or disposes the entry. */
|
|
22
|
-
put(tokens: number[], caches: State, ns?: string, retain?: () => void, attachments?: Attachment): void;
|
|
32
|
+
put(tokens: number[], caches: State, ns?: string, retain?: () => void, attachments?: Attachment, sessionId?: string): void;
|
|
23
33
|
}
|
|
@@ -9,6 +9,10 @@ export interface ExecutionContext {
|
|
|
9
9
|
/** A backend execution group exposes bounded work units and readiness. Queue
|
|
10
10
|
* payloads, tensors, cache merging and sampling remain inside its adapter. */
|
|
11
11
|
export interface ExecutionGroup {
|
|
12
|
+
/** Native preparation may borrow the execution lease between work units.
|
|
13
|
+
* Payloads and task failures belong to the backend's queue. */
|
|
14
|
+
readonly pendingTasks?: number;
|
|
15
|
+
advanceTask?(): Promise<void>;
|
|
12
16
|
readonly active: number;
|
|
13
17
|
readonly queued: number;
|
|
14
18
|
readonly preparing: boolean;
|
|
@@ -30,6 +34,11 @@ export interface ExecutionGroup {
|
|
|
30
34
|
admitNext(): boolean;
|
|
31
35
|
/** Read-only budget check used to preserve admission before decode. */
|
|
32
36
|
canBurst(): boolean;
|
|
37
|
+
/** Method-owned token demand for a compatible mixed iteration. Absent when
|
|
38
|
+
* the backend has no packed implementation for the current work. */
|
|
39
|
+
readonly mixedPreparation?: { readonly runningTokens: number; readonly minimumPreparationTokens: number };
|
|
40
|
+
readonly maxIterationTokens?: number;
|
|
41
|
+
advanceMixed?(tokenBudget: number): Promise<void>;
|
|
33
42
|
advancePreparation(): Promise<void>;
|
|
34
43
|
advance(): Promise<void>;
|
|
35
44
|
failActive(error: unknown): void;
|
|
@@ -8,7 +8,7 @@ export function resolveExecution(
|
|
|
8
8
|
const reasons: string[] = [];
|
|
9
9
|
const sharedRequestExclusions = [
|
|
10
10
|
[!capabilities.continuous, "continuous-unavailable"],
|
|
11
|
-
[request.hasVision, "media-requires-serial"],
|
|
11
|
+
[request.hasVision && !capabilities.mediaBatch, "media-requires-serial"],
|
|
12
12
|
[request.hasAdapters && !capabilities.adapterBatch, "adapters-require-serial"],
|
|
13
13
|
[request.kvQuant && !capabilities.quantizedBatch, "kv-scheme-requires-serial"],
|
|
14
14
|
[request.turboQuant && !capabilities.turboQuantBatch, "turbo-kv-requires-serial"],
|
|
@@ -18,10 +18,16 @@ export function resolveExecution(
|
|
|
18
18
|
const sharedAdapterMethod = capabilities.sharedSpeculativeAdapters === true &&
|
|
19
19
|
!sharedRequestExclusions.some(([excluded]) => excluded) &&
|
|
20
20
|
capabilities.groupedMethods?.includes("speculative") === true;
|
|
21
|
-
const
|
|
21
|
+
const grammarProposals = features.grammarJump === true && request.hasGrammar && !request.hasDraft && !request.hasVision &&
|
|
22
|
+
(!request.wantsLogprobs || capabilities.speculativeLogprobs === true) && !features.pagedKv && capabilities.method === "autoregressive" &&
|
|
23
|
+
capabilities.sharedGrammarProposals === true && capabilities.groupedMethods?.includes("speculative") === true &&
|
|
24
|
+
(!request.hasAdapters || sharedAdapterMethod) && (!request.kvQuant || capabilities.speculativeKvQuant === true) &&
|
|
25
|
+
(!request.turboQuant || capabilities.speculativeTurboQuant === true) &&
|
|
26
|
+
!sharedRequestExclusions.some(([excluded]) => excluded);
|
|
27
|
+
const speculative = grammarProposals || (capabilities.method === "autoregressive" && request.hasDraft &&
|
|
22
28
|
!request.hasVision && (!request.hasAdapters || sharedAdapterMethod) && (!request.wantsLogprobs || capabilities.speculativeLogprobs === true) &&
|
|
23
29
|
(!request.kvQuant || capabilities.speculativeKvQuant === true) &&
|
|
24
|
-
(!request.turboQuant || capabilities.speculativeTurboQuant === true) && !features.pagedKv;
|
|
30
|
+
(!request.turboQuant || capabilities.speculativeTurboQuant === true) && !features.pagedKv);
|
|
25
31
|
const method = speculative ? "speculative" : capabilities.method;
|
|
26
32
|
const continuousExclusions = [sharedRequestExclusions[0],
|
|
27
33
|
[!(capabilities.groupedMethods ?? ["autoregressive"]).includes(method), "method-requires-serial"],
|
|
@@ -32,19 +38,24 @@ export function resolveExecution(
|
|
|
32
38
|
if (request.hasDraft && !speculative) reasons.push("draft-incompatible-with-request");
|
|
33
39
|
const pagedKv = features.pagedKv && !request.hasVision && !request.hasAdapters;
|
|
34
40
|
if (features.pagedKv && !pagedKv) reasons.push("paged-kv-bypassed-for-media-or-adapters");
|
|
35
|
-
const promptCache = method !== "speculative" && !request.hasVision
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
41
|
+
const promptCache = method !== "speculative" && (!request.hasVision ||
|
|
42
|
+
(mechanism === "continuous" && request.hasPreparedPrefixIdentity === true && capabilities.mediaPrefixCache === true));
|
|
43
|
+
const sharedFill = mechanism === "continuous" && capabilities.sharedFill === true && !pagedKv;
|
|
44
|
+
const speculativeEcho = method === "speculative" && mechanism === "continuous" &&
|
|
45
|
+
capabilities.sharedSpeculativeEcho === true;
|
|
46
|
+
const fill = features.fill && !request.hasVision && !request.hasGrammar && !request.wantsLogprobs &&
|
|
47
|
+
(speculativeEcho || (method === "autoregressive" && (mechanism === "serial" || sharedFill) &&
|
|
48
|
+
!request.hasDraft && (!request.userSeed || sharedFill)));
|
|
49
|
+
// Cache-format eligibility belongs to the method's append binding.
|
|
39
50
|
if (features.fill && !fill) reasons.push("fill-incompatible-with-request");
|
|
40
|
-
const checkpoint = capabilities.checkpoints && method === "autoregressive" &&
|
|
41
|
-
(mechanism === "serial" || capabilities.sharedCheckpoints === true) && promptCache && !request.hasGrammar &&
|
|
51
|
+
const checkpoint = capabilities.checkpoints && method === "autoregressive" && !request.hasVision &&
|
|
52
|
+
(mechanism === "serial" || capabilities.sharedCheckpoints === true) && promptCache && !pagedKv && !request.hasGrammar &&
|
|
42
53
|
!features.fill && !request.wantsLogprobs;
|
|
43
54
|
const compiledDecode = features.compiledDecode === true && capabilities.compiledDecode === true &&
|
|
44
|
-
method === "autoregressive" && !request.hasAdapters && !pagedKv;
|
|
55
|
+
method === "autoregressive" && !request.hasAdapters && !pagedKv && !(sharedFill && fill);
|
|
45
56
|
if (features.compiledDecode && !compiledDecode) reasons.push("compiled-decode-unavailable-for-request");
|
|
46
|
-
const grammarJump = features.grammarJump === true && request.hasGrammar &&
|
|
47
|
-
method === "autoregressive" && mechanism === "serial" && !request.wantsLogprobs;
|
|
57
|
+
const grammarJump = grammarProposals || (features.grammarJump === true && request.hasGrammar &&
|
|
58
|
+
method === "autoregressive" && mechanism === "serial" && !request.wantsLogprobs);
|
|
48
59
|
if (features.grammarJump && request.hasGrammar && !grammarJump)
|
|
49
60
|
reasons.push("grammar-jump-incompatible-with-request");
|
|
50
61
|
return Object.freeze({ method, mechanism, pagedKv, promptCache, checkpoint, fill,
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
interface Task {
|
|
2
|
+
run(): Promise<void>;
|
|
3
|
+
reject(error: unknown): void;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** Work whose owner requires an execution boundary, without owning a decode
|
|
7
|
+
* row. The driver supplies the lease; this queue owns waiting and cancellation.
|
|
8
|
+
* Once started, work settles before its caller can release native resources. */
|
|
9
|
+
export class ExecutionTasks {
|
|
10
|
+
#pending: Task[] = [];
|
|
11
|
+
get pending(): number { return this.#pending.length; }
|
|
12
|
+
|
|
13
|
+
enqueue<T>(work: () => Promise<T>, cancellation?: Cancellation): Promise<T> {
|
|
14
|
+
if (cancellation?.reason !== undefined) return Promise.reject(new GenerationCancelled(cancellation.reason));
|
|
15
|
+
return new Promise<T>((resolve, reject) => {
|
|
16
|
+
let unsubscribe = () => {};
|
|
17
|
+
const cleanup = () => unsubscribe();
|
|
18
|
+
const task: Task = {
|
|
19
|
+
async run() {
|
|
20
|
+
cleanup();
|
|
21
|
+
try { resolve(await work()); } catch (error) { reject(error); }
|
|
22
|
+
},
|
|
23
|
+
reject(error) { cleanup(); reject(error); },
|
|
24
|
+
};
|
|
25
|
+
this.#pending.push(task);
|
|
26
|
+
unsubscribe = cancellation?.subscribe(reason => {
|
|
27
|
+
const index = this.#pending.indexOf(task);
|
|
28
|
+
if (index < 0) return;
|
|
29
|
+
this.#pending.splice(index, 1);
|
|
30
|
+
task.reject(new GenerationCancelled(reason));
|
|
31
|
+
}) ?? (() => {});
|
|
32
|
+
if (!this.#pending.includes(task)) unsubscribe();
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async advance(): Promise<void> { await this.#pending.shift()?.run(); }
|
|
37
|
+
|
|
38
|
+
rejectAll(error: unknown): void {
|
|
39
|
+
const tasks = this.#pending; this.#pending = [];
|
|
40
|
+
for (const task of tasks) task.reject(error);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
import type { Cancellation } from "../contracts/generation";
|
|
44
|
+
import { GenerationCancelled } from "./cancellation";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { PrefixCache } from "../contracts/prefix-cache";
|
|
2
|
+
|
|
3
|
+
/** Compose numerical identity with a cache without exposing its storage tiers. */
|
|
4
|
+
export function namespacedCache<State, Attachment>(cache: PrefixCache<State, Attachment>,
|
|
5
|
+
namespace: (base: string) => string): PrefixCache<State, Attachment> {
|
|
6
|
+
return {
|
|
7
|
+
closeSession: id => cache.closeSession?.(id),
|
|
8
|
+
reclaim: () => cache.reclaim?.(),
|
|
9
|
+
...(cache.prefetch ? { prefetch: (tokens: number[], ns = "", sessionId?: string) => cache.prefetch!(tokens, namespace(ns), sessionId) } : {}),
|
|
10
|
+
take: (tokens, ns = "", sessionId) => cache.take(tokens, namespace(ns), sessionId),
|
|
11
|
+
put: (tokens, state, ns = "", retain, attachments, sessionId) => cache.put(tokens, state, namespace(ns), retain, attachments, sessionId),
|
|
12
|
+
};
|
|
13
|
+
}
|
package/src/engine/scheduler.ts
CHANGED
|
@@ -18,8 +18,8 @@ export async function driveExecutionGroup(
|
|
|
18
18
|
try {
|
|
19
19
|
while (!group.closed) {
|
|
20
20
|
group.pruneCancelled();
|
|
21
|
-
|
|
22
|
-
if (!group.active && !group.preparing && (held || !group.queued)) {
|
|
21
|
+
let held = group.admissionHeld;
|
|
22
|
+
if (!group.active && !group.preparing && (held || (!group.queued && !group.pendingTasks))) {
|
|
23
23
|
release();
|
|
24
24
|
await group.waitForWork();
|
|
25
25
|
continue;
|
|
@@ -28,6 +28,15 @@ export async function driveExecutionGroup(
|
|
|
28
28
|
if (!execution && group.acquireExecution) execution = await group.acquireExecution();
|
|
29
29
|
if (group.closed) break;
|
|
30
30
|
|
|
31
|
+
// One preparation task per iteration. An exclusive model mutation
|
|
32
|
+
// keeps its existing drain priority; preparation cannot prolong it.
|
|
33
|
+
if (!held && group.pendingTasks && group.advanceTask) {
|
|
34
|
+
await group.advanceTask();
|
|
35
|
+
if (group.closed) break;
|
|
36
|
+
group.pruneCancelled();
|
|
37
|
+
held = group.admissionHeld;
|
|
38
|
+
}
|
|
39
|
+
|
|
31
40
|
if (!group.preparing && !held && group.queued && group.active < group.maxActive)
|
|
32
41
|
group.admitNext();
|
|
33
42
|
while (group.preparing && group.canPrepareMore && !held && group.queued &&
|
|
@@ -35,6 +44,17 @@ export async function driveExecutionGroup(
|
|
|
35
44
|
(group.preparingTokens ?? 0) + (group.nextPreparationTokens ?? 0) <= (group.maxPreparationTokens ?? Infinity)) {
|
|
36
45
|
if (!group.admitNext()) break;
|
|
37
46
|
}
|
|
47
|
+
const mixed = group.preparing && group.active ? group.mixedPreparation : undefined;
|
|
48
|
+
if (mixed && group.advanceMixed) {
|
|
49
|
+
// Reserve the method's running-token demand first; prompt work fills
|
|
50
|
+
// the rest. A budget smaller than one token per row still progresses.
|
|
51
|
+
const budget = Math.max(group.maxIterationTokens ?? Infinity,
|
|
52
|
+
mixed.runningTokens + mixed.minimumPreparationTokens);
|
|
53
|
+
await group.advanceMixed(budget);
|
|
54
|
+
lastYield = clock.now();
|
|
55
|
+
await clock.yield();
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
38
58
|
if (group.preparing) {
|
|
39
59
|
const activeBefore = group.active;
|
|
40
60
|
await group.advancePreparation();
|