@mlx-node/lm 0.0.13 → 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,74 @@
1
+ /** Native-free metadata and discovery for optional speculative draft checkpoints. */
2
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
3
+ import { basename, dirname, join } from 'node:path';
4
+
5
+ export const QWEN38_DFLASH2 = {
6
+ label: 'DFlash2',
7
+ hfRepo: 'z-lab/Qwen3.8-27B-DFlash2',
8
+ sizeGb: 3.85,
9
+ } as const;
10
+
11
+ function regularFile(path: string): boolean {
12
+ try {
13
+ const stat = statSync(path);
14
+ return stat.isFile() && stat.size > 0;
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ /** Draft-only directories must never appear in a chat-model picker. */
21
+ export function isDFlash2DraftDirectory(path: string): boolean {
22
+ const configPath = join(path, 'config.json');
23
+ if (!regularFile(configPath)) return false;
24
+ try {
25
+ const config = JSON.parse(readFileSync(configPath, 'utf8')) as { architectures?: unknown } | null;
26
+ return Array.isArray(config?.architectures) && config.architectures.includes('DFlash2DraftModel');
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ /** Cheap completeness check; the native loader validates tensor shapes and target compatibility. */
33
+ export function isDFlash2Companion(path: string): boolean {
34
+ return isDFlash2DraftDirectory(path) && regularFile(join(path, 'model.safetensors'));
35
+ }
36
+
37
+ /**
38
+ * Prefer a target's explicit `draft/`, then the shared Qwen3.8-27B companion in
39
+ * its models directory. Shared pairing requires the Qwen3.8-27B target name;
40
+ * Qwen3.5, MoE models and unrelated renamed checkpoints must not acquire it.
41
+ */
42
+ export function findDFlash2Draft(modelPath: string, modelType: string, modelsDir?: string): string | undefined {
43
+ if (modelType !== 'qwen3_5') return undefined;
44
+ const isGguf = modelPath.toLowerCase().endsWith('.gguf');
45
+ const modelDir = isGguf ? dirname(modelPath) : modelPath;
46
+ const embedded = join(modelDir, 'draft');
47
+ if (isDFlash2Companion(embedded)) return embedded;
48
+
49
+ const targetName = /(?:^|[-_.])qwen3[._-]8[-_.]27b(?:[-_.]|$)/i;
50
+ if (!targetName.test(basename(modelPath)) && !(isGguf && targetName.test(basename(modelDir)))) return undefined;
51
+ const slug = QWEN38_DFLASH2.hfRepo.split('/')[1].toLowerCase();
52
+ const roots = modelsDir === undefined ? [isGguf ? modelDir : dirname(modelDir)] : [modelsDir];
53
+ // A sibling config identifies a downloaded model repository. Its parent
54
+ // can hold the shared companion; a standalone GGUF's containing directory
55
+ // is already the model store, so do not search above it. An explicit store
56
+ // always takes precedence over this repository inference.
57
+ if (modelsDir === undefined && isGguf && regularFile(join(modelDir, 'config.json'))) {
58
+ roots.push(dirname(modelDir));
59
+ }
60
+ for (const root of roots) {
61
+ try {
62
+ const candidates = readdirSync(root, { withFileTypes: true })
63
+ .filter((entry) => entry.isDirectory() && entry.name.toLowerCase() === slug)
64
+ .sort((a, b) => a.name.localeCompare(b.name));
65
+ for (const candidate of candidates) {
66
+ const path = join(root, candidate.name);
67
+ if (isDFlash2Companion(path)) return path;
68
+ }
69
+ } catch {
70
+ // An absent/unreadable companion leaves ordinary target loading available.
71
+ }
72
+ }
73
+ return undefined;
74
+ }
@@ -0,0 +1,542 @@
1
+ /**
2
+ * Native-free per-family registration rows plus the pure `matchFamily`
3
+ * detection they drive.
4
+ *
5
+ * This module must stay free of runtime imports (`import type` only): it is
6
+ * re-exported through the native-free `@mlx-node/agent/catalog` subpath to the
7
+ * dashboard viewer process, which must never dlopen the Metal addon.
8
+ * `packages/agent/__test__/catalog-native-free.test.ts` gates that contract.
9
+ */
10
+
11
+ import type { ChatConfig } from '@mlx-node/core';
12
+
13
+ export type ModelFamilyKind = 'trainable' | 'loadable' | 'embedding' | 'vlm';
14
+
15
+ export interface NormalizedModelConfig {
16
+ readonly usesDefaultModelType: boolean;
17
+ readonly rawModelType: string | undefined;
18
+ readonly rawModelTypeLabel: string;
19
+ readonly architectures: ReadonlySet<string>;
20
+ }
21
+
22
+ export interface ModelConfigMatchContext extends NormalizedModelConfig {
23
+ readonly modelType: string | undefined;
24
+ }
25
+
26
+ export interface ModelConfigMatcher {
27
+ /** Exact raw `config.json` model_type values owned by this family. */
28
+ readonly rawModelTypes: readonly string[];
29
+ /** Optional higher-priority architecture probe for shared or absent model_type values. */
30
+ readonly architectureProbe?: (config: ModelConfigMatchContext) => boolean;
31
+ }
32
+
33
+ /**
34
+ * Structural mirror of pi's `ProviderModelConfig['thinkingLevelMap']`. Declared
35
+ * here because pi types are agent-only; `packages/agent/src/provider/models.ts`
36
+ * pins assignability with a `satisfies` check at its use site.
37
+ */
38
+ export interface FamilyThinkingLevelMap {
39
+ readonly off?: string | null;
40
+ readonly minimal?: string | null;
41
+ readonly low?: string | null;
42
+ readonly medium?: string | null;
43
+ readonly high?: string | null;
44
+ readonly xhigh?: string | null;
45
+ readonly max?: string | null;
46
+ }
47
+
48
+ export interface FamilyTraits {
49
+ /**
50
+ * Whether the family emits `<think>` reasoning (drives pi's thinking
51
+ * levels). Gemma4 routes its `<|channel>thought` protocol through its
52
+ * family stream parser rather than the generic `<think>` tracker, but the
53
+ * user-facing level still controls the prompt's `<|think|>` capability.
54
+ */
55
+ readonly reasoning: boolean;
56
+ /**
57
+ * Optional family-specific projection of Pi's thinking controls. Gemma4's
58
+ * prompt protocol has two modes rather than four distinct effort levels:
59
+ * minimal disables `<|think|>` and high enables it.
60
+ */
61
+ readonly thinkingLevelMap?: FamilyThinkingLevelMap;
62
+ /**
63
+ * Context-window fallback when `config.json` carries no
64
+ * `max_position_embeddings` at either nesting level.
65
+ *
66
+ * A family's VOCAB size is never the right value here — the two are
67
+ * unrelated numbers that happen to collide on some checkpoints
68
+ * (nemotron_h is 131072 vocab / 1048576 context).
69
+ */
70
+ readonly fallbackContextWindow: number;
71
+ }
72
+
73
+ /**
74
+ * Sampling defaults from Unsloth's Qwen3.6 guide:
75
+ * https://unsloth.ai/docs/models/qwen3.6#recommended-settings
76
+ *
77
+ * All modes pin `top_k = 20` and `min_p = 0.0`; they differ in
78
+ * `temperature`, `top_p`, and `presence_penalty`.
79
+ *
80
+ * Deliberately no `maxConsecutiveTokens` / `maxNgramRepeats` / `ngramSize`: the
81
+ * native anti-repetition cutoff is off by default (vLLM-aligned), and a client
82
+ * can still opt in per request.
83
+ */
84
+ export const QWEN_SAMPLING_DEFAULTS = {
85
+ /** Thinking mode for precise coding tasks. */
86
+ thinkingCoding: {
87
+ temperature: 0.6,
88
+ topP: 0.95,
89
+ topK: 20,
90
+ minP: 0.0,
91
+ presencePenalty: 0.0,
92
+ repetitionPenalty: 1.0,
93
+ } satisfies ChatConfig,
94
+
95
+ /** Thinking mode for general tasks. */
96
+ thinkingGeneral: {
97
+ temperature: 1.0,
98
+ topP: 0.95,
99
+ topK: 20,
100
+ minP: 0.0,
101
+ presencePenalty: 1.5,
102
+ repetitionPenalty: 1.0,
103
+ } satisfies ChatConfig,
104
+
105
+ /** Instruct (non-thinking) for general tasks. */
106
+ instructGeneral: {
107
+ temperature: 0.7,
108
+ topP: 0.8,
109
+ topK: 20,
110
+ minP: 0.0,
111
+ presencePenalty: 1.5,
112
+ repetitionPenalty: 1.0,
113
+ } satisfies ChatConfig,
114
+
115
+ /** Instruct (non-thinking) for reasoning tasks. */
116
+ instructReasoning: {
117
+ temperature: 1.0,
118
+ topP: 0.95,
119
+ topK: 20,
120
+ minP: 0.0,
121
+ presencePenalty: 1.5,
122
+ repetitionPenalty: 1.0,
123
+ } satisfies ChatConfig,
124
+ } as const;
125
+
126
+ /** Sampling defaults for Gemma4 Instruct. */
127
+ export const GEMMA4_SAMPLING_DEFAULTS: ChatConfig = {
128
+ temperature: 0.7,
129
+ topP: 0.95,
130
+ topK: 64,
131
+ minP: 0.0,
132
+ presencePenalty: 0.0,
133
+ repetitionPenalty: 1.0,
134
+ };
135
+
136
+ /** Sampling defaults from Meta's Muse-Glimmer release recipe. */
137
+ export const MUSE_GLIMMER_SAMPLING_DEFAULTS: ChatConfig = {
138
+ temperature: 0.6,
139
+ topP: 0.95,
140
+ topK: 20,
141
+ minP: 0.0,
142
+ presencePenalty: 0.0,
143
+ repetitionPenalty: 1.0,
144
+ };
145
+
146
+ /** Sampling defaults from NVIDIA's Nemotron 3.5 Lightning release recipe. */
147
+ export const NEMOTRON_SAMPLING_DEFAULTS: ChatConfig = {
148
+ temperature: 1.0,
149
+ topP: 0.95,
150
+ topK: 20,
151
+ minP: 0.0,
152
+ presencePenalty: 0.0,
153
+ repetitionPenalty: 1.0,
154
+ };
155
+
156
+ /** Sampling defaults for LFM2.5 Thinking. */
157
+ export const LFM2_SAMPLING_DEFAULTS: ChatConfig = {
158
+ temperature: 0.05,
159
+ topP: 1.0,
160
+ topK: 50,
161
+ minP: 0.0,
162
+ presencePenalty: 0.0,
163
+ repetitionPenalty: 1.05,
164
+ };
165
+
166
+ /**
167
+ * Sampling defaults + per-model output cap. A per-request client value still
168
+ * wins: `ChatSession.mergeConfig` treats per-call config as an overlay on top
169
+ * of `defaultConfig`.
170
+ */
171
+ export interface LaunchPreset {
172
+ sampling: ChatConfig;
173
+ maxOutputTokens: number;
174
+ }
175
+
176
+ interface ModelFamilyDataBase {
177
+ /** Canonical `ModelType` id. */
178
+ readonly id: string;
179
+ readonly match: ModelConfigMatcher;
180
+ /** Backward-compatible fallback when config.json omits model_type or sets it to null. */
181
+ readonly defaultForNullishModelType?: true;
182
+ readonly acceptsDraftModel?: true;
183
+ /**
184
+ * GGUF `general.architecture` values whose native `load(path)` accepts a
185
+ * direct GGUF file. Qwen3.5-MoE currently consumes converted directories,
186
+ * not direct files, so it carries none.
187
+ */
188
+ readonly ggufArchitectures?: readonly string[];
189
+ }
190
+
191
+ /**
192
+ * A chat-capable family MUST declare `traits` and a `launchPreset` — the
193
+ * compile-time completeness gate. One preset serves every surface:
194
+ * `@mlx-node/server` discovery, `mlx launch claude` and `mlx agent`. A chat
195
+ * family is therefore reachable from all of them or from none, never from one
196
+ * and not another.
197
+ */
198
+ interface ChatFamilyData extends ModelFamilyDataBase {
199
+ readonly kind: 'trainable' | 'loadable';
200
+ readonly traits: FamilyTraits;
201
+ readonly launchPreset: LaunchPreset;
202
+ }
203
+
204
+ interface NonGenerativeFamilyData extends ModelFamilyDataBase {
205
+ readonly kind: 'embedding' | 'vlm';
206
+ readonly traits?: undefined;
207
+ readonly launchPreset?: undefined;
208
+ }
209
+
210
+ export type ModelFamilyData = ChatFamilyData | NonGenerativeFamilyData;
211
+
212
+ /**
213
+ * Ordered source of truth for every supported model family's registration
214
+ * data. Each entry owns its canonical `ModelType`, raw config aliases /
215
+ * architecture probes, and `ChatSession` eligibility via `kind`:
216
+ *
217
+ * - `'trainable'` — GRPO/SFT-capable LM (Qwen3 family); chat-capable.
218
+ * - `'loadable'` — chat-capable LM with no trainer engine (Gemma4, LFM2).
219
+ * - `'embedding'` — no chat surface (Harrier); rejected by `loadSession`.
220
+ * - `'vlm'` — VLM whose AsyncGenerator wrapper lives in
221
+ * `@mlx-node/vlm` (importing it here would create a
222
+ * circular package dependency), so `loadSession`
223
+ * rejects it and routes callers to `@mlx-node/vlm`.
224
+ *
225
+ * ORDER IS LOAD-BEARING: a base family is selected from an explicit alias or
226
+ * the single declarative nullish-model_type default, then architecture probes
227
+ * refine it in declaration order. Gemma's unified architecture is
228
+ * authoritative (matching the native loader); Harrier refines a Qwen3 base.
229
+ * Adding a family means adding one data row here plus one loader binding in
230
+ * `models/model-loader.ts`; the row type and the family-completeness test
231
+ * enumerate everything else.
232
+ */
233
+ export const MODEL_FAMILY_DATA = [
234
+ {
235
+ id: 'gemma4',
236
+ kind: 'loadable',
237
+ ggufArchitectures: ['gemma4'],
238
+ match: {
239
+ rawModelTypes: ['gemma4', 'gemma4_text', 'gemma4_unified'],
240
+ architectureProbe: ({ architectures }) => architectures.has('Gemma4UnifiedForConditionalGeneration'),
241
+ },
242
+ acceptsDraftModel: true,
243
+ traits: {
244
+ reasoning: true,
245
+ thinkingLevelMap: {
246
+ minimal: 'minimal',
247
+ low: null,
248
+ medium: null,
249
+ high: 'high',
250
+ },
251
+ fallbackContextWindow: 131072,
252
+ },
253
+ launchPreset: {
254
+ sampling: GEMMA4_SAMPLING_DEFAULTS,
255
+ maxOutputTokens: 16384,
256
+ },
257
+ },
258
+ {
259
+ id: 'muse_glimmer',
260
+ kind: 'loadable',
261
+ ggufArchitectures: ['muse-glimmer'],
262
+ match: {
263
+ rawModelTypes: ['muse_glimmer', 'muse_glimmer_text'],
264
+ architectureProbe: ({ architectures }) => architectures.has('MuseGlimmerForConditionalGeneration'),
265
+ },
266
+ traits: {
267
+ reasoning: true,
268
+ fallbackContextWindow: 131072,
269
+ },
270
+ launchPreset: {
271
+ sampling: MUSE_GLIMMER_SAMPLING_DEFAULTS,
272
+ maxOutputTokens: 16384,
273
+ },
274
+ },
275
+ {
276
+ id: 'harrier',
277
+ kind: 'embedding',
278
+ match: {
279
+ rawModelTypes: ['harrier'],
280
+ architectureProbe: ({ modelType, architectures }) =>
281
+ modelType === 'qwen3' && architectures.has('Qwen3Model') && !architectures.has('Qwen3ForCausalLM'),
282
+ },
283
+ },
284
+ {
285
+ id: 'qwen3',
286
+ kind: 'trainable',
287
+ match: { rawModelTypes: ['qwen3'] },
288
+ defaultForNullishModelType: true,
289
+ traits: { reasoning: true, fallbackContextWindow: 40960 },
290
+ launchPreset: {
291
+ sampling: QWEN_SAMPLING_DEFAULTS.thinkingCoding,
292
+ maxOutputTokens: 38912,
293
+ },
294
+ },
295
+ {
296
+ id: 'qwen3_5',
297
+ kind: 'trainable',
298
+ match: { rawModelTypes: ['qwen3_5'] },
299
+ acceptsDraftModel: true,
300
+ ggufArchitectures: ['qwen35'],
301
+ traits: { reasoning: true, fallbackContextWindow: 262144 },
302
+ launchPreset: {
303
+ sampling: QWEN_SAMPLING_DEFAULTS.thinkingCoding,
304
+ maxOutputTokens: 81920,
305
+ },
306
+ },
307
+ {
308
+ id: 'qwen3_5_moe',
309
+ kind: 'trainable',
310
+ match: { rawModelTypes: ['qwen3_5_moe'] },
311
+ traits: { reasoning: true, fallbackContextWindow: 262144 },
312
+ launchPreset: {
313
+ sampling: QWEN_SAMPLING_DEFAULTS.thinkingCoding,
314
+ maxOutputTokens: 81920,
315
+ },
316
+ },
317
+ {
318
+ id: 'lfm2',
319
+ kind: 'loadable',
320
+ match: { rawModelTypes: ['lfm2'] },
321
+ traits: { reasoning: true, fallbackContextWindow: 128000 },
322
+ launchPreset: {
323
+ sampling: LFM2_SAMPLING_DEFAULTS,
324
+ maxOutputTokens: 8192,
325
+ },
326
+ },
327
+ {
328
+ id: 'lfm2_moe',
329
+ kind: 'loadable',
330
+ match: { rawModelTypes: ['lfm2_moe'] },
331
+ traits: { reasoning: true, fallbackContextWindow: 128000 },
332
+ /**
333
+ * LFM2.5-8B-A1B: LiquidAI's MoE card recommends temperature 0.2 / top_k 80
334
+ * — deliberately NOT the dense `lfm2` values (0.05 / 50).
335
+ */
336
+ launchPreset: {
337
+ sampling: {
338
+ temperature: 0.2,
339
+ topP: 1.0,
340
+ topK: 80,
341
+ minP: 0.0,
342
+ presencePenalty: 0.0,
343
+ repetitionPenalty: 1.05,
344
+ },
345
+ maxOutputTokens: 8192,
346
+ },
347
+ },
348
+ {
349
+ id: 'nemotron_h',
350
+ kind: 'loadable',
351
+ match: {
352
+ rawModelTypes: ['nemotron_h'],
353
+ architectureProbe: ({ architectures }) => architectures.has('NemotronHForCausalLM'),
354
+ },
355
+ traits: {
356
+ reasoning: true,
357
+ fallbackContextWindow: 1048576,
358
+ },
359
+ launchPreset: {
360
+ sampling: NEMOTRON_SAMPLING_DEFAULTS,
361
+ maxOutputTokens: 32768,
362
+ },
363
+ },
364
+ {
365
+ id: 'internvl_chat',
366
+ kind: 'vlm',
367
+ match: { rawModelTypes: ['internvl_chat'] },
368
+ },
369
+ {
370
+ id: 'qianfan-ocr',
371
+ kind: 'vlm',
372
+ match: { rawModelTypes: ['qianfan-ocr'] },
373
+ },
374
+ ] as const satisfies readonly ModelFamilyData[];
375
+
376
+ type FamilyDataRow = (typeof MODEL_FAMILY_DATA)[number];
377
+
378
+ export type ModelType = FamilyDataRow['id'];
379
+
380
+ type ChatFamilyRow = Extract<FamilyDataRow, { readonly kind: 'trainable' | 'loadable' }>;
381
+
382
+ export type ChatFamilyId = ChatFamilyRow['id'];
383
+
384
+ export type TrainableFamilyId = Extract<FamilyDataRow, { readonly kind: 'trainable' }>['id'];
385
+
386
+ /**
387
+ * Every chat-capable family (kind trainable | loadable), in registry order —
388
+ * the default set the paged-config override manager forces onto the block-paged
389
+ * path. Derived, so a new chat family can never be forgotten.
390
+ */
391
+ export const CHAT_FAMILY_IDS: readonly ChatFamilyId[] = MODEL_FAMILY_DATA.filter(
392
+ (row): row is ChatFamilyRow => row.kind === 'trainable' || row.kind === 'loadable',
393
+ ).map((row) => row.id);
394
+
395
+ /** Detection results that cannot back a chat endpoint (kind embedding | vlm). */
396
+ export const NON_GENERATIVE_FAMILY_IDS: ReadonlySet<ModelType> = new Set<ModelType>(
397
+ MODEL_FAMILY_DATA.filter((row) => row.kind === 'embedding' || row.kind === 'vlm').map((row) => row.id),
398
+ );
399
+
400
+ interface FamilyDataIndex {
401
+ readonly byId: ReadonlyMap<string, ModelFamilyData>;
402
+ readonly byRawModelType: ReadonlyMap<string, ModelFamilyData>;
403
+ readonly defaultForNullishModelType: ModelFamilyData;
404
+ }
405
+
406
+ function buildFamilyDataIndex(rows: readonly ModelFamilyData[]): FamilyDataIndex {
407
+ const byId = new Map<string, ModelFamilyData>();
408
+ const byRawModelType = new Map<string, ModelFamilyData>();
409
+ let defaultForNullishModelType: ModelFamilyData | undefined;
410
+
411
+ for (const family of rows) {
412
+ const previousFamily = byId.get(family.id);
413
+ if (previousFamily !== undefined) {
414
+ throw new Error(`Duplicate canonical model type "${family.id}" in model family registry`);
415
+ }
416
+ byId.set(family.id, family);
417
+
418
+ for (const rawModelType of family.match.rawModelTypes) {
419
+ const previous = byRawModelType.get(rawModelType);
420
+ if (previous !== undefined) {
421
+ throw new Error(`Duplicate model_type alias "${rawModelType}" for "${previous.id}" and "${family.id}"`);
422
+ }
423
+ byRawModelType.set(rawModelType, family);
424
+ }
425
+
426
+ if (family.defaultForNullishModelType === true) {
427
+ if (defaultForNullishModelType !== undefined) {
428
+ throw new Error(
429
+ `Duplicate nullish-model_type defaults for "${defaultForNullishModelType.id}" and "${family.id}"`,
430
+ );
431
+ }
432
+ defaultForNullishModelType = family;
433
+ }
434
+ }
435
+
436
+ if (defaultForNullishModelType === undefined) {
437
+ throw new Error('Model family registry must declare exactly one nullish-model_type default');
438
+ }
439
+ return { byId, byRawModelType, defaultForNullishModelType };
440
+ }
441
+
442
+ const FAMILY_DATA_INDEX = buildFamilyDataIndex(MODEL_FAMILY_DATA);
443
+
444
+ /** Registration data for a canonical family id, or `undefined` for a foreign string. */
445
+ export function familyDataFor(modelType: string): ModelFamilyData | undefined {
446
+ return FAMILY_DATA_INDEX.byId.get(modelType);
447
+ }
448
+
449
+ function chatFamilyDataFor(modelType: string): ChatFamilyData | undefined {
450
+ const row = FAMILY_DATA_INDEX.byId.get(modelType);
451
+ return row !== undefined && (row.kind === 'trainable' || row.kind === 'loadable') ? row : undefined;
452
+ }
453
+
454
+ /** Canonical family id owning a raw `config.json` model_type alias. */
455
+ export function rawModelTypeToCanonical(rawModelType: string): ModelType | undefined {
456
+ return FAMILY_DATA_INDEX.byRawModelType.get(rawModelType)?.id as ModelType | undefined;
457
+ }
458
+
459
+ /** Agent discovery traits for a chat-capable family. */
460
+ export function familyTraitsFor(modelType: string): FamilyTraits | undefined {
461
+ return chatFamilyDataFor(modelType)?.traits;
462
+ }
463
+
464
+ /**
465
+ * Launch preset for every surface that serves a chat family:
466
+ * `@mlx-node/server` discovery, `mlx launch claude` and `mlx agent`.
467
+ * `undefined` only for a non-generative family or an unknown type.
468
+ */
469
+ export function launchPresetFor(modelType: string): LaunchPreset | undefined {
470
+ return chatFamilyDataFor(modelType)?.launchPreset;
471
+ }
472
+
473
+ export class MalformedModelConfigError extends Error {
474
+ constructor(modelPath: string, reason: string) {
475
+ super(`Malformed config.json in ${modelPath}: ${reason}`);
476
+ this.name = 'MalformedModelConfigError';
477
+ }
478
+ }
479
+
480
+ export class UnsupportedModelTypeError extends Error {
481
+ constructor(modelPath: string, rawModelTypeLabel: string) {
482
+ super(`Unsupported model_type "${rawModelTypeLabel}" in ${modelPath}/config.json`);
483
+ this.name = 'UnsupportedModelTypeError';
484
+ }
485
+ }
486
+
487
+ /**
488
+ * Fail-closed validation: a config.json whose root is not a plain object,
489
+ * or whose `architectures` is neither an array nor a string, is rejected
490
+ * instead of coerced (coercion would fall through to the qwen3
491
+ * nullish-model_type default and silently misroute the checkpoint).
492
+ */
493
+ function normalizeConfig(modelPath: string, config: unknown): NormalizedModelConfig {
494
+ if (typeof config !== 'object' || config === null || Array.isArray(config)) {
495
+ throw new MalformedModelConfigError(modelPath, 'root must be a JSON object');
496
+ }
497
+ const object = config as Record<string, unknown>;
498
+ const hasModelType = Object.hasOwn(object, 'model_type');
499
+ const rawModelTypeValue = hasModelType ? object.model_type : undefined;
500
+ const usesDefaultModelType = !hasModelType || rawModelTypeValue === null;
501
+ const rawModelType = typeof rawModelTypeValue === 'string' ? rawModelTypeValue : undefined;
502
+ const rawModelTypeLabel = hasModelType ? String(rawModelTypeValue) : '<missing>';
503
+ const rawArchitectures = 'architectures' in object ? object.architectures : undefined;
504
+ if (
505
+ rawArchitectures !== undefined &&
506
+ rawArchitectures !== null &&
507
+ !Array.isArray(rawArchitectures) &&
508
+ typeof rawArchitectures !== 'string'
509
+ ) {
510
+ throw new MalformedModelConfigError(modelPath, '"architectures" must be an array or a string');
511
+ }
512
+ const architectures = Array.isArray(rawArchitectures)
513
+ ? rawArchitectures.filter((architecture): architecture is string => typeof architecture === 'string')
514
+ : typeof rawArchitectures === 'string'
515
+ ? [rawArchitectures]
516
+ : [];
517
+
518
+ return { usesDefaultModelType, rawModelType, rawModelTypeLabel, architectures: new Set(architectures) };
519
+ }
520
+
521
+ /**
522
+ * Pure family detection over a parsed `config.json`: alias (or the qwen3
523
+ * nullish default) picks a base family, then architecture probes refine it in
524
+ * registry declaration order. Throws {@link MalformedModelConfigError} /
525
+ * {@link UnsupportedModelTypeError} with `modelPath` naming the checkpoint.
526
+ * The filesystem/GGUF half lives in `detectModelType`
527
+ * (`models/model-loader.ts`); native-free consumers (dashboard labels via
528
+ * `@mlx-node/agent/catalog`) call this directly.
529
+ */
530
+ export function matchFamily(modelPath: string, parsedConfig: unknown): ModelType {
531
+ const config = normalizeConfig(modelPath, parsedConfig);
532
+ const rows: readonly ModelFamilyData[] = MODEL_FAMILY_DATA;
533
+ const baseFamily = config.usesDefaultModelType
534
+ ? FAMILY_DATA_INDEX.defaultForNullishModelType
535
+ : config.rawModelType === undefined
536
+ ? undefined
537
+ : FAMILY_DATA_INDEX.byRawModelType.get(config.rawModelType);
538
+ const matchContext: ModelConfigMatchContext = { ...config, modelType: baseFamily?.id };
539
+ const family = rows.find((candidate) => candidate.match.architectureProbe?.(matchContext) === true) ?? baseFamily;
540
+ if (family === undefined) throw new UnsupportedModelTypeError(modelPath, config.rawModelTypeLabel);
541
+ return family.id as ModelType;
542
+ }