@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.
- package/README.md +56 -38
- package/dist/chat-session.d.ts +372 -104
- package/dist/chat-session.d.ts.map +1 -1
- package/dist/chat-session.js +891 -187
- package/dist/index.d.ts +12 -17
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +31 -7
- package/dist/interfaces.d.ts +2 -17
- package/dist/interfaces.d.ts.map +1 -1
- package/dist/models/lfm2-configs.d.ts.map +1 -1
- package/dist/models/lfm2-configs.js +59 -0
- package/dist/models/model-loader.d.ts +175 -4
- package/dist/models/model-loader.d.ts.map +1 -1
- package/dist/models/model-loader.js +226 -49
- package/dist/models/paged-config-override.d.ts +72 -0
- package/dist/models/paged-config-override.d.ts.map +1 -0
- package/dist/models/paged-config-override.js +291 -0
- package/dist/models/qwen3_5-configs.d.ts.map +1 -1
- package/dist/models/qwen3_5-configs.js +5 -0
- package/dist/stream.d.ts +216 -103
- package/dist/stream.d.ts.map +1 -1
- package/dist/stream.js +192 -229
- package/package.json +3 -3
|
@@ -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;
|
|
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
|
/**
|