@mlx-node/lm 0.0.12 → 0.0.15

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.
Files changed (42) hide show
  1. package/dist/chat-session.d.ts +1 -1
  2. package/dist/chat-session.d.ts.map +1 -1
  3. package/dist/chat-session.js +2 -2
  4. package/dist/draft-companion.d.ts +16 -0
  5. package/dist/draft-companion.d.ts.map +1 -0
  6. package/dist/draft-companion.js +76 -0
  7. package/dist/family-data.d.ts +2 -0
  8. package/dist/family-data.d.ts.map +1 -1
  9. package/dist/family-data.js +2 -0
  10. package/dist/gguf-metadata.d.ts +2 -0
  11. package/dist/gguf-metadata.d.ts.map +1 -0
  12. package/dist/gguf-metadata.js +128 -0
  13. package/dist/model-detection.d.ts +6 -0
  14. package/dist/model-detection.d.ts.map +1 -0
  15. package/dist/model-detection.js +38 -0
  16. package/dist/model-discovery.d.ts +24 -0
  17. package/dist/model-discovery.d.ts.map +1 -0
  18. package/dist/model-discovery.js +274 -0
  19. package/dist/models/model-loader.d.ts +6 -0
  20. package/dist/models/model-loader.d.ts.map +1 -1
  21. package/dist/models/model-loader.js +10 -32
  22. package/dist/models/paged-config-override.d.ts.map +1 -1
  23. package/dist/models/paged-config-override.js +21 -1
  24. package/dist/stream.d.ts.map +1 -1
  25. package/dist/stream.js +5 -5
  26. package/package.json +21 -3
  27. package/src/chat-session.ts +2369 -0
  28. package/src/draft-companion.ts +74 -0
  29. package/src/family-data.ts +542 -0
  30. package/src/gguf-metadata.ts +117 -0
  31. package/src/index.ts +151 -0
  32. package/src/model-detection.ts +46 -0
  33. package/src/model-discovery.ts +329 -0
  34. package/src/models/lfm2-configs.ts +110 -0
  35. package/src/models/model-loader.ts +256 -0
  36. package/src/models/paged-config-override.ts +387 -0
  37. package/src/models/qwen3-configs.ts +113 -0
  38. package/src/models/qwen3_5-configs.ts +60 -0
  39. package/src/profiling.ts +69 -0
  40. package/src/stream.ts +960 -0
  41. package/src/tools/index.ts +58 -0
  42. package/src/tools/types.ts +215 -0
@@ -0,0 +1,387 @@
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
+
13
+ import { mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises';
14
+ import { tmpdir } from 'node:os';
15
+ import { dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
16
+
17
+ import { ggufArchitecture, prepareMuseGlimmerGguf } from '@mlx-node/core';
18
+
19
+ import { CHAT_FAMILY_IDS } from '../family-data.js';
20
+
21
+ /** Families historically forced paged by `mlx launch claude`. */
22
+ export const QWEN35_PAGED_MODEL_TYPES = ['qwen3_5', 'qwen3_5_moe'] as const;
23
+
24
+ const QWEN35_CACHE_FLOOR_MODEL_TYPES = new Set<string>(QWEN35_PAGED_MODEL_TYPES);
25
+ const DEFAULT_QWEN35_PAGED_CACHE_MB = 16_384;
26
+ const DEFAULT_QWEN35_PAGED_CACHE_INITIAL_MB = 2_048;
27
+ const QWEN35_MTP_DRAFTER_DIR = 'mtp-drafter';
28
+ const QWEN35_DENSE_NESTED_MTP_SIDECAR = 'mtp/weights.safetensors';
29
+
30
+ export interface PagedConfigOverrideManagerOptions {
31
+ /** Model types to force onto the paged path. Defaults to all agent chat families. */
32
+ modelTypes?: readonly string[];
33
+ /** Temporary-directory prefix. Primarily useful for diagnostics/tests. */
34
+ tempDirPrefix?: string;
35
+ /**
36
+ * Pass Gemma4 checkpoints with an embedded `draft/` through unchanged.
37
+ * This keeps native draft auto-discovery, which currently requires flat KV
38
+ * caches. Defaults to false so a paged override remains a paged contract.
39
+ */
40
+ preserveEmbeddedGemmaDraft?: boolean;
41
+ }
42
+
43
+ /**
44
+ * Owns one isolated set of temporary paged-config overrides.
45
+ *
46
+ * A manager is intentionally single-lifecycle: repeated resolution of one
47
+ * source returns the same override, and `cleanup()` permanently disposes the
48
+ * manager. Separate managers never share directories, so one launch cannot
49
+ * remove another launch's live override.
50
+ */
51
+ export class PagedConfigOverrideManager {
52
+ private readonly modelTypes: ReadonlySet<string>;
53
+ private readonly tempDirPrefix: string;
54
+ private readonly preserveEmbeddedGemmaDraft: boolean;
55
+ private readonly overrides = new Map<string, Promise<string>>();
56
+ private readonly activeResolves = new Set<Promise<string>>();
57
+ private rootPromise: Promise<string> | undefined;
58
+ private cleanupPromise: Promise<void> | undefined;
59
+ private disposed = false;
60
+
61
+ constructor(options: PagedConfigOverrideManagerOptions = {}) {
62
+ this.modelTypes = new Set(options.modelTypes ?? CHAT_FAMILY_IDS);
63
+ this.tempDirPrefix = options.tempDirPrefix ?? 'mlx-paged-overrides-';
64
+ this.preserveEmbeddedGemmaDraft = options.preserveEmbeddedGemmaDraft ?? false;
65
+ }
66
+
67
+ /**
68
+ * Resolve `modelPath` to a paged-aware clone when its model type is managed.
69
+ * A caller-supplied canonical family takes precedence over the raw config
70
+ * type (for example, `gemma4` for a `gemma4_unified` checkpoint).
71
+ * Unmanaged, unreadable, or malformed checkpoints pass through unchanged.
72
+ *
73
+ * `persistPagedCache` is a tri-state cold-tier directive. `undefined` leaves
74
+ * the field untouched (families with no cold-tier opt-in). A boolean is
75
+ * AUTHORITATIVE: it writes `persist_paged_cache: <value>` into the cloned
76
+ * config, overriding whatever the source config.json carries (either alias),
77
+ * and forces a clone whenever the source's value disagrees so the directive
78
+ * actually reaches the loader. Callers gate the boolean to the families whose
79
+ * paged cold restore is sound — the allowlist in
80
+ * `packages/agent/src/cold-tier.ts`, mirrored by `COLD_RESTORE_FAMILIES` in
81
+ * `crates/mlx-core/src/cold_tier.rs`. It is a SET, not one family, and it is
82
+ * deliberately not repeated here: `MlxModelHost` calls this for every
83
+ * allowlisted family, and a second copy of the list would drift.
84
+ */
85
+ async resolve(modelPath: string, canonicalModelType?: string, persistPagedCache?: boolean): Promise<string> {
86
+ if (this.disposed) {
87
+ throw new Error('PagedConfigOverrideManager: resolve() called after cleanup()');
88
+ }
89
+
90
+ const operation = this.resolveInternal(modelPath, canonicalModelType, persistPagedCache);
91
+ this.activeResolves.add(operation);
92
+ try {
93
+ return await operation;
94
+ } finally {
95
+ this.activeResolves.delete(operation);
96
+ }
97
+ }
98
+
99
+ private async resolveInternal(
100
+ modelPath: string,
101
+ canonicalModelType?: string,
102
+ persistPagedCache?: boolean,
103
+ ): Promise<string> {
104
+ const sourcePath = isAbsolute(modelPath) ? modelPath : resolve(modelPath);
105
+ if (extname(sourcePath).toLowerCase() === '.gguf') {
106
+ let modelType = canonicalModelType;
107
+ if (modelType === undefined) {
108
+ try {
109
+ if (ggufArchitecture(sourcePath) === 'muse-glimmer') modelType = 'muse_glimmer';
110
+ } catch {
111
+ return modelPath;
112
+ }
113
+ }
114
+ if (modelType !== 'muse_glimmer' || !this.modelTypes.has(modelType)) return modelPath;
115
+ // A symlink to the GGUF would canonicalize back to the original config.
116
+ // Prepare once, then overlay the packed cache so paging/persistence
117
+ // directives reach the loader and the DFlash sidecar stays attached.
118
+ const prepared = await prepareMuseGlimmerGguf(sourcePath);
119
+ return this.resolveInternal(prepared, modelType, persistPagedCache);
120
+ }
121
+ let config: Record<string, unknown>;
122
+ try {
123
+ config = JSON.parse(await readFile(join(sourcePath, 'config.json'), 'utf-8')) as Record<string, unknown>;
124
+ } catch {
125
+ return modelPath;
126
+ }
127
+
128
+ const rawModelType = typeof config.model_type === 'string' ? config.model_type : null;
129
+ const modelType = canonicalModelType ?? rawModelType;
130
+ if (modelType === null || !this.modelTypes.has(modelType)) {
131
+ return modelPath;
132
+ }
133
+
134
+ // A boolean persist directive is authoritative: force a clone whenever the
135
+ // source config's EFFECTIVE value disagrees, so the directive actually reaches
136
+ // the loader (both `--no-persist-cache` off AND default-on). Mirror the native
137
+ // parser's snake-first precedence — `persist_paged_cache` wins when present,
138
+ // else the camelCase alias — so a config with snake=false AND camel=true does
139
+ // not read as `true` here (an OR) while native reads snake=false, silently
140
+ // dropping an authoritative `true` request.
141
+ const sourcePersist =
142
+ typeof config.persist_paged_cache === 'boolean' ? config.persist_paged_cache : config.persistPagedCache === true;
143
+ const persistOverrideNeeded = persistPagedCache !== undefined && persistPagedCache !== sourcePersist;
144
+
145
+ // Gemma4's DSpark / assistant speculative executor currently owns flat KV
146
+ // caches. Preserve native draft discovery only for explicit callers; the
147
+ // default paged clone intentionally omits subdirectories, hiding `draft/`
148
+ // while keeping the downloaded checkpoint unchanged.
149
+ const hasEmbeddedGemmaDraft = modelType === 'gemma4' && (await isDirectory(join(sourcePath, 'draft')));
150
+ if (this.preserveEmbeddedGemmaDraft && hasEmbeddedGemmaDraft) {
151
+ return modelPath;
152
+ }
153
+
154
+ const cacheFloorMb = QWEN35_CACHE_FLOOR_MODEL_TYPES.has(modelType) ? resolveQwen35CacheFloorMb() : undefined;
155
+ const pagedEnabled = config.use_block_paged_cache === true;
156
+ const configuredMemoryMb = positiveNumber(config.paged_cache_memory_mb);
157
+ const memorySatisfied = cacheFloorMb === undefined || (configuredMemoryMb ?? 0) >= cacheFloorMb;
158
+ // The clone writes `paged_cache_initial_memory_mb = min(initialMb, maxMb)`.
159
+ // A source whose field already equals that value needs no clone; an absent
160
+ // or different field does, or the new start-small default would silently
161
+ // skip every already-paged Qwen3.5 checkpoint (they lack the field).
162
+ const initialMb = QWEN35_CACHE_FLOOR_MODEL_TYPES.has(modelType) ? resolveQwen35InitialMb() : undefined;
163
+ const configuredInitialMb = positiveNumber(config.paged_cache_initial_memory_mb);
164
+ const initialSatisfied =
165
+ initialMb === undefined ||
166
+ configuredInitialMb === Math.min(initialMb, Math.max(configuredMemoryMb ?? 0, cacheFloorMb ?? 0));
167
+ // Even an already-paged Gemma config must be cloned when `draft/` exists:
168
+ // returning the source would expose the draft to native auto-discovery and
169
+ // trigger the flat-speculation/paged-cache conflict. An authoritative
170
+ // persist directive that disagrees with the source likewise blocks the
171
+ // pass-through so the resolved value reaches the loader.
172
+ if (pagedEnabled && memorySatisfied && initialSatisfied && !hasEmbeddedGemmaDraft && !persistOverrideNeeded) {
173
+ return modelPath;
174
+ }
175
+
176
+ // Memoize per (source, resolved family, persist directive): the same
177
+ // checkpoint resolved with a different persist tri-state must yield a distinct
178
+ // clone, not the first one cached under the bare path. `cleanup()` iterates
179
+ // every value, so multiple entries per path are all still disposed.
180
+ const persistKey = persistPagedCache === undefined ? 'u' : persistPagedCache ? 't' : 'f';
181
+ const cacheKey = `${sourcePath}\0${modelType}\0${persistKey}`;
182
+ const existing = this.overrides.get(cacheKey);
183
+ if (existing !== undefined) return existing;
184
+
185
+ const pending = this.createOverride(sourcePath, config, cacheFloorMb, initialMb, modelType, persistPagedCache);
186
+ this.overrides.set(cacheKey, pending);
187
+ try {
188
+ return await pending;
189
+ } catch (error) {
190
+ this.overrides.delete(cacheKey);
191
+ throw error;
192
+ }
193
+ }
194
+
195
+ /** Remove this manager's temporary root without affecting other managers. */
196
+ cleanup(): Promise<void> {
197
+ if (this.cleanupPromise !== undefined) return this.cleanupPromise;
198
+ this.disposed = true;
199
+ this.cleanupPromise = this.performCleanup();
200
+ return this.cleanupPromise;
201
+ }
202
+
203
+ private async performCleanup(): Promise<void> {
204
+ await Promise.allSettled(this.activeResolves);
205
+ this.activeResolves.clear();
206
+ await Promise.allSettled(this.overrides.values());
207
+ this.overrides.clear();
208
+ if (this.rootPromise === undefined) return;
209
+
210
+ const root = await this.rootPromise.catch(() => undefined);
211
+ if (root !== undefined) {
212
+ await rm(root, { recursive: true, force: true }).catch(() => undefined);
213
+ }
214
+ }
215
+
216
+ private async createOverride(
217
+ sourcePath: string,
218
+ sourceConfig: Record<string, unknown>,
219
+ cacheFloorMb: number | undefined,
220
+ initialMb: number | undefined,
221
+ modelType: string,
222
+ persistPagedCache: boolean | undefined,
223
+ ): Promise<string> {
224
+ const root = await this.getRoot();
225
+ const overrideDir = await mkdtemp(join(root, 'model-'));
226
+
227
+ const config: Record<string, unknown> = {
228
+ ...sourceConfig,
229
+ use_block_paged_cache: true,
230
+ };
231
+ if (persistPagedCache !== undefined) {
232
+ // Authoritative: the loader reads snake_case, so that spelling is the one
233
+ // that decides persistence. Reconcile a stray camelCase alias spread from
234
+ // the source config so it can never contradict the authoritative value.
235
+ config.persist_paged_cache = persistPagedCache;
236
+ if ('persistPagedCache' in config) config.persistPagedCache = persistPagedCache;
237
+ }
238
+ if (cacheFloorMb !== undefined) {
239
+ config.paged_cache_memory_mb = Math.max(positiveNumber(sourceConfig.paged_cache_memory_mb) ?? 0, cacheFloorMb);
240
+ }
241
+ if (initialMb !== undefined) {
242
+ // Authoritative initial (grow-on-demand) budget, clamped to the max just
243
+ // written above so it can never exceed the pool ceiling. `initialMb` is
244
+ // always >= 1 and the max is floored by `cacheFloorMb` (>= 1) for the
245
+ // same families, so this never writes 0 — which the native loader rejects.
246
+ const maxMb = positiveNumber(config.paged_cache_memory_mb) ?? 0;
247
+ config.paged_cache_initial_memory_mb = Math.min(initialMb, maxMb);
248
+ }
249
+ await writeFile(join(overrideDir, 'config.json'), JSON.stringify(config, null, 2), 'utf-8');
250
+
251
+ const sourceEntries = await readdir(sourcePath);
252
+ for (const name of sourceEntries) {
253
+ if (name === 'config.json') continue;
254
+ const source = join(sourcePath, name);
255
+ const destination = join(overrideDir, name);
256
+
257
+ let isFile: boolean;
258
+ try {
259
+ isFile = (await stat(source)).isFile();
260
+ } catch {
261
+ continue;
262
+ }
263
+ if (!isFile) continue;
264
+
265
+ try {
266
+ await symlink(source, destination);
267
+ } catch (error) {
268
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
269
+ }
270
+ }
271
+
272
+ if (QWEN35_CACHE_FLOOR_MODEL_TYPES.has(modelType)) {
273
+ await preserveQwen35MtpSidecars(sourcePath, overrideDir, sourceConfig, modelType);
274
+ }
275
+
276
+ return overrideDir;
277
+ }
278
+
279
+ private getRoot(): Promise<string> {
280
+ this.rootPromise ??= mkdtemp(join(tmpdir(), this.tempDirPrefix));
281
+ return this.rootPromise;
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Keep only the nested paths that the native Qwen3.5 loaders inspect.
287
+ *
288
+ * Both dense and MoE loaders accept an mlx-vlm `mtp-drafter/` directory.
289
+ * Dense additionally accepts a config-declared relative sidecar and the
290
+ * conventional `mtp/weights.safetensors` fallback. Top-level sidecar files
291
+ * are already handled by the normal source-file loop above.
292
+ */
293
+ async function preserveQwen35MtpSidecars(
294
+ sourcePath: string,
295
+ overrideDir: string,
296
+ sourceConfig: Record<string, unknown>,
297
+ modelType: string,
298
+ ): Promise<void> {
299
+ await symlinkDirectoryIfPresent(join(sourcePath, QWEN35_MTP_DRAFTER_DIR), join(overrideDir, QWEN35_MTP_DRAFTER_DIR));
300
+
301
+ // The MoE loader supports the split drafter directory, but deliberately has
302
+ // no `mtp.safetensors`-style sidecar discovery path.
303
+ if (modelType !== 'qwen3_5') return;
304
+
305
+ const relativeSidecars = new Set<string>([QWEN35_DENSE_NESTED_MTP_SIDECAR]);
306
+ const configuredSidecar = configuredQwen35MtpFile(sourceConfig);
307
+ if (configuredSidecar !== undefined && !isAbsolute(configuredSidecar)) {
308
+ relativeSidecars.add(configuredSidecar);
309
+ }
310
+
311
+ for (const sidecar of relativeSidecars) {
312
+ await symlinkContainedFileIfPresent(sourcePath, overrideDir, sidecar);
313
+ }
314
+ }
315
+
316
+ function configuredQwen35MtpFile(config: Record<string, unknown>): string | undefined {
317
+ const extra = config.mlx_lm_extra_tensors;
318
+ if (extra === null || typeof extra !== 'object' || Array.isArray(extra)) return undefined;
319
+ const mtpFile = (extra as Record<string, unknown>).mtp_file;
320
+ return typeof mtpFile === 'string' && mtpFile.trim() !== '' ? mtpFile : undefined;
321
+ }
322
+
323
+ async function symlinkContainedFileIfPresent(
324
+ sourceRoot: string,
325
+ destinationRoot: string,
326
+ relativePath: string,
327
+ ): Promise<void> {
328
+ const source = resolve(sourceRoot, relativePath);
329
+ const normalizedRelative = relative(sourceRoot, source);
330
+ if (
331
+ normalizedRelative === '' ||
332
+ normalizedRelative === '..' ||
333
+ normalizedRelative.startsWith(`..${sep}`) ||
334
+ isAbsolute(normalizedRelative)
335
+ ) {
336
+ return;
337
+ }
338
+
339
+ try {
340
+ if (!(await stat(source)).isFile()) return;
341
+ } catch {
342
+ return;
343
+ }
344
+
345
+ const destination = join(destinationRoot, normalizedRelative);
346
+ await mkdir(dirname(destination), { recursive: true });
347
+ await symlinkIfMissing(source, destination);
348
+ }
349
+
350
+ async function symlinkDirectoryIfPresent(source: string, destination: string): Promise<void> {
351
+ if (!(await isDirectory(source))) return;
352
+ await symlinkIfMissing(source, destination);
353
+ }
354
+
355
+ async function symlinkIfMissing(source: string, destination: string): Promise<void> {
356
+ try {
357
+ await symlink(source, destination);
358
+ } catch (error) {
359
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
360
+ }
361
+ }
362
+
363
+ async function isDirectory(path: string): Promise<boolean> {
364
+ try {
365
+ return (await stat(path)).isDirectory();
366
+ } catch {
367
+ return false;
368
+ }
369
+ }
370
+
371
+ function positiveNumber(value: unknown): number | undefined {
372
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
373
+ }
374
+
375
+ function resolveQwen35CacheFloorMb(): number {
376
+ const raw = process.env.MLX_PAGED_CACHE_MEMORY_MB;
377
+ if (raw == null || raw === '') return DEFAULT_QWEN35_PAGED_CACHE_MB;
378
+ const parsed = Number.parseInt(raw, 10);
379
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_QWEN35_PAGED_CACHE_MB;
380
+ }
381
+
382
+ function resolveQwen35InitialMb(): number {
383
+ const raw = process.env.MLX_PAGED_CACHE_INITIAL_MB;
384
+ if (raw == null || raw === '') return DEFAULT_QWEN35_PAGED_CACHE_INITIAL_MB;
385
+ const parsed = Number.parseInt(raw, 10);
386
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_QWEN35_PAGED_CACHE_INITIAL_MB;
387
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Qwen3 Model Configurations and Type Definitions
3
+ *
4
+ * This module provides:
5
+ * - Default configurations for common Qwen3 model sizes
6
+ * - Type re-exports from Rust with enhanced documentation
7
+ * - Helper functions for config management
8
+ */
9
+
10
+ import type {
11
+ Qwen3Config as RustQwen3Config,
12
+ GenerationConfig as RustGenerationConfig,
13
+ GenerationResult as RustGenerationResult,
14
+ } from '@mlx-node/core';
15
+
16
+ /**
17
+ * Configuration for Qwen3 models
18
+ *
19
+ * All fields are required when creating a model directly.
20
+ * Use QWEN3_CONFIGS for pre-configured model sizes.
21
+ */
22
+ export type Qwen3Config = RustQwen3Config;
23
+
24
+ /**
25
+ * Configuration for text generation
26
+ *
27
+ * Controls sampling behavior, temperature, and stopping criteria.
28
+ */
29
+ export type GenerationConfig = RustGenerationConfig;
30
+
31
+ /**
32
+ * Result from text generation with detailed metadata
33
+ *
34
+ * Includes generated tokens, log probabilities, finish reason, and token count.
35
+ */
36
+ export type GenerationResult = RustGenerationResult;
37
+
38
+ /**
39
+ * Default configurations for common Qwen3 models
40
+ *
41
+ * Includes optimized hyperparameters for:
42
+ * - qwen3-0.6b: Smallest model (1024 hidden size, 28 layers)
43
+ * - qwen3-1.8b: Medium model (1536 hidden size, 28 layers)
44
+ * - qwen3-7b: Large model (3072 hidden size, 32 layers)
45
+ */
46
+ export const QWEN3_CONFIGS: { [key: string]: Qwen3Config } = {
47
+ 'qwen3-0.6b': {
48
+ vocabSize: 151936,
49
+ hiddenSize: 1024,
50
+ numLayers: 28,
51
+ numHeads: 16,
52
+ numKvHeads: 8, // GQA with 2:1 ratio
53
+ headDim: 64, // hiddenSize / numHeads = 1024 / 16 = 64
54
+ intermediateSize: 3072,
55
+ rmsNormEps: 1e-6,
56
+ ropeTheta: 1000000.0,
57
+ maxPositionEmbeddings: 40960,
58
+ useQkNorm: true, // Qwen3 always uses QK normalization (core feature)
59
+ tieWordEmbeddings: true,
60
+ padTokenId: 151643,
61
+ eosTokenId: 151645,
62
+ bosTokenId: 151643,
63
+ },
64
+ 'qwen3-1.8b': {
65
+ vocabSize: 151936,
66
+ hiddenSize: 1536,
67
+ numLayers: 28,
68
+ numHeads: 12,
69
+ numKvHeads: 2, // GQA with 6:1 ratio
70
+ headDim: 128, // hiddenSize / numHeads = 1536 / 12 = 128
71
+ intermediateSize: 8960,
72
+ rmsNormEps: 1e-6,
73
+ ropeTheta: 1000000.0,
74
+ maxPositionEmbeddings: 131072,
75
+ useQkNorm: true, // Qwen3 always uses QK normalization (core feature)
76
+ tieWordEmbeddings: false,
77
+ padTokenId: 151643,
78
+ eosTokenId: 151645,
79
+ bosTokenId: 151643,
80
+ },
81
+ 'qwen3-7b': {
82
+ vocabSize: 151936,
83
+ hiddenSize: 3072,
84
+ numLayers: 32,
85
+ numHeads: 24,
86
+ numKvHeads: 4, // GQA with 6:1 ratio
87
+ headDim: 128, // hiddenSize / numHeads = 3072 / 24 = 128
88
+ intermediateSize: 18944,
89
+ rmsNormEps: 1e-6,
90
+ ropeTheta: 1000000.0,
91
+ maxPositionEmbeddings: 131072,
92
+ useQkNorm: true, // Qwen3 always uses QK normalization (core feature)
93
+ tieWordEmbeddings: false,
94
+ padTokenId: 151643,
95
+ eosTokenId: 151645,
96
+ bosTokenId: 151643,
97
+ },
98
+ };
99
+
100
+ /**
101
+ * Get a Qwen3 configuration by name
102
+ *
103
+ * @param name - Model name (e.g., "qwen3-0.6b", "qwen3-1.8b", "qwen3-7b")
104
+ * @returns Model configuration
105
+ * @throws Error if model name is not recognized
106
+ */
107
+ export function getQwen3Config(name: string): Qwen3Config {
108
+ const config = QWEN3_CONFIGS[name];
109
+ if (!config) {
110
+ throw new Error(`Unknown model configuration: ${name}. Available models: ${Object.keys(QWEN3_CONFIGS).join(', ')}`);
111
+ }
112
+ return config;
113
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Qwen3.5 Model Configurations and Type Definitions
3
+ *
4
+ * Supports both dense and MoE variants. MoE fields are optional -
5
+ * when `numExperts` is undefined, the model uses dense MLP layers.
6
+ */
7
+
8
+ import type { Qwen35Config as RustQwen35Config } from '@mlx-node/core';
9
+
10
+ export type Qwen35Config = RustQwen35Config;
11
+
12
+ /**
13
+ * Default configurations for common Qwen3.5 models
14
+ */
15
+ export const QWEN35_CONFIGS: { [key: string]: Qwen35Config } = {
16
+ 'qwen3.5-0.6b': {
17
+ vocabSize: 151936,
18
+ hiddenSize: 1024,
19
+ numLayers: 28,
20
+ numHeads: 16,
21
+ numKvHeads: 8,
22
+ intermediateSize: 3072,
23
+ rmsNormEps: 1e-6,
24
+ headDim: 64,
25
+ tieWordEmbeddings: true,
26
+ attentionBias: false,
27
+ maxPositionEmbeddings: 131072,
28
+ padTokenId: 151643,
29
+ eosTokenId: 151645,
30
+ bosTokenId: 151643,
31
+ linearNumValueHeads: 64,
32
+ linearNumKeyHeads: 16,
33
+ linearKeyHeadDim: 192,
34
+ linearValueHeadDim: 128,
35
+ linearConvKernelDim: 4,
36
+ fullAttentionInterval: 4,
37
+ partialRotaryFactor: 0.25,
38
+ ropeTheta: 100000.0,
39
+ // W1 (MTP): no MTP head in the stock 0.6B dense checkpoint.
40
+ // Real values are populated from `config.json` at load time; this
41
+ // preset is only used by tests that build a `Qwen35Config` from
42
+ // scratch.
43
+ nMtpLayers: 0,
44
+ },
45
+ };
46
+
47
+ /**
48
+ * Get a Qwen3.5 configuration by name
49
+ *
50
+ * @param name - Model name (e.g., "qwen3.5-0.6b")
51
+ * @returns Model configuration
52
+ * @throws Error if model name is not recognized
53
+ */
54
+ export function getQwen35Config(name: string): Qwen35Config {
55
+ const config = QWEN35_CONFIGS[name];
56
+ if (!config) {
57
+ throw new Error(`Unknown Qwen3.5 config: ${name}. Available: ${Object.keys(QWEN35_CONFIGS).join(', ')}`);
58
+ }
59
+ return config;
60
+ }
@@ -0,0 +1,69 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+
3
+ import { setProfilingEnabled, isProfilingEnabled, getProfilingData, resetProfilingData } from '@mlx-node/core';
4
+
5
+ const ENV_VAR = 'MLX_PROFILE_DECODE';
6
+ const envVarSet = !!process.env[ENV_VAR];
7
+ let exitHandlerRegistered = false;
8
+
9
+ // Auto-enable if env var set
10
+ if (envVarSet) {
11
+ setProfilingEnabled(true);
12
+ registerExitHandler();
13
+ }
14
+
15
+ /**
16
+ * Enable profiling programmatically.
17
+ *
18
+ * When enabled, all subsequent model generate/chat calls will record
19
+ * timing, memory, and throughput data. Call `disableProfiling()` to
20
+ * stop recording and write the report.
21
+ *
22
+ * If `MLX_PROFILE_DECODE` env var is set, this is a no-op (env var
23
+ * takes precedence).
24
+ */
25
+ export function enableProfiling(): void {
26
+ if (envVarSet) {
27
+ console.warn(`Warning: env var ${ENV_VAR} is set, ignoring explicit profiling API calls`);
28
+ return;
29
+ }
30
+ setProfilingEnabled(true);
31
+ resetProfilingData();
32
+ registerExitHandler();
33
+ }
34
+
35
+ /**
36
+ * Disable profiling and write the collected data to a JSON file.
37
+ *
38
+ * Returns the path to the written file, or empty string if no data
39
+ * was collected. If `MLX_PROFILE_DECODE` env var is set, this is a
40
+ * no-op (env var controls the lifecycle).
41
+ */
42
+ export async function disableProfiling(): Promise<string> {
43
+ if (envVarSet) {
44
+ console.warn(`Warning: env var ${ENV_VAR} is set, ignoring explicit profiling API calls`);
45
+ return '';
46
+ }
47
+ setProfilingEnabled(false);
48
+ return writeProfilingReport();
49
+ }
50
+
51
+ async function writeProfilingReport(): Promise<string> {
52
+ const data = getProfilingData();
53
+ if (data.generations.length === 0) return '';
54
+ const path = `mlx-profile-${Date.now()}.json`;
55
+ await writeFile(path, JSON.stringify(data, null, 2));
56
+ console.info(`Profiling report written to ${path}`);
57
+ return path;
58
+ }
59
+
60
+ function registerExitHandler(): void {
61
+ if (exitHandlerRegistered) return;
62
+ exitHandlerRegistered = true;
63
+ process.on('beforeExit', async () => {
64
+ if (isProfilingEnabled()) {
65
+ setProfilingEnabled(false);
66
+ await writeProfilingReport();
67
+ }
68
+ });
69
+ }