@mlx-node/lm 0.0.7 → 0.0.9

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.
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Process-local model-directory overrides for forcing block-paged KV caches.
3
+ *
4
+ * Native model loaders accept a directory path and read paging policy from
5
+ * `config.json`. This manager creates an isolated temporary clone containing a
6
+ * patched config plus symlinks to the source files, so callers can opt models
7
+ * into paging without mutating downloaded checkpoints. Loader-known Qwen3.5
8
+ * MTP sidecars are preserved explicitly; unrelated directories (including a
9
+ * Gemma4 `draft/`) remain hidden unless a caller explicitly preserves the
10
+ * Gemma draft for flat speculative decoding.
11
+ */
12
+ import { mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises';
13
+ import { tmpdir } from 'node:os';
14
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
15
+ /** Every chat-capable family currently discovered by `@mlx-node/agent`. */
16
+ export const AGENT_PAGED_MODEL_TYPES = ['qwen3', 'qwen3_5', 'qwen3_5_moe', 'gemma4', 'lfm2', 'lfm2_moe'];
17
+ /** Families historically forced paged by `mlx launch claude`. */
18
+ export const QWEN35_PAGED_MODEL_TYPES = ['qwen3_5', 'qwen3_5_moe'];
19
+ const QWEN35_CACHE_FLOOR_MODEL_TYPES = new Set(QWEN35_PAGED_MODEL_TYPES);
20
+ const DEFAULT_QWEN35_PAGED_CACHE_MB = 16_384;
21
+ const QWEN35_MTP_DRAFTER_DIR = 'mtp-drafter';
22
+ const QWEN35_DENSE_NESTED_MTP_SIDECAR = 'mtp/weights.safetensors';
23
+ /**
24
+ * Owns one isolated set of temporary paged-config overrides.
25
+ *
26
+ * A manager is intentionally single-lifecycle: repeated resolution of one
27
+ * source returns the same override, and `cleanup()` permanently disposes the
28
+ * manager. Separate managers never share directories, so one launch cannot
29
+ * remove another launch's live override.
30
+ */
31
+ export class PagedConfigOverrideManager {
32
+ modelTypes;
33
+ tempDirPrefix;
34
+ preserveEmbeddedGemmaDraft;
35
+ overrides = new Map();
36
+ activeResolves = new Set();
37
+ rootPromise;
38
+ cleanupPromise;
39
+ disposed = false;
40
+ constructor(options = {}) {
41
+ this.modelTypes = new Set(options.modelTypes ?? AGENT_PAGED_MODEL_TYPES);
42
+ this.tempDirPrefix = options.tempDirPrefix ?? 'mlx-paged-overrides-';
43
+ this.preserveEmbeddedGemmaDraft = options.preserveEmbeddedGemmaDraft ?? false;
44
+ }
45
+ /**
46
+ * Resolve `modelPath` to a paged-aware clone when its model type is managed.
47
+ * A caller-supplied canonical family takes precedence over the raw config
48
+ * type (for example, `gemma4` for a `gemma4_unified` checkpoint).
49
+ * Unmanaged, unreadable, or malformed checkpoints pass through unchanged.
50
+ *
51
+ * `persistPagedCache` is a tri-state cold-tier directive. `undefined` leaves
52
+ * the field untouched (families with no cold-tier opt-in). A boolean is
53
+ * AUTHORITATIVE: it writes `persist_paged_cache: <value>` into the cloned
54
+ * config, overriding whatever the source config.json carries (either alias),
55
+ * and forces a clone whenever the source's value disagrees so the directive
56
+ * actually reaches the loader. Callers gate the boolean to the families whose
57
+ * paged cold restore is sound — the allowlist in
58
+ * `packages/agent/src/cold-tier.ts`, mirrored by `COLD_RESTORE_FAMILIES` in
59
+ * `crates/mlx-core/src/cold_tier.rs`. It is a SET, not one family, and it is
60
+ * deliberately not repeated here: `MlxModelHost` calls this for every
61
+ * allowlisted family, and a second copy of the list would drift.
62
+ */
63
+ async resolve(modelPath, canonicalModelType, persistPagedCache) {
64
+ if (this.disposed) {
65
+ throw new Error('PagedConfigOverrideManager: resolve() called after cleanup()');
66
+ }
67
+ const operation = this.resolveInternal(modelPath, canonicalModelType, persistPagedCache);
68
+ this.activeResolves.add(operation);
69
+ try {
70
+ return await operation;
71
+ }
72
+ finally {
73
+ this.activeResolves.delete(operation);
74
+ }
75
+ }
76
+ async resolveInternal(modelPath, canonicalModelType, persistPagedCache) {
77
+ const sourcePath = isAbsolute(modelPath) ? modelPath : resolve(modelPath);
78
+ let config;
79
+ try {
80
+ config = JSON.parse(await readFile(join(sourcePath, 'config.json'), 'utf-8'));
81
+ }
82
+ catch {
83
+ return modelPath;
84
+ }
85
+ const rawModelType = typeof config.model_type === 'string' ? config.model_type : null;
86
+ const modelType = canonicalModelType ?? rawModelType;
87
+ if (modelType === null || !this.modelTypes.has(modelType)) {
88
+ return modelPath;
89
+ }
90
+ // A boolean persist directive is authoritative: force a clone whenever the
91
+ // source config's EFFECTIVE value disagrees, so the directive actually reaches
92
+ // the loader (both `--no-persist-cache` off AND default-on). Mirror the native
93
+ // parser's snake-first precedence — `persist_paged_cache` wins when present,
94
+ // else the camelCase alias — so a config with snake=false AND camel=true does
95
+ // not read as `true` here (an OR) while native reads snake=false, silently
96
+ // dropping an authoritative `true` request.
97
+ const sourcePersist = typeof config.persist_paged_cache === 'boolean' ? config.persist_paged_cache : config.persistPagedCache === true;
98
+ const persistOverrideNeeded = persistPagedCache !== undefined && persistPagedCache !== sourcePersist;
99
+ // Gemma4's DSpark / assistant speculative executor currently owns flat KV
100
+ // caches. Preserve native draft discovery only for explicit callers; the
101
+ // default paged clone intentionally omits subdirectories, hiding `draft/`
102
+ // while keeping the downloaded checkpoint unchanged.
103
+ const hasEmbeddedGemmaDraft = modelType === 'gemma4' && (await isDirectory(join(sourcePath, 'draft')));
104
+ if (this.preserveEmbeddedGemmaDraft && hasEmbeddedGemmaDraft) {
105
+ return modelPath;
106
+ }
107
+ const cacheFloorMb = QWEN35_CACHE_FLOOR_MODEL_TYPES.has(modelType) ? resolveQwen35CacheFloorMb() : undefined;
108
+ const pagedEnabled = config.use_block_paged_cache === true;
109
+ const configuredMemoryMb = positiveNumber(config.paged_cache_memory_mb);
110
+ const memorySatisfied = cacheFloorMb === undefined || (configuredMemoryMb ?? 0) >= cacheFloorMb;
111
+ // Even an already-paged Gemma config must be cloned when `draft/` exists:
112
+ // returning the source would expose the draft to native auto-discovery and
113
+ // trigger the flat-speculation/paged-cache conflict. An authoritative
114
+ // persist directive that disagrees with the source likewise blocks the
115
+ // pass-through so the resolved value reaches the loader.
116
+ if (pagedEnabled && memorySatisfied && !hasEmbeddedGemmaDraft && !persistOverrideNeeded) {
117
+ return modelPath;
118
+ }
119
+ // Memoize per (source, resolved family, persist directive): the same
120
+ // checkpoint resolved with a different persist tri-state must yield a distinct
121
+ // clone, not the first one cached under the bare path. `cleanup()` iterates
122
+ // every value, so multiple entries per path are all still disposed.
123
+ const persistKey = persistPagedCache === undefined ? 'u' : persistPagedCache ? 't' : 'f';
124
+ const cacheKey = `${sourcePath}\0${modelType}\0${persistKey}`;
125
+ const existing = this.overrides.get(cacheKey);
126
+ if (existing !== undefined)
127
+ return existing;
128
+ const pending = this.createOverride(sourcePath, config, cacheFloorMb, modelType, persistPagedCache);
129
+ this.overrides.set(cacheKey, pending);
130
+ try {
131
+ return await pending;
132
+ }
133
+ catch (error) {
134
+ this.overrides.delete(cacheKey);
135
+ throw error;
136
+ }
137
+ }
138
+ /** Remove this manager's temporary root without affecting other managers. */
139
+ cleanup() {
140
+ if (this.cleanupPromise !== undefined)
141
+ return this.cleanupPromise;
142
+ this.disposed = true;
143
+ this.cleanupPromise = this.performCleanup();
144
+ return this.cleanupPromise;
145
+ }
146
+ async performCleanup() {
147
+ await Promise.allSettled(this.activeResolves);
148
+ this.activeResolves.clear();
149
+ await Promise.allSettled(this.overrides.values());
150
+ this.overrides.clear();
151
+ if (this.rootPromise === undefined)
152
+ return;
153
+ const root = await this.rootPromise.catch(() => undefined);
154
+ if (root !== undefined) {
155
+ await rm(root, { recursive: true, force: true }).catch(() => undefined);
156
+ }
157
+ }
158
+ async createOverride(sourcePath, sourceConfig, cacheFloorMb, modelType, persistPagedCache) {
159
+ const root = await this.getRoot();
160
+ const overrideDir = await mkdtemp(join(root, 'model-'));
161
+ const config = {
162
+ ...sourceConfig,
163
+ use_block_paged_cache: true,
164
+ };
165
+ if (persistPagedCache !== undefined) {
166
+ // Authoritative: the loader reads snake_case, so that spelling is the one
167
+ // that decides persistence. Reconcile a stray camelCase alias spread from
168
+ // the source config so it can never contradict the authoritative value.
169
+ config.persist_paged_cache = persistPagedCache;
170
+ if ('persistPagedCache' in config)
171
+ config.persistPagedCache = persistPagedCache;
172
+ }
173
+ if (cacheFloorMb !== undefined) {
174
+ config.paged_cache_memory_mb = Math.max(positiveNumber(sourceConfig.paged_cache_memory_mb) ?? 0, cacheFloorMb);
175
+ }
176
+ await writeFile(join(overrideDir, 'config.json'), JSON.stringify(config, null, 2), 'utf-8');
177
+ const sourceEntries = await readdir(sourcePath);
178
+ for (const name of sourceEntries) {
179
+ if (name === 'config.json')
180
+ continue;
181
+ const source = join(sourcePath, name);
182
+ const destination = join(overrideDir, name);
183
+ let isFile;
184
+ try {
185
+ isFile = (await stat(source)).isFile();
186
+ }
187
+ catch {
188
+ continue;
189
+ }
190
+ if (!isFile)
191
+ continue;
192
+ try {
193
+ await symlink(source, destination);
194
+ }
195
+ catch (error) {
196
+ if (error.code !== 'EEXIST')
197
+ throw error;
198
+ }
199
+ }
200
+ if (QWEN35_CACHE_FLOOR_MODEL_TYPES.has(modelType)) {
201
+ await preserveQwen35MtpSidecars(sourcePath, overrideDir, sourceConfig, modelType);
202
+ }
203
+ return overrideDir;
204
+ }
205
+ getRoot() {
206
+ this.rootPromise ??= mkdtemp(join(tmpdir(), this.tempDirPrefix));
207
+ return this.rootPromise;
208
+ }
209
+ }
210
+ /**
211
+ * Keep only the nested paths that the native Qwen3.5 loaders inspect.
212
+ *
213
+ * Both dense and MoE loaders accept an mlx-vlm `mtp-drafter/` directory.
214
+ * Dense additionally accepts a config-declared relative sidecar and the
215
+ * conventional `mtp/weights.safetensors` fallback. Top-level sidecar files
216
+ * are already handled by the normal source-file loop above.
217
+ */
218
+ async function preserveQwen35MtpSidecars(sourcePath, overrideDir, sourceConfig, modelType) {
219
+ await symlinkDirectoryIfPresent(join(sourcePath, QWEN35_MTP_DRAFTER_DIR), join(overrideDir, QWEN35_MTP_DRAFTER_DIR));
220
+ // The MoE loader supports the split drafter directory, but deliberately has
221
+ // no `mtp.safetensors`-style sidecar discovery path.
222
+ if (modelType !== 'qwen3_5')
223
+ return;
224
+ const relativeSidecars = new Set([QWEN35_DENSE_NESTED_MTP_SIDECAR]);
225
+ const configuredSidecar = configuredQwen35MtpFile(sourceConfig);
226
+ if (configuredSidecar !== undefined && !isAbsolute(configuredSidecar)) {
227
+ relativeSidecars.add(configuredSidecar);
228
+ }
229
+ for (const sidecar of relativeSidecars) {
230
+ await symlinkContainedFileIfPresent(sourcePath, overrideDir, sidecar);
231
+ }
232
+ }
233
+ function configuredQwen35MtpFile(config) {
234
+ const extra = config.mlx_lm_extra_tensors;
235
+ if (extra === null || typeof extra !== 'object' || Array.isArray(extra))
236
+ return undefined;
237
+ const mtpFile = extra.mtp_file;
238
+ return typeof mtpFile === 'string' && mtpFile.trim() !== '' ? mtpFile : undefined;
239
+ }
240
+ async function symlinkContainedFileIfPresent(sourceRoot, destinationRoot, relativePath) {
241
+ const source = resolve(sourceRoot, relativePath);
242
+ const normalizedRelative = relative(sourceRoot, source);
243
+ if (normalizedRelative === '' ||
244
+ normalizedRelative === '..' ||
245
+ normalizedRelative.startsWith(`..${sep}`) ||
246
+ isAbsolute(normalizedRelative)) {
247
+ return;
248
+ }
249
+ try {
250
+ if (!(await stat(source)).isFile())
251
+ return;
252
+ }
253
+ catch {
254
+ return;
255
+ }
256
+ const destination = join(destinationRoot, normalizedRelative);
257
+ await mkdir(dirname(destination), { recursive: true });
258
+ await symlinkIfMissing(source, destination);
259
+ }
260
+ async function symlinkDirectoryIfPresent(source, destination) {
261
+ if (!(await isDirectory(source)))
262
+ return;
263
+ await symlinkIfMissing(source, destination);
264
+ }
265
+ async function symlinkIfMissing(source, destination) {
266
+ try {
267
+ await symlink(source, destination);
268
+ }
269
+ catch (error) {
270
+ if (error.code !== 'EEXIST')
271
+ throw error;
272
+ }
273
+ }
274
+ async function isDirectory(path) {
275
+ try {
276
+ return (await stat(path)).isDirectory();
277
+ }
278
+ catch {
279
+ return false;
280
+ }
281
+ }
282
+ function positiveNumber(value) {
283
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
284
+ }
285
+ function resolveQwen35CacheFloorMb() {
286
+ const raw = process.env.MLX_PAGED_CACHE_MEMORY_MB;
287
+ if (raw == null || raw === '')
288
+ return DEFAULT_QWEN35_PAGED_CACHE_MB;
289
+ const parsed = Number.parseInt(raw, 10);
290
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_QWEN35_PAGED_CACHE_MB;
291
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"qwen3_5-configs.d.ts","sourceRoot":"","sources":["../../src/models/qwen3_5-configs.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EACV,YAAY,IAAI,gBAAgB,EAChC,sBAAsB,IAAI,0BAA0B,EACpD,sBAAsB,IAAI,0BAA0B,EACrD,MAAM,gBAAgB,CAAC;AAExB,MAAM,MAAM,YAAY,GAAG,gBAAgB,CAAC;AAC5C,MAAM,MAAM,sBAAsB,GAAG,0BAA0B,CAAC;AAChE,MAAM,MAAM,sBAAsB,GAAG,0BAA0B,CAAC;AAEhE;;GAEG;AACH,eAAO,MAAM,cAAc,EAAE;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,YAAY,CAAA;CAyBzD,CAAC;AAEF;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,CAM1D"}
1
+ {"version":3,"file":"qwen3_5-configs.d.ts","sourceRoot":"","sources":["../../src/models/qwen3_5-configs.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EACV,YAAY,IAAI,gBAAgB,EAChC,sBAAsB,IAAI,0BAA0B,EACpD,sBAAsB,IAAI,0BAA0B,EACrD,MAAM,gBAAgB,CAAC;AAExB,MAAM,MAAM,YAAY,GAAG,gBAAgB,CAAC;AAC5C,MAAM,MAAM,sBAAsB,GAAG,0BAA0B,CAAC;AAChE,MAAM,MAAM,sBAAsB,GAAG,0BAA0B,CAAC;AAEhE;;GAEG;AACH,eAAO,MAAM,cAAc,EAAE;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,YAAY,CAAA;CA8BzD,CAAC;AAEF;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,CAM1D"}
@@ -31,6 +31,11 @@ export const QWEN35_CONFIGS = {
31
31
  fullAttentionInterval: 4,
32
32
  partialRotaryFactor: 0.25,
33
33
  ropeTheta: 100000.0,
34
+ // W1 (MTP): no MTP head in the stock 0.6B dense checkpoint.
35
+ // Real values are populated from `config.json` at load time; this
36
+ // preset is only used by tests that build a `Qwen35Config` from
37
+ // scratch.
38
+ nMtpLayers: 0,
34
39
  },
35
40
  };
36
41
  /**