mlx-bun 0.0.10 → 0.0.12
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 +107 -32
- package/THIRD_PARTY_LICENSES.md +10 -0
- package/docs/archive/mlx-bun-lab-report.html +275 -0
- package/docs/archive/wwdc-mlx-bun.html +848 -0
- package/docs/design/audio-input-plan.md +356 -0
- package/docs/design/batching-perf-path.md +58 -35
- package/docs/design/batching-v2-plan.md +23 -9
- package/docs/design/bucketing-stage.md +2 -1
- package/docs/design/colibri-glm52-port.md +1479 -0
- package/docs/design/compat-cli-surface-design.md +4 -3
- package/docs/design/decode-speed-program.md +172 -0
- package/docs/design/docs-reorg-plan.md +1 -1
- package/docs/design/dspark-serving-program.md +371 -0
- package/docs/design/dspark-speculative-decoding.md +167 -5
- package/docs/design/faithful-l1-consolidation.md +310 -0
- package/docs/design/generic-model-support.md +8 -5
- package/docs/design/grammar-spec-batching-integration.md +301 -0
- package/docs/design/minicpm5-decode-megakernel.md +16 -11
- package/docs/design/mlx-lm-tool-parity-plan.md +11 -1
- package/docs/design/omlx-adoption-map.md +14 -6
- package/docs/design/orpo-training.md +34 -30
- package/docs/design/paged-kv-cache.md +214 -0
- package/docs/design/parallel-slots.md +82 -49
- package/docs/design/parity-tier-dag.md +43 -33
- package/docs/design/pre-colibri-stabilization.md +329 -0
- package/docs/design/repo-cleanup-plan.md +189 -0
- package/docs/design/runtime-isolation.md +19 -3
- package/docs/design/segmented-backward-training.md +3 -2
- package/docs/design/ssd-kv-cold-tier.md +130 -0
- package/docs/design/structured-output.md +484 -0
- package/docs/design/the-dreaming-master-plan.md +2 -3
- package/docs/design/turboquant-kv.md +235 -0
- package/docs/design/unified-engine-frontier-plan.md +774 -0
- package/docs/design/web-chat-beat-matrix.md +315 -0
- package/docs/design/web-chat-redesign.md +1112 -0
- package/docs/investigations/colibri-oracle-pin.md +1284 -0
- package/docs/investigations/dspark-handoff.md +182 -16
- package/docs/investigations/indexshare-performance-spike.md +357 -0
- package/docs/investigations/lab-build-journal.md +2 -2
- package/docs/investigations/openwiki-evaluation.md +249 -0
- package/docs/planning/IDEAS.md +14 -0
- package/docs/planning/PRODUCT_ROADMAP.md +2 -3
- package/docs/planning/chat-ui-vision-memory-first.md +199 -0
- package/docs/planning/chat-ui-vision.md +50 -0
- package/docs/planning/memory-docs-and-dag-plan.md +15 -6
- package/docs/planning/release-notes-v0.0.11.md +239 -0
- package/docs/planning/release-notes-v0.0.12.md +80 -0
- package/docs/reference/cli.md +63 -18
- package/docs/reference/distribution.md +37 -6
- package/docs/reference/embedding.md +40 -16
- package/docs/reference/features-matrix.md +99 -0
- package/docs/reference/library-api.md +69 -20
- package/docs/reference/memory.md +26 -6
- package/docs/reference/models.md +60 -3
- package/docs/reference/orpo-quickstart.md +3 -4
- package/docs/reference/server-api.md +717 -24
- package/docs/reference/server-config.md +265 -129
- package/docs/reference/training.md +24 -16
- package/package.json +5 -3
- package/src/anthropic.ts +67 -44
- package/src/audio/conformer.ts +647 -0
- package/src/audio/decode.ts +133 -0
- package/src/audio/features.ts +202 -0
- package/src/audio/transcode.ts +67 -0
- package/src/chat-template.ts +155 -10
- package/src/cli.ts +483 -113
- package/src/config.ts +116 -24
- package/src/diffusion/diffusion-generate.ts +14 -8
- package/src/download.ts +143 -12
- package/src/eval/README.md +7 -6
- package/src/eval/ifeval.ts +63 -120
- package/src/eval/kl.ts +59 -3
- package/src/eval/quality-db.ts +2 -2
- package/src/eval/runner.ts +17 -8
- package/src/eval/tasks/ifeval.ts +167 -31
- package/src/expert-io.ts +391 -0
- package/src/expert-offload.ts +15 -2
- package/src/expert-residency.ts +1134 -0
- package/src/expert-usage.ts +415 -0
- package/src/flags.ts +13 -0
- package/src/generate.ts +438 -35
- package/src/grammar.ts +585 -0
- package/src/hf-push.ts +1 -1
- package/src/hub-rest.ts +242 -0
- package/src/index.ts +14 -2
- package/src/jobs/runner.ts +37 -18
- package/src/kv-store.ts +586 -93
- package/src/lora.ts +85 -14
- package/src/md-modules.d.ts +42 -0
- package/src/media-fetch.ts +426 -0
- package/src/memory/rest.ts +245 -0
- package/src/memory/vault.ts +74 -0
- package/src/mlx/array.ts +149 -19
- package/src/mlx/autograd.ts +53 -16
- package/src/mlx/checkpoint.ts +46 -13
- package/src/mlx/custom-vjp.ts +83 -35
- package/src/mlx/ffi.ts +27 -3
- package/src/mlx/metal-kernel.ts +7 -4
- package/src/mlx/ops.ts +153 -23
- package/src/mlx/turboquant-ops.ts +831 -0
- package/src/mlx/turboquant-tables.ts +237 -0
- package/src/model/batched-mask.ts +62 -1
- package/src/model/batched-quant.ts +206 -0
- package/src/model/batched-rotating-quant.ts +346 -0
- package/src/model/batched-rotating.ts +46 -1
- package/src/model/compiled-decode.ts +4 -15
- package/src/model/factory.ts +103 -6
- package/src/model/gemma4-base.ts +395 -39
- package/src/model/gemma4.ts +122 -33
- package/src/model/generated/gemma4-12b.ts +9 -13
- package/src/model/generated/gemma4-26b.ts +10 -5
- package/src/model/generated/gemma4-e4b.ts +14 -9
- package/src/model/glm52-atlas.ts +511 -0
- package/src/model/glm52-cache.ts +706 -0
- package/src/model/glm52-config.ts +262 -0
- package/src/model/glm52-container.ts +305 -0
- package/src/model/glm52-coupling.ts +388 -0
- package/src/model/glm52-dsa.ts +602 -0
- package/src/model/glm52-expert-layout.ts +253 -0
- package/src/model/glm52-layout.ts +152 -0
- package/src/model/glm52-memory.ts +473 -0
- package/src/model/glm52-mla.ts +786 -0
- package/src/model/glm52-moe.ts +223 -0
- package/src/model/glm52-pilot.ts +537 -0
- package/src/model/glm52-reference.ts +445 -0
- package/src/model/glm52-residency.ts +557 -0
- package/src/model/glm52-resident-weights.ts +135 -0
- package/src/model/glm52-streamed-experts.ts +453 -0
- package/src/model/glm52-streamed-metal.ts +610 -0
- package/src/model/glm52-weights.ts +503 -0
- package/src/model/glm52.ts +1153 -0
- package/src/model/minicpm5.ts +58 -102
- package/src/model/paged-kv.ts +253 -0
- package/src/model/qwen3-delta.ts +76 -15
- package/src/model/qwen3-moe.ts +404 -0
- package/src/model/qwen3.ts +9 -8
- package/src/model/qwen3_5.ts +88 -23
- package/src/model/support.ts +19 -1
- package/src/model/universal/dense.ts +40 -5
- package/src/model/universal/rope.ts +36 -21
- package/src/native/expert_io.c +523 -0
- package/src/native-pack.ts +6 -4
- package/src/pi-web.ts +1299 -82
- package/src/preflight.ts +27 -8
- package/src/prompt-cache.ts +293 -33
- package/src/quantize/quantizer.ts +16 -1
- package/src/registry.ts +57 -2
- package/src/responses.ts +62 -49
- package/src/serve/batch-scheduler.ts +1050 -78
- package/src/serve/generation-gateway.ts +252 -24
- package/src/serve/isolate.ts +417 -0
- package/src/serve/lane-registry.ts +44 -0
- package/src/serve/session-search.ts +326 -0
- package/src/server.ts +1701 -175
- package/src/spec/assistant-source.ts +150 -0
- package/src/spec/deepspec-source.ts +143 -0
- package/src/spec/dflash-source.ts +124 -0
- package/src/spec/dspark/ab-stats.ts +136 -0
- package/src/spec/dspark/calibration.ts +112 -0
- package/src/spec/dspark/deepspec-module.ts +801 -0
- package/src/spec/dspark/generate-dflash.ts +72 -20
- package/src/spec/dspark/loader.ts +23 -0
- package/src/spec/dspark/module-dflash.ts +278 -36
- package/src/spec/dspark/quantize-drafter.ts +91 -0
- package/src/spec/generate.ts +59 -25
- package/src/spec/glm52-mtp-source.ts +392 -0
- package/src/spec/ngram-source.ts +132 -0
- package/src/spec/serve-loop.ts +617 -0
- package/src/spec/source.ts +118 -0
- package/src/spec/two-model.ts +171 -0
- package/src/ssd-cache.ts +111 -35
- package/src/tokenizer.ts +288 -2
- package/src/tool-approvals.ts +131 -0
- package/src/tool-call.ts +438 -22
- package/src/train/flash-cce.ts +2 -2
- package/src/train/job.ts +0 -12
- package/src/train/lora-params.ts +17 -6
- package/src/train/trainer.ts +60 -30
- package/src/vision/prompt.ts +217 -48
- package/src/web/app.html +1197 -1791
- package/src/web/app.js +5694 -0
- package/src/web/icon.svg +13 -0
- package/src/web/manifest.webmanifest +24 -0
- package/src/web/src/adapters-panel.ts +288 -0
- package/src/web/src/api.ts +57 -0
- package/src/web/src/assistant.ts +485 -0
- package/src/web/src/chat.ts +1050 -0
- package/src/web/src/composer.ts +959 -0
- package/src/web/src/dataset.ts +132 -0
- package/src/web/src/finetune.ts +267 -0
- package/src/web/src/hub.ts +428 -0
- package/src/web/src/main.ts +98 -0
- package/src/web/src/markdown.ts +0 -0
- package/src/web/src/memory-panel.ts +572 -0
- package/src/web/src/model-picker.ts +159 -0
- package/src/web/src/palette.ts +346 -0
- package/src/web/src/protocol.ts +62 -0
- package/src/web/src/quantize.ts +167 -0
- package/src/web/src/rag.ts +274 -0
- package/src/web/src/sessions.ts +469 -0
- package/src/web/src/shell.ts +804 -0
- package/src/web/src/status.ts +300 -0
- package/src/web/src/ui-catalog.ts +92 -0
- package/src/web/sw.js +71 -0
- package/src/web/vendor/README.md +61 -0
- package/src/web/vendor/hljs-LICENSE +29 -0
- package/src/web/vendor/hljs-theme.css +59 -0
- package/src/web/vendor/hljs.js +26 -0
- package/src/web-tools.ts +54 -2
- package/src/weights.ts +82 -19
- package/src/model/fused-decode-kernel.ts +0 -312
- package/src/model/fused-geglu-kernel.ts +0 -198
- package/src/model/fused-mlp-kernel.ts +0 -169
- package/src/model/fused-swiglu-kernel.ts +0 -240
- package/src/model/steel-linear-kernel.ts +0 -96
- package/src/status-page.html +0 -497
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// WAV decode for audio input (A1 of docs/design/audio-input-plan.md).
|
|
2
|
+
//
|
|
3
|
+
// Scope: RIFF/WAVE containers — PCM 16/24/32-bit and IEEE float32, any
|
|
4
|
+
// channel count (mean mixdown), any sample rate (linear resample to 16 kHz).
|
|
5
|
+
// PCM16 scales by 1/32768 to match the oracle golden generator
|
|
6
|
+
// (scripts/gen-e4b-audio-golden.py) exactly. Non-WAV containers (mp3/m4a/
|
|
7
|
+
// flac/ogg) are transcoded to WAV by the serve layer via macOS `afconvert`
|
|
8
|
+
// before reaching this parser.
|
|
9
|
+
//
|
|
10
|
+
// Resampling note: optiq's serve frontend never exposes audio, so there is
|
|
11
|
+
// no oracle behavior for non-16 kHz input — linear interpolation is our
|
|
12
|
+
// documented choice (the parity fixtures are native 16 kHz and skip it).
|
|
13
|
+
|
|
14
|
+
export const AUDIO_SAMPLE_RATE = 16_000;
|
|
15
|
+
export const AUDIO_MS_PER_TOKEN = 40;
|
|
16
|
+
export const AUDIO_SEQ_LENGTH = 750; // 30 s cap, matches the oracle processor
|
|
17
|
+
/** Raw-speech truncation bound — the oracle feature extractor's default
|
|
18
|
+
* (audio_feature_extractor.py __call__: truncation=True,
|
|
19
|
+
* max_length=480_000 → `w[:max_length]`, i.e. 30 s at 16 kHz) applied
|
|
20
|
+
* BEFORE feature extraction. Mirrored in decodeAudio so a >30 s clip
|
|
21
|
+
* yields exactly the capped 750 soft tokens AND the matching mel frame
|
|
22
|
+
* count — the prompt builder hard-checks that the tower's output length
|
|
23
|
+
* equals the splice's soft-token count. */
|
|
24
|
+
export const AUDIO_MAX_SAMPLES = 30 * AUDIO_SAMPLE_RATE; // 480_000
|
|
25
|
+
|
|
26
|
+
export interface DecodedWav {
|
|
27
|
+
samples: Float32Array; // mono
|
|
28
|
+
sampleRate: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Number of audio soft tokens for a clip — processing_gemma4.py
|
|
32
|
+
* `_compute_audio_num_tokens`: ceil(duration_ms / 40) capped at 750.
|
|
33
|
+
* MUST be computed from decoded samples, never from container metadata. */
|
|
34
|
+
export function audioSoftTokenCount(
|
|
35
|
+
numSamples: number,
|
|
36
|
+
sampleRate: number = AUDIO_SAMPLE_RATE,
|
|
37
|
+
): number {
|
|
38
|
+
const durationMs = (numSamples / sampleRate) * 1000;
|
|
39
|
+
return Math.min(Math.ceil(durationMs / AUDIO_MS_PER_TOKEN), AUDIO_SEQ_LENGTH);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const WAVE_FORMAT_PCM = 1;
|
|
43
|
+
const WAVE_FORMAT_IEEE_FLOAT = 3;
|
|
44
|
+
const WAVE_FORMAT_EXTENSIBLE = 0xfffe;
|
|
45
|
+
|
|
46
|
+
/** Parse a RIFF/WAVE file into mono float samples at its native rate. */
|
|
47
|
+
export function decodeWav(bytes: Uint8Array): DecodedWav {
|
|
48
|
+
const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
49
|
+
const tag = (o: number) => String.fromCharCode(bytes[o]!, bytes[o + 1]!, bytes[o + 2]!, bytes[o + 3]!);
|
|
50
|
+
if (bytes.length < 44 || tag(0) !== "RIFF" || tag(8) !== "WAVE")
|
|
51
|
+
throw new Error("not a RIFF/WAVE file");
|
|
52
|
+
|
|
53
|
+
let fmt: { format: number; channels: number; sampleRate: number; bits: number } | null = null;
|
|
54
|
+
let dataOff = -1;
|
|
55
|
+
let dataLen = 0;
|
|
56
|
+
for (let o = 12; o + 8 <= bytes.length; ) {
|
|
57
|
+
const id = tag(o);
|
|
58
|
+
const size = v.getUint32(o + 4, true);
|
|
59
|
+
const body = o + 8;
|
|
60
|
+
if (id === "fmt ") {
|
|
61
|
+
// the reads below reach body+16 (base fields) / body+26 (extensible)
|
|
62
|
+
if (size < 16 || body + 16 > bytes.length)
|
|
63
|
+
throw new Error("WAV: truncated fmt chunk");
|
|
64
|
+
let format = v.getUint16(body, true);
|
|
65
|
+
const channels = v.getUint16(body + 2, true);
|
|
66
|
+
const sampleRate = v.getUint32(body + 4, true);
|
|
67
|
+
const bits = v.getUint16(body + 14, true);
|
|
68
|
+
if (format === WAVE_FORMAT_EXTENSIBLE) {
|
|
69
|
+
if (size < 40 || body + 26 > bytes.length)
|
|
70
|
+
throw new Error("WAV: truncated WAVE_FORMAT_EXTENSIBLE fmt chunk");
|
|
71
|
+
format = v.getUint16(body + 24, true); // first 2 bytes of SubFormat GUID
|
|
72
|
+
}
|
|
73
|
+
fmt = { format, channels, sampleRate, bits };
|
|
74
|
+
} else if (id === "data") {
|
|
75
|
+
dataOff = body;
|
|
76
|
+
dataLen = Math.min(size, bytes.length - body);
|
|
77
|
+
}
|
|
78
|
+
o = body + size + (size & 1); // chunks are word-aligned
|
|
79
|
+
}
|
|
80
|
+
if (!fmt) throw new Error("WAV: missing fmt chunk");
|
|
81
|
+
if (dataOff < 0) throw new Error("WAV: missing data chunk");
|
|
82
|
+
const { format, channels, sampleRate, bits } = fmt;
|
|
83
|
+
if (channels < 1) throw new Error("WAV: zero channels");
|
|
84
|
+
|
|
85
|
+
const bytesPer = bits / 8;
|
|
86
|
+
const frames = Math.floor(dataLen / (bytesPer * channels));
|
|
87
|
+
const out = new Float32Array(frames);
|
|
88
|
+
|
|
89
|
+
const readSample = (off: number): number => {
|
|
90
|
+
if (format === WAVE_FORMAT_IEEE_FLOAT && bits === 32) return v.getFloat32(off, true);
|
|
91
|
+
if (format === WAVE_FORMAT_PCM && bits === 16) return v.getInt16(off, true) / 32768;
|
|
92
|
+
if (format === WAVE_FORMAT_PCM && bits === 24) {
|
|
93
|
+
const raw = bytes[off]! | (bytes[off + 1]! << 8) | (bytes[off + 2]! << 16);
|
|
94
|
+
return ((raw << 8) >> 8) / 8388608;
|
|
95
|
+
}
|
|
96
|
+
if (format === WAVE_FORMAT_PCM && bits === 32) return v.getInt32(off, true) / 2147483648;
|
|
97
|
+
throw new Error(`WAV: unsupported format ${format} / ${bits}-bit`);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
for (let f = 0; f < frames; f++) {
|
|
101
|
+
const base = dataOff + f * bytesPer * channels;
|
|
102
|
+
let acc = 0;
|
|
103
|
+
for (let c = 0; c < channels; c++) acc += readSample(base + c * bytesPer);
|
|
104
|
+
out[f] = Math.fround(acc / channels);
|
|
105
|
+
}
|
|
106
|
+
return { samples: out, sampleRate };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Linear resample to the model rate (no-op passthrough at 16 kHz). */
|
|
110
|
+
export function resampleTo16k(samples: Float32Array, sampleRate: number): Float32Array {
|
|
111
|
+
if (sampleRate === AUDIO_SAMPLE_RATE) return samples;
|
|
112
|
+
if (sampleRate <= 0) throw new Error(`bad sample rate ${sampleRate}`);
|
|
113
|
+
const outLen = Math.max(1, Math.round((samples.length * AUDIO_SAMPLE_RATE) / sampleRate));
|
|
114
|
+
const out = new Float32Array(outLen);
|
|
115
|
+
const step = samples.length / outLen;
|
|
116
|
+
for (let i = 0; i < outLen; i++) {
|
|
117
|
+
const pos = i * step;
|
|
118
|
+
const j = Math.floor(pos);
|
|
119
|
+
const fracPart = pos - j;
|
|
120
|
+
const a = samples[Math.min(j, samples.length - 1)]!;
|
|
121
|
+
const b = samples[Math.min(j + 1, samples.length - 1)]!;
|
|
122
|
+
out[i] = Math.fround(a + (b - a) * fracPart);
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** WAV bytes → mono 16 kHz float samples (the feature-extractor input),
|
|
128
|
+
* truncated at 30 s exactly like the oracle (AUDIO_MAX_SAMPLES). */
|
|
129
|
+
export function decodeAudio(bytes: Uint8Array): Float32Array {
|
|
130
|
+
const { samples, sampleRate } = decodeWav(bytes);
|
|
131
|
+
const s = resampleTo16k(samples, sampleRate);
|
|
132
|
+
return s.length > AUDIO_MAX_SAMPLES ? s.subarray(0, AUDIO_MAX_SAMPLES) : s;
|
|
133
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// USM log-mel feature extraction for gemma-4 audio — a verbatim-semantics
|
|
2
|
+
// port of the oracle's Gemma4AudioFeatureExtractor (optiq/vlm/_mlxvlm/
|
|
3
|
+
// models/gemma4/audio_feature_extractor.py, constructor DEFAULTS — §3.3 Q3
|
|
4
|
+
// of docs/design/audio-input-plan.md: no per-bin normalization, params are
|
|
5
|
+
// fixed for all gemma-4 models). Gated bit-close against the T0 mel goldens
|
|
6
|
+
// (goldens/e4b-audio-*-mel.bin) in tests/audio-features.test.ts.
|
|
7
|
+
//
|
|
8
|
+
// Pipeline (single waveform, 16 kHz mono float32):
|
|
9
|
+
// pad to a multiple of 128 samples (validity mask over the original part)
|
|
10
|
+
// → semicausal left-pad of frame_length/2 = 160 zeros
|
|
11
|
+
// → unfold into 321-sample frames at hop 160, drop the 321st sample
|
|
12
|
+
// (preemphasis = 0 branch)
|
|
13
|
+
// → × periodic Hann window (numpy computes it in FLOAT32 — emulated with
|
|
14
|
+
// Math.fround so the products match the oracle's f32 rounding)
|
|
15
|
+
// → rfft n=512 (numpy promotes to float64; we FFT in f64)
|
|
16
|
+
// → |·| → @ mel filter bank (128 HTK bins over 0–8 kHz, float64, the
|
|
17
|
+
// transformers mel_filter_bank construction: linspace fft freqs,
|
|
18
|
+
// unguarded slope division, no norm)
|
|
19
|
+
// → log(x + 1e-3) → zero out invalid frames → float32 [T, 128].
|
|
20
|
+
//
|
|
21
|
+
// Frame validity: frame f is valid iff original-sample index
|
|
22
|
+
// f·hop + frame_length lands inside the un-padded waveform.
|
|
23
|
+
|
|
24
|
+
const SR = 16_000;
|
|
25
|
+
const FRAME = 320; // 20 ms
|
|
26
|
+
const HOP = 160; // 10 ms
|
|
27
|
+
const FFT_N = 512; // 2^ceil(log2(320)), fft_overdrive=false
|
|
28
|
+
const FREQ_BINS = FFT_N / 2 + 1; // 257
|
|
29
|
+
const MEL_BINS = 128;
|
|
30
|
+
const MEL_FMIN = 0.0;
|
|
31
|
+
const MEL_FMAX = 8000.0;
|
|
32
|
+
const MEL_FLOOR = 1e-3;
|
|
33
|
+
const PAD_MULTIPLE = 128;
|
|
34
|
+
|
|
35
|
+
export interface MelFeatures {
|
|
36
|
+
/** [frames, 128] row-major log-mel, float32 (invalid frames zeroed). */
|
|
37
|
+
features: Float32Array;
|
|
38
|
+
frames: number;
|
|
39
|
+
/** Per-frame validity (1 = real audio, 0 = padding). */
|
|
40
|
+
mask: Uint8Array;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// --- mel filter bank (float64, transformers semantics) -----------------------
|
|
44
|
+
const hzToMel = (f: number): number => 2595.0 * Math.log10(1.0 + f / 700.0);
|
|
45
|
+
const melToHz = (m: number): number => 700.0 * (10 ** (m / 2595.0) - 1.0);
|
|
46
|
+
|
|
47
|
+
function buildMelFilters(): Float64Array {
|
|
48
|
+
// filter_freqs: 130 points linspace in mel space, mapped back to Hz
|
|
49
|
+
const melMin = hzToMel(MEL_FMIN);
|
|
50
|
+
const melMax = hzToMel(MEL_FMAX);
|
|
51
|
+
const nPts = MEL_BINS + 2;
|
|
52
|
+
const filterFreqs = new Float64Array(nPts);
|
|
53
|
+
for (let i = 0; i < nPts; i++)
|
|
54
|
+
filterFreqs[i] = melToHz(melMin + ((melMax - melMin) * i) / (nPts - 1));
|
|
55
|
+
// fft_freqs: linspace(0, sr//2, 257)
|
|
56
|
+
const fftFreqs = new Float64Array(FREQ_BINS);
|
|
57
|
+
for (let k = 0; k < FREQ_BINS; k++) fftFreqs[k] = (8000.0 * k) / (FREQ_BINS - 1);
|
|
58
|
+
// triangular filters: max(0, min(down, up)) — unguarded diffs like the oracle
|
|
59
|
+
const filters = new Float64Array(FREQ_BINS * MEL_BINS);
|
|
60
|
+
for (let m = 0; m < MEL_BINS; m++) {
|
|
61
|
+
const lower = filterFreqs[m]!;
|
|
62
|
+
const center = filterFreqs[m + 1]!;
|
|
63
|
+
const upper = filterFreqs[m + 2]!;
|
|
64
|
+
for (let k = 0; k < FREQ_BINS; k++) {
|
|
65
|
+
const down = (fftFreqs[k]! - lower) / (center - lower);
|
|
66
|
+
const up = (upper - fftFreqs[k]!) / (upper - center);
|
|
67
|
+
filters[k * MEL_BINS + m] = Math.max(0, Math.min(down, up));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return filters;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// --- periodic Hann window: the ORACLE'S EXACT float32 values -----------------
|
|
74
|
+
// numpy builds this in float32 end-to-end (arange f32 → ×2π/320 → cos, all in
|
|
75
|
+
// numpy's vectorized f32 routines) and those cos values differ from
|
|
76
|
+
// fround(Math.cos(x)) by 1 ulp on some inputs. That 1 ulp is the ONLY
|
|
77
|
+
// divergence in the whole pipeline: with these exact values our extractor
|
|
78
|
+
// reproduces the oracle mel golden BIT-EXACTLY; with a recomputed window the
|
|
79
|
+
// log-amplified cancellation error reaches ~4.5e-4 (verified 2026-07-07).
|
|
80
|
+
// So the constant IS the spec. Regen (oracle venv):
|
|
81
|
+
// python -c "from optiq.vlm._mlxvlm.models.gemma4.audio_feature_extractor \
|
|
82
|
+
// import Gemma4AudioFeatureExtractor as F; import base64; \
|
|
83
|
+
// print(base64.b64encode(F().window.astype('<f4').tobytes()).decode())"
|
|
84
|
+
const WINDOW_B64 =
|
|
85
|
+
"AAAAAAAgyjgAHMo5AFRjOgAHyjqAyR07ACJjOwCDmjvAtsk7wCf/O0BoHTyAVT48gFhiPNC2hDyQ" +
|
|
86
|
+
"yJk8UF+wPPB4yDwQE+I8ECv9PDDfDD0Q5Rs9qKUrPXAfPD3IUE09+DdfPUjTcT1okII9XI+MPYDl" +
|
|
87
|
+
"lj3QkaE9PJOsPbDotz0MkcM9LIvPPeDV2z30b+g9JFj1PZZGAT7iBgg+SOwOPhr2FT6mIx0+PHQk" +
|
|
88
|
+
"PhrnKz6OezM+zjA7Ph4GQz64+ko+0A1TPp4+Wz5OjGM+EvZrPhR7dD54Gn0+uemCPopShz5Ix4s+" +
|
|
89
|
+
"fEeQPrbSlD6GaJk+dQiePhCyoj7gZKc+biCsPkTksD7or7U+4oK6Prdcvz7uPMQ+CiPJPpEOzj4F" +
|
|
90
|
+
"/9I+6fPXPsTs3D4S6eE+W+jmPhvq6z7Y7fA+EvP1Pkr5+j4AAAA/XIMCP3cGBT8ViQc/8woKP9OL" +
|
|
91
|
+
"DD93Cw8/oIkRPwwGFD9+gBY/uPgYP3xuGz+K4R0/pVEgP5C+Ij8OKCU/340nP8rvKT+RTSw/+KYu" +
|
|
92
|
+
"P8b7MD++SzM/pZY1P0PcNz9dHDo/u1Y8PySLPj9iuUA/PeFCP3wCRT/sHEc/WjBJP448Sz9SQU0/" +
|
|
93
|
+
"eD5PP84zUT8dIVM/OgZVP/LiVj8Yt1g/eoJaP+5EXD9I/l0/Wq5fP/xUYT8C8mI/RIVkP5oOZj/e" +
|
|
94
|
+
"jWc/6gJpP5ltaj/GzWs/UCNtPxVubj/0rW8/zOJwP4EMcj/0KnM/Cj50P6ZFdT+wQXY/DTJ3P6gW" +
|
|
95
|
+
"eD9o73g/OLx5PwZ9ej+8MXs/Stp7P552fD+qBn0/YIp9P7ABfj+SbH4/+sp+P94cfz82Yn8//Zp/" +
|
|
96
|
+
"PyvHfz+85n8/r/l/PwAAgD+v+X8/vOZ/PyvHfz/9mn8/NmJ/P94cfz/6yn4/kmx+P7ABfj9gin0/" +
|
|
97
|
+
"qgZ9P552fD9J2ns/uzF7PwV9ej84vHk/aO94P6gWeD8NMnc/r0F2P6RFdT8IPnQ/8ipzP4AMcj/M" +
|
|
98
|
+
"4nA/861vPxRubj9QI20/xc1rP5dtaj/rAmk/3o1nP5oOZj9EhWQ/AfJiP/tUYT9Yrl8/SP5dP+9E" +
|
|
99
|
+
"XD95glo/FbdYP/DiVj84BlU/HCFTP80zUT93Pk8/UkFNP4o8Sz9ZMEk/6xxHP3gCRT894UI/YrlA" +
|
|
100
|
+
"PyKLPj+6Vjw/Whw6P0LcNz+mljU/vEszP8b7MD/4pi4/jk0sP8jvKT/cjSc/DCglP5C+Ij+kUSA/" +
|
|
101
|
+
"iuEdP3puGz+1+Bg/fYAWPwwGFD+diRE/dwsPP9KLDD/zCgo/EokHP3MGBT9dgwI/AAAAP0X5+j4R" +
|
|
102
|
+
"8/U+0+3wPhrq6z5U6OY+EenhPsXs3D7o89c+/v7SPo4Ozj4EI8k+6zzEPrhcvz7fgro+6a+1PkHk" +
|
|
103
|
+
"sD5oIKw+3WSnPgqyoj5zCJ4+iGiZPrTSlD58R5A+RMeLPoRShz616YI+ehp9Pgx7dD4Q9ms+Roxj" +
|
|
104
|
+
"Ppw+Wz7IDVM+qvpKPiQGQz7MMDs+hHszPhrnKz4ydCQ+piMdPhL2FT5E7A4+5AYIPpRGAT4UWPU9" +
|
|
105
|
+
"7G/oPdDV2z0oi889EJHDPajotz08k6w9yJGhPXDllj1Uj4w9WJCCPTjTcT34N189uFBNPXAfPD2Y" +
|
|
106
|
+
"pSs98OQbPTjfDD0QK/088BLiPPB4yDxAX7A8gMiZPMC2hDxgWGI8gFU+PCBoHTyAJ/87wLbJO8CC" +
|
|
107
|
+
"mjsAImM7gMkdOwAGyjoAVGM6ABzKOQAgyjg=";
|
|
108
|
+
|
|
109
|
+
function buildWindow(): Float32Array {
|
|
110
|
+
const raw = Buffer.from(WINDOW_B64, "base64");
|
|
111
|
+
return new Float32Array(raw.buffer, raw.byteOffset, FRAME);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// --- iterative radix-2 complex FFT (float64), N = 512 ------------------------
|
|
115
|
+
const FFT_COS = new Float64Array(FFT_N / 2);
|
|
116
|
+
const FFT_SIN = new Float64Array(FFT_N / 2);
|
|
117
|
+
for (let i = 0; i < FFT_N / 2; i++) {
|
|
118
|
+
FFT_COS[i] = Math.cos((-2 * Math.PI * i) / FFT_N);
|
|
119
|
+
FFT_SIN[i] = Math.sin((-2 * Math.PI * i) / FFT_N);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function fft512(re: Float64Array, im: Float64Array): void {
|
|
123
|
+
// bit-reversal permutation
|
|
124
|
+
for (let i = 1, j = 0; i < FFT_N; i++) {
|
|
125
|
+
let bit = FFT_N >> 1;
|
|
126
|
+
for (; j & bit; bit >>= 1) j ^= bit;
|
|
127
|
+
j ^= bit;
|
|
128
|
+
if (i < j) {
|
|
129
|
+
const tr = re[i]!; re[i] = re[j]!; re[j] = tr;
|
|
130
|
+
const ti = im[i]!; im[i] = im[j]!; im[j] = ti;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
for (let len = 2; len <= FFT_N; len <<= 1) {
|
|
134
|
+
const half = len >> 1;
|
|
135
|
+
const step = FFT_N / len;
|
|
136
|
+
for (let i = 0; i < FFT_N; i += len) {
|
|
137
|
+
for (let k = 0; k < half; k++) {
|
|
138
|
+
const c = FFT_COS[k * step]!;
|
|
139
|
+
const s = FFT_SIN[k * step]!;
|
|
140
|
+
const xr = re[i + k + half]! * c - im[i + k + half]! * s;
|
|
141
|
+
const xi = re[i + k + half]! * s + im[i + k + half]! * c;
|
|
142
|
+
re[i + k + half] = re[i + k]! - xr;
|
|
143
|
+
im[i + k + half] = im[i + k]! - xi;
|
|
144
|
+
re[i + k] = re[i + k]! + xr;
|
|
145
|
+
im[i + k] = im[i + k]! + xi;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let melFilters: Float64Array | null = null;
|
|
152
|
+
let hannWindow: Float32Array | null = null;
|
|
153
|
+
|
|
154
|
+
/** 16 kHz mono float32 waveform → USM log-mel features [T, 128]. */
|
|
155
|
+
export function extractMelFeatures(waveform: Float32Array): MelFeatures {
|
|
156
|
+
melFilters ??= buildMelFilters();
|
|
157
|
+
hannWindow ??= buildWindow();
|
|
158
|
+
|
|
159
|
+
const nReal = waveform.length;
|
|
160
|
+
const nPadded = Math.ceil(nReal / PAD_MULTIPLE) * PAD_MULTIPLE;
|
|
161
|
+
const PAD_LEFT = FRAME >> 1; // 160
|
|
162
|
+
const total = PAD_LEFT + nPadded;
|
|
163
|
+
const frames = Math.floor((total - (FRAME + 1)) / HOP) + 1;
|
|
164
|
+
if (frames <= 0) return { features: new Float32Array(0), frames: 0, mask: new Uint8Array(0) };
|
|
165
|
+
|
|
166
|
+
const features = new Float32Array(frames * MEL_BINS);
|
|
167
|
+
const mask = new Uint8Array(frames);
|
|
168
|
+
const re = new Float64Array(FFT_N);
|
|
169
|
+
const im = new Float64Array(FFT_N);
|
|
170
|
+
const mel = new Float64Array(MEL_BINS);
|
|
171
|
+
|
|
172
|
+
for (let f = 0; f < frames; f++) {
|
|
173
|
+
// frame f covers padded indices [f*HOP, f*HOP+FRAME) after the left pad;
|
|
174
|
+
// validity = the frame-END sample (index f*HOP+FRAME in the left-padded
|
|
175
|
+
// stream, i.e. f*HOP+FRAME-PAD_LEFT in the original) is real audio.
|
|
176
|
+
const endIdx = f * HOP + FRAME - PAD_LEFT;
|
|
177
|
+
const valid = endIdx >= 0 && endIdx < nReal;
|
|
178
|
+
mask[f] = valid ? 1 : 0;
|
|
179
|
+
if (!valid) continue; // oracle zeroes invalid frames; ours start zeroed
|
|
180
|
+
|
|
181
|
+
re.fill(0);
|
|
182
|
+
im.fill(0);
|
|
183
|
+
for (let n = 0; n < FRAME; n++) {
|
|
184
|
+
const srcIdx = f * HOP + n - PAD_LEFT; // <0 → semicausal zero pad
|
|
185
|
+
const sample = srcIdx >= 0 && srcIdx < nReal ? waveform[srcIdx]! : 0;
|
|
186
|
+
re[n] = Math.fround(sample * hannWindow[n]!); // numpy f32 product
|
|
187
|
+
}
|
|
188
|
+
fft512(re, im);
|
|
189
|
+
|
|
190
|
+
mel.fill(0);
|
|
191
|
+
for (let k = 0; k < FREQ_BINS; k++) {
|
|
192
|
+
const mag = Math.hypot(re[k]!, im[k]!);
|
|
193
|
+
if (mag === 0) continue;
|
|
194
|
+
const base = k * MEL_BINS;
|
|
195
|
+
for (let m = 0; m < MEL_BINS; m++) mel[m] = mel[m]! + mag * melFilters![base + m]!;
|
|
196
|
+
}
|
|
197
|
+
const out = f * MEL_BINS;
|
|
198
|
+
for (let m = 0; m < MEL_BINS; m++)
|
|
199
|
+
features[out + m] = Math.fround(Math.log(mel[m]! + MEL_FLOOR));
|
|
200
|
+
}
|
|
201
|
+
return { features, frames, mask };
|
|
202
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Non-WAV audio containers → 16 kHz WAV via macOS `afconvert` (CoreAudio —
|
|
2
|
+
// built into darwin, our only platform; zero new dependencies). The serve
|
|
3
|
+
// layer runs every clip through ensureWav(): RIFF/WAVE bytes pass through
|
|
4
|
+
// untouched (decode.ts owns the parse), anything else (mp3/m4a/flac/ogg/
|
|
5
|
+
// aiff/…) round-trips through a temp file under os.tmpdir().
|
|
6
|
+
//
|
|
7
|
+
// Two deliberate choices:
|
|
8
|
+
// - The WAV/non-WAV decision is CONTENT-based (RIFF magic), not
|
|
9
|
+
// metadata-based — a mislabelled `format` field must not break a valid
|
|
10
|
+
// WAV, and an mp3 labelled "wav" still transcodes.
|
|
11
|
+
// - Channel count is preserved (decodeWav does the mono mixdown); the
|
|
12
|
+
// sample rate converts here (`@16000`) because CoreAudio's resampler
|
|
13
|
+
// beats decode.ts's documented linear interpolation for real
|
|
14
|
+
// compressed sources.
|
|
15
|
+
|
|
16
|
+
import { randomUUID } from "node:crypto";
|
|
17
|
+
import { unlink } from "node:fs/promises";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
|
|
21
|
+
/** RIFF/WAVE magic sniff ("RIFF" at 0, "WAVE" at 8). */
|
|
22
|
+
export function isRiffWave(bytes: Uint8Array): boolean {
|
|
23
|
+
return (
|
|
24
|
+
bytes.length >= 12 &&
|
|
25
|
+
bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
|
|
26
|
+
bytes[8] === 0x57 && bytes[9] === 0x41 && bytes[10] === 0x56 && bytes[11] === 0x45
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Transcode any CoreAudio-readable container to 16 kHz PCM16 WAV. Throws
|
|
31
|
+
* with afconvert's diagnostic on failure — the serve layer surfaces it as
|
|
32
|
+
* a 400 ("prompt build failed: audio transcode failed …"). Temp files are
|
|
33
|
+
* cleaned up on every path. */
|
|
34
|
+
export async function transcodeToWav(bytes: Uint8Array): Promise<Uint8Array> {
|
|
35
|
+
const base = join(tmpdir(), `mlx-bun-audio-${randomUUID()}`);
|
|
36
|
+
const inPath = `${base}.in`;
|
|
37
|
+
const outPath = `${base}.wav`;
|
|
38
|
+
await Bun.write(inPath, bytes);
|
|
39
|
+
try {
|
|
40
|
+
const proc = Bun.spawn(
|
|
41
|
+
["afconvert", "-f", "WAVE", "-d", "LEI16@16000", inPath, outPath],
|
|
42
|
+
{ stdout: "ignore", stderr: "pipe" },
|
|
43
|
+
);
|
|
44
|
+
// ≤30 s clips convert in well under a second — a hung afconvert must
|
|
45
|
+
// not pin the request forever. kill() → nonzero exit → the 400 below.
|
|
46
|
+
const killer = setTimeout(() => proc.kill(), 30_000);
|
|
47
|
+
const exit = await proc.exited;
|
|
48
|
+
clearTimeout(killer);
|
|
49
|
+
if (exit !== 0) {
|
|
50
|
+
const err = (await new Response(proc.stderr).text()).trim();
|
|
51
|
+
throw new Error(
|
|
52
|
+
`audio transcode failed (afconvert exit ${exit}): ` +
|
|
53
|
+
`${err.split("\n").at(-1) || "unknown error"} — ` +
|
|
54
|
+
`supported containers: WAV (native), mp3/m4a/flac/ogg/aiff via CoreAudio`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return new Uint8Array(await Bun.file(outPath).arrayBuffer());
|
|
58
|
+
} finally {
|
|
59
|
+
await Promise.allSettled([unlink(inPath), unlink(outPath)]);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** WAV passthrough or afconvert transcode — every serve-side clip goes
|
|
64
|
+
* through here before decodeAudio. */
|
|
65
|
+
export async function ensureWav(bytes: Uint8Array): Promise<Uint8Array> {
|
|
66
|
+
return isRiffWave(bytes) ? bytes : transcodeToWav(bytes);
|
|
67
|
+
}
|
package/src/chat-template.ts
CHANGED
|
@@ -42,6 +42,126 @@ export interface RenderOptions {
|
|
|
42
42
|
enableThinking?: boolean;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
type ChatRenderer = (
|
|
46
|
+
messages: ChatMessage[],
|
|
47
|
+
options: RenderOptions,
|
|
48
|
+
) => string;
|
|
49
|
+
|
|
50
|
+
function glm52ContentText(content: ChatMessage["content"], label: string): string {
|
|
51
|
+
if (typeof content === "string") return content;
|
|
52
|
+
if (!Array.isArray(content))
|
|
53
|
+
throw new Error(`${label} must be a string or an array of text parts`);
|
|
54
|
+
return content.map((part, index) => {
|
|
55
|
+
if (
|
|
56
|
+
!part ||
|
|
57
|
+
(part.type !== "text" && part.type !== "input_text") ||
|
|
58
|
+
typeof part.text !== "string"
|
|
59
|
+
) {
|
|
60
|
+
throw new Error(`${label}.${index}: GLM-5.2 supports text content only`);
|
|
61
|
+
}
|
|
62
|
+
return part.text;
|
|
63
|
+
}).join("");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Text/tool subset of the pinned GLM-5.2 template used by Colibri. */
|
|
67
|
+
export function renderGlm52Chat(
|
|
68
|
+
messages: ChatMessage[],
|
|
69
|
+
options: RenderOptions = {},
|
|
70
|
+
): string {
|
|
71
|
+
if (!Array.isArray(messages) || messages.length === 0)
|
|
72
|
+
throw new Error("GLM-5.2 messages must be a non-empty array");
|
|
73
|
+
const {
|
|
74
|
+
addGenerationPrompt = true,
|
|
75
|
+
tools = null,
|
|
76
|
+
enableThinking = false,
|
|
77
|
+
} = options;
|
|
78
|
+
const prompt = ["[gMASK]<sop>"];
|
|
79
|
+
if (enableThinking)
|
|
80
|
+
prompt.push("<|system|>Reasoning Effort: Max");
|
|
81
|
+
|
|
82
|
+
const normalizedTools = normalizeToolSchemas(tools);
|
|
83
|
+
if (normalizedTools && normalizedTools.length > 0) {
|
|
84
|
+
prompt.push(
|
|
85
|
+
"<|system|>\n# Tools\n\nYou may call one or more functions to assist with the " +
|
|
86
|
+
"user query.\n\nYou are provided with function signatures within <tools></tools> " +
|
|
87
|
+
"XML tags:\n<tools>\n",
|
|
88
|
+
);
|
|
89
|
+
for (const tool of normalizedTools) {
|
|
90
|
+
const fn = { ...tool.function } as Record<string, unknown>;
|
|
91
|
+
delete fn.defer_loading;
|
|
92
|
+
delete fn.strict;
|
|
93
|
+
prompt.push(`${JSON.stringify(fn)}\n`);
|
|
94
|
+
}
|
|
95
|
+
prompt.push(
|
|
96
|
+
"</tools>\n\nFor each function call, output the function name and arguments " +
|
|
97
|
+
"within the following XML format:\n<tool_call>{function-name}" +
|
|
98
|
+
"<arg_key>{arg-key-1}</arg_key><arg_value>{arg-value-1}</arg_value>" +
|
|
99
|
+
"<arg_key>{arg-key-2}</arg_key><arg_value>{arg-value-2}</arg_value>...</tool_call>",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let previousWasTool = false;
|
|
104
|
+
for (let index = 0; index < messages.length; index++) {
|
|
105
|
+
const message = messages[index]!;
|
|
106
|
+
const role = message.role;
|
|
107
|
+
if (role === "system" || role === "developer") {
|
|
108
|
+
prompt.push(
|
|
109
|
+
`<|system|>${glm52ContentText(message.content, `messages.${index}.content`)}`,
|
|
110
|
+
);
|
|
111
|
+
} else if (role === "user") {
|
|
112
|
+
prompt.push(
|
|
113
|
+
`<|user|>${glm52ContentText(message.content, `messages.${index}.content`)}`,
|
|
114
|
+
);
|
|
115
|
+
} else if (role === "assistant") {
|
|
116
|
+
const content = message.content === null || message.content === undefined
|
|
117
|
+
? ""
|
|
118
|
+
: glm52ContentText(message.content, `messages.${index}.content`);
|
|
119
|
+
prompt.push(`<|assistant|><think></think>${content.trim()}`);
|
|
120
|
+
for (const call of message.tool_calls ?? []) {
|
|
121
|
+
const raw = call.function.arguments;
|
|
122
|
+
let args: Record<string, unknown> = {};
|
|
123
|
+
if (typeof raw === "string") {
|
|
124
|
+
try {
|
|
125
|
+
const parsed = JSON.parse(raw);
|
|
126
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
|
|
127
|
+
args = parsed as Record<string, unknown>;
|
|
128
|
+
} catch {
|
|
129
|
+
// Match the pinned template path: malformed historical arguments
|
|
130
|
+
// contribute an empty call body rather than changing the prompt.
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
args = raw;
|
|
134
|
+
}
|
|
135
|
+
prompt.push(`<tool_call>${call.function.name}`);
|
|
136
|
+
for (const [key, value] of Object.entries(args)) {
|
|
137
|
+
prompt.push(
|
|
138
|
+
`<arg_key>${key}</arg_key><arg_value>` +
|
|
139
|
+
`${typeof value === "string" ? value : JSON.stringify(value)}</arg_value>`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
prompt.push("</tool_call>");
|
|
143
|
+
}
|
|
144
|
+
} else if (role === "tool") {
|
|
145
|
+
if (!previousWasTool) prompt.push("<|observation|>");
|
|
146
|
+
prompt.push(
|
|
147
|
+
`<tool_response>${glm52ContentText(message.content, `messages.${index}.content`)}` +
|
|
148
|
+
"</tool_response>",
|
|
149
|
+
);
|
|
150
|
+
} else {
|
|
151
|
+
throw new Error(`GLM-5.2 unsupported message role ${JSON.stringify(role)}`);
|
|
152
|
+
}
|
|
153
|
+
previousWasTool = role === "tool";
|
|
154
|
+
}
|
|
155
|
+
if (addGenerationPrompt) {
|
|
156
|
+
prompt.push(
|
|
157
|
+
enableThinking
|
|
158
|
+
? "<|assistant|><think>"
|
|
159
|
+
: "<|assistant|><think></think>",
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
return prompt.join("");
|
|
163
|
+
}
|
|
164
|
+
|
|
45
165
|
/** JSON-schema type implied by a literal value. */
|
|
46
166
|
function jsonTypeOf(v: unknown): string {
|
|
47
167
|
if (typeof v === "number") return Number.isInteger(v) ? "integer" : "number";
|
|
@@ -116,7 +236,8 @@ export function normalizeToolSchemas(tools: ToolDefinition[] | null): ToolDefini
|
|
|
116
236
|
}
|
|
117
237
|
|
|
118
238
|
export class ChatTemplate {
|
|
119
|
-
readonly #template: Template;
|
|
239
|
+
readonly #template: Template | null;
|
|
240
|
+
readonly #renderer: ChatRenderer | null;
|
|
120
241
|
readonly #bosToken: string | null;
|
|
121
242
|
readonly #eosToken: string | null;
|
|
122
243
|
/** Which reasoning format the template's `enable_thinking` channel uses, or
|
|
@@ -132,15 +253,20 @@ export class ChatTemplate {
|
|
|
132
253
|
readonly supportsThinking: boolean;
|
|
133
254
|
|
|
134
255
|
private constructor(
|
|
135
|
-
source: string,
|
|
256
|
+
source: string | null,
|
|
136
257
|
bosToken: string | null,
|
|
137
258
|
eosToken: string | null,
|
|
138
259
|
forceNoThinking = false,
|
|
260
|
+
renderer: ChatRenderer | null = null,
|
|
261
|
+
explicitThinkingFormat: "think-tag" | "gemma-channel" | null = null,
|
|
139
262
|
) {
|
|
140
|
-
|
|
263
|
+
if (source === null && renderer === null)
|
|
264
|
+
throw new Error("ChatTemplate needs a Jinja source or renderer");
|
|
265
|
+
this.#template = source === null ? null : new Template(source);
|
|
266
|
+
this.#renderer = renderer;
|
|
141
267
|
this.#bosToken = bosToken;
|
|
142
268
|
this.#eosToken = eosToken;
|
|
143
|
-
const gatesThinking = source
|
|
269
|
+
const gatesThinking = source?.includes("enable_thinking") ?? false;
|
|
144
270
|
// `forceNoThinking` suppresses the switchable channel even when the template
|
|
145
271
|
// carries the gemma markers. DiffusionGemma ships the shared gemma-family
|
|
146
272
|
// template (with the `<|channel>thought…<channel|>` reasoning channel), but
|
|
@@ -149,13 +275,13 @@ export class ChatTemplate {
|
|
|
149
275
|
// reasoning, no answer). The OptiQ reference never enables thinking for this
|
|
150
276
|
// model — it always renders the template's `default(false)` pre-closed empty
|
|
151
277
|
// channel and decodes the whole canvas as plain text. We match that.
|
|
152
|
-
this.thinkingFormat = forceNoThinking
|
|
278
|
+
this.thinkingFormat = explicitThinkingFormat ?? (forceNoThinking
|
|
153
279
|
? null
|
|
154
|
-
: gatesThinking && source
|
|
280
|
+
: gatesThinking && source!.includes("<think>")
|
|
155
281
|
? "think-tag"
|
|
156
|
-
: gatesThinking && source
|
|
282
|
+
: gatesThinking && source!.includes("<|channel>")
|
|
157
283
|
? "gemma-channel"
|
|
158
|
-
: null;
|
|
284
|
+
: null);
|
|
159
285
|
this.supportsThinking = this.thinkingFormat !== null;
|
|
160
286
|
}
|
|
161
287
|
|
|
@@ -169,7 +295,25 @@ export class ChatTemplate {
|
|
|
169
295
|
const jinjaFile = Bun.file(`${modelDir}/chat_template.jinja`);
|
|
170
296
|
if (await jinjaFile.exists()) source = await jinjaFile.text();
|
|
171
297
|
}
|
|
172
|
-
if (!source)
|
|
298
|
+
if (!source) {
|
|
299
|
+
const modelConfigFile = Bun.file(`${modelDir}/config.json`);
|
|
300
|
+
const modelConfig = await modelConfigFile.exists()
|
|
301
|
+
? await modelConfigFile.json() as Record<string, unknown>
|
|
302
|
+
: null;
|
|
303
|
+
if (modelConfig?.model_type === "glm_moe_dsa") {
|
|
304
|
+
const tokenText = (t: unknown): string | null =>
|
|
305
|
+
t == null ? null : typeof t === "string" ? t : (t as any).content;
|
|
306
|
+
return new ChatTemplate(
|
|
307
|
+
null,
|
|
308
|
+
tokenText(config.bos_token),
|
|
309
|
+
tokenText(config.eos_token),
|
|
310
|
+
false,
|
|
311
|
+
renderGlm52Chat,
|
|
312
|
+
"think-tag",
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
throw new Error(`${modelDir}: no chat template found`);
|
|
316
|
+
}
|
|
173
317
|
// @huggingface/jinja lacks the `min`/`max` array filters that real
|
|
174
318
|
// Jinja2 has. MiniCPM5's template uses `[a, b]|min` in its assistant
|
|
175
319
|
// tool-call history branch, so without this rewrite every multi-turn
|
|
@@ -189,8 +333,9 @@ export class ChatTemplate {
|
|
|
189
333
|
}
|
|
190
334
|
|
|
191
335
|
render(messages: ChatMessage[], options: RenderOptions = {}): string {
|
|
336
|
+
if (this.#renderer) return this.#renderer(messages, options);
|
|
192
337
|
const { addGenerationPrompt = true, tools = null, enableThinking } = options;
|
|
193
|
-
return this.#template
|
|
338
|
+
return this.#template!.render({
|
|
194
339
|
messages,
|
|
195
340
|
add_generation_prompt: addGenerationPrompt,
|
|
196
341
|
// Guarantee every tool param schema has a `type` so templates that do
|