@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,117 @@
1
+ /** Read GGUF metadata without linking the native addon or loading tensor weights. */
2
+ import { closeSync, constants, fstatSync, openSync, readSync } from 'node:fs';
3
+
4
+ const MAX_GGUF_LENGTH = 256 * 1024 * 1024;
5
+ const SCALAR_BYTES = new Map([
6
+ [0, 1],
7
+ [1, 1],
8
+ [2, 2],
9
+ [3, 2],
10
+ [4, 4],
11
+ [5, 4],
12
+ [6, 4],
13
+ [7, 1],
14
+ [10, 8],
15
+ [11, 8],
16
+ [12, 8],
17
+ ]);
18
+
19
+ /** Buffered header cursor; skipping an array never allocates its payload. */
20
+ class HeaderReader {
21
+ private readonly buffer = Buffer.alloc(64 * 1024);
22
+ private start = 0;
23
+ private end = 0;
24
+ private position = 0;
25
+
26
+ constructor(
27
+ private readonly fd: number,
28
+ private readonly size: number,
29
+ ) {}
30
+
31
+ skip(length: number): void {
32
+ if (!Number.isSafeInteger(length) || length < 0 || length > this.size - this.position) {
33
+ throw new Error('Truncated or oversized GGUF metadata');
34
+ }
35
+ this.position += length;
36
+ }
37
+
38
+ bytes(length: number): Buffer {
39
+ const position = this.position;
40
+ this.skip(length);
41
+ if (length > MAX_GGUF_LENGTH) throw new Error('GGUF string exceeds maximum length');
42
+ const result = Buffer.alloc(length);
43
+ let offset = 0;
44
+ while (offset < length) {
45
+ const current = position + offset;
46
+ if (current < this.start || current >= this.end) {
47
+ this.start = current;
48
+ this.end = current + readSync(this.fd, this.buffer, 0, this.buffer.length, current);
49
+ if (this.end === current) throw new Error('Truncated GGUF metadata');
50
+ }
51
+ const count = Math.min(length - offset, this.end - current);
52
+ this.buffer.copy(result, offset, current - this.start, current - this.start + count);
53
+ offset += count;
54
+ }
55
+ return result;
56
+ }
57
+
58
+ u32(): number {
59
+ return this.bytes(4).readUInt32LE();
60
+ }
61
+ u64(): number {
62
+ const value = this.bytes(8).readBigUInt64LE();
63
+ if (value > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('Oversized GGUF metadata length');
64
+ return Number(value);
65
+ }
66
+ length(): number {
67
+ const length = this.u64();
68
+ if (length > MAX_GGUF_LENGTH) throw new Error('GGUF metadata exceeds maximum length');
69
+ return length;
70
+ }
71
+ string(): string {
72
+ return new TextDecoder('utf-8', { fatal: true }).decode(this.bytes(this.length()));
73
+ }
74
+
75
+ skipValue(type: number, depth = 0): void {
76
+ const size = SCALAR_BYTES.get(type);
77
+ if (size !== undefined) return this.skip(size);
78
+ if (type === 8) return this.skip(this.length());
79
+ if (type !== 9 || depth > 16) throw new Error('Unsupported GGUF metadata type');
80
+ const element = this.u32();
81
+ const count = this.length();
82
+ const elementSize = SCALAR_BYTES.get(element);
83
+ if (elementSize !== undefined) return this.skip(count * elementSize);
84
+ if (element !== 8 && element !== 9) throw new Error('Unsupported GGUF array element type');
85
+ for (let index = 0; index < count; index++) this.skipValue(element, depth + 1);
86
+ }
87
+ }
88
+
89
+ export function readGgufArchitecture(path: string): string {
90
+ // O_NONBLOCK plus the descriptor type check prevents a renamed FIFO from
91
+ // hanging model discovery in the control-panel worker.
92
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK);
93
+ try {
94
+ const info = fstatSync(fd);
95
+ if (!info.isFile()) throw new Error('GGUF path must be a regular file');
96
+ const reader = new HeaderReader(fd, info.size);
97
+ if (reader.bytes(4).toString() !== 'GGUF') throw new Error('Not a GGUF file');
98
+ if (reader.u32() < 3) throw new Error('Unsupported GGUF version (only v3+ supported)');
99
+ reader.u64(); // Tensor count: discovery only needs the metadata section.
100
+ const count = reader.u64();
101
+ let architecture: string | undefined;
102
+ for (let index = 0; index < count; index++) {
103
+ const key = reader.string();
104
+ const type = reader.u32();
105
+ if (key === 'general.architecture') {
106
+ architecture = type === 8 ? reader.string() : undefined;
107
+ if (type !== 8) reader.skipValue(type);
108
+ } else {
109
+ reader.skipValue(type);
110
+ }
111
+ }
112
+ if (!architecture) throw new Error('GGUF does not declare a non-empty general.architecture');
113
+ return architecture;
114
+ } finally {
115
+ closeSync(fd);
116
+ }
117
+ }
package/src/index.ts ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * @mlx-node/lm - High-level inference API for MLX models
3
+ *
4
+ * This package provides everything needed for model loading and inference,
5
+ * aligned with Python's mlx-lm library.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { loadModel, Qwen3Model } from '@mlx-node/lm';
10
+ *
11
+ * const model = await loadModel('./models/qwen3-0.6b');
12
+ * const result = await model.generate([{ role: 'user', content: 'Hello!' }]);
13
+ * ```
14
+ */
15
+
16
+ // Model classes (for inference)
17
+ export { Qwen3Model } from './stream.js';
18
+
19
+ // Gemma4 models
20
+ export { Gemma4Model } from './stream.js';
21
+
22
+ // Muse-Glimmer models
23
+ export { MuseGlimmerModel } from './stream.js';
24
+
25
+ // Embedding models
26
+ export { HarrierModel } from '@mlx-node/core';
27
+ export { Qwen35Model } from './stream.js';
28
+ export type { Qwen35Config, Qwen35ContextLimits } from '@mlx-node/core';
29
+
30
+ // LFM2 models
31
+ export { Lfm2Model } from './stream.js';
32
+ export { LFM2_CONFIGS, getLfm2Config } from './models/lfm2-configs.js';
33
+
34
+ // MoE variant
35
+ export { Qwen35MoeModel } from './stream.js';
36
+ export type { Qwen35MoeConfig } from '@mlx-node/core';
37
+
38
+ // Nemotron H models
39
+ //
40
+ // The config/limits types ship alongside the class for the same reason
41
+ // Qwen3.5's do: `NemotronHModel` exposes both `getConfig()` and
42
+ // `contextLimits()`, and without these exports a consumer cannot name
43
+ // either return type. (`MuseGlimmerModel` above deliberately exports no
44
+ // config type — that wrapper has no `getConfig()` at all.)
45
+ export { NemotronHModel } from './stream.js';
46
+ export type { NemotronHConfig, NemotronHContextLimits } from '@mlx-node/core';
47
+
48
+ // Memory hygiene: most management is automatic — the decode loop
49
+ // inside `@mlx-node/core` calls `mlx_clear_cache()` every 256 generated
50
+ // tokens to prevent unbounded free-pool growth during long
51
+ // generations, and `MLX_CACHE_LIMIT_GB` auto-tunes the Metal pool cap
52
+ // at model load. Across-request drains are handled by the
53
+ // `@mlx-node/server` idle sweeper (see `packages/server/src/idle-sweeper.ts`):
54
+ // a single `clearCache()` fires after `idleClearCacheMs` of HTTP
55
+ // inactivity once the in-flight request counter has returned to zero.
56
+ // `memoryStats()` is re-exported as a read-only observability hook for
57
+ // dashboards / debugging.
58
+ //
59
+ // `clearCache()` is DELIBERATELY not re-exported here: the native impl
60
+ // routes through MLX's no-arg `synchronize()` which waits only on the
61
+ // default stream, so calling it while a decode runs on a model's
62
+ // custom stream risks racing live Metal command buffers. The only
63
+ // safe caller today is `@mlx-node/server`'s idle sweeper (fires after
64
+ // the in-flight request counter hits zero AND — for hot-load flows —
65
+ // outside any `withSuspendedDrains()` bracket). Admin / cron code that
66
+ // reaches for a manual drain should deep-import from `@mlx-node/core`
67
+ // directly and read the `@internal` caveat there.
68
+ export { memoryStats } from '@mlx-node/core';
69
+
70
+ // Unified Chat API types (shared by Qwen3, Qwen3.5, Qwen3.5 MoE)
71
+ export type { ChatConfig, ChatResult, ChatMessage, ToolCallResult, PerformanceMetrics } from '@mlx-node/core';
72
+
73
+ // Streaming chat API
74
+ export type { ChatStreamDelta, ChatStreamFinal, ChatStreamEvent } from './stream.js';
75
+ // Internal: exported for testing the callback-to-AsyncGenerator bridge
76
+ // Not part of the public API — may change without notice.
77
+ // `_runChatStream` is the generic adapter used by every model wrapper
78
+ // (and the VLM package's QianfanOCR wrapper) to turn a callback-based
79
+ // native stream into an `AsyncGenerator<ChatStreamEvent>`.
80
+ // `makeStreamingModel` is the factory that builds each family's wrapper
81
+ // subclass from its native class; the VLM package reuses it to build
82
+ // `QianfanOCRModel`.
83
+ export { _runChatStream, makeStreamingModel } from './stream.js';
84
+ export type { NativeStreamingInstance, NativeStreamingMethod, StreamingInstance, StreamingModel } from './stream.js';
85
+ // Cross-model chat session wrapper (see chat-session.ts for design notes).
86
+ // `SessionCapableModel` is the structural interface matched by every
87
+ // generative model wrapper and used as the upper-bound for
88
+ // `ChatSession<M>`; exported so the VLM wrapper can pin a compile-time
89
+ // conformance assertion.
90
+ export { ChatSession, ContextCapacityError, isContextCapacityError } from './chat-session.js';
91
+ export type { ChatSessionOptions, SendOptions, SessionCapableModel, SessionContextLimits } from './chat-session.js';
92
+
93
+ // Model utilities (TypeScript-only)
94
+ export {
95
+ type Qwen3Config,
96
+ QWEN3_CONFIGS,
97
+ type GenerationResult,
98
+ type GenerationConfig,
99
+ getQwen3Config,
100
+ } from './models/qwen3-configs.js';
101
+
102
+ // Model loading
103
+ export {
104
+ loadModel,
105
+ loadSession,
106
+ detectModelType,
107
+ type LoadableModel,
108
+ type TrainableModel,
109
+ type LoadModelOptions,
110
+ } from './models/model-loader.js';
111
+
112
+ // Native-free per-family registration data (also published as the
113
+ // `@mlx-node/lm/family-data` subpath for consumers that must not dlopen the
114
+ // addon; see family-data.ts).
115
+ export {
116
+ CHAT_FAMILY_IDS,
117
+ familyDataFor,
118
+ familyTraitsFor,
119
+ GEMMA4_SAMPLING_DEFAULTS,
120
+ launchPresetFor,
121
+ LFM2_SAMPLING_DEFAULTS,
122
+ matchFamily,
123
+ MODEL_FAMILY_DATA,
124
+ MUSE_GLIMMER_SAMPLING_DEFAULTS,
125
+ NEMOTRON_SAMPLING_DEFAULTS,
126
+ NON_GENERATIVE_FAMILY_IDS,
127
+ QWEN_SAMPLING_DEFAULTS,
128
+ rawModelTypeToCanonical,
129
+ type ChatFamilyId,
130
+ type FamilyThinkingLevelMap,
131
+ type FamilyTraits,
132
+ type LaunchPreset,
133
+ type ModelFamilyData,
134
+ type ModelFamilyKind,
135
+ type ModelType,
136
+ type TrainableFamilyId,
137
+ } from './family-data.js';
138
+
139
+ export {
140
+ PagedConfigOverrideManager,
141
+ QWEN35_PAGED_MODEL_TYPES,
142
+ type PagedConfigOverrideManagerOptions,
143
+ } from './models/paged-config-override.js';
144
+
145
+ export { QWEN35_CONFIGS, getQwen35Config } from './models/qwen3_5-configs.js';
146
+
147
+ // Tool calling utilities
148
+ export * from './tools/index.js';
149
+
150
+ // Profiling API
151
+ export { enableProfiling, disableProfiling } from './profiling.js';
@@ -0,0 +1,46 @@
1
+ /** Shared filesystem-only model detection for inference and control-panel discovery. */
2
+ import { constants } from 'node:fs';
3
+ import { open } from 'node:fs/promises';
4
+ import { dirname, extname, join } from 'node:path';
5
+
6
+ import { MODEL_FAMILY_DATA, matchFamily, type ModelType } from './family-data.js';
7
+ import { readGgufArchitecture } from './gguf-metadata.js';
8
+
9
+ export { readGgufArchitecture } from './gguf-metadata.js';
10
+
11
+ const GGUF_ARCHITECTURE_MODEL_TYPES = new Map<string, ModelType>(
12
+ MODEL_FAMILY_DATA.flatMap((row) =>
13
+ 'ggufArchitectures' in row ? row.ggufArchitectures.map((architecture) => [architecture, row.id] as const) : [],
14
+ ),
15
+ );
16
+
17
+ export async function readModelConfig(modelDir: string): Promise<unknown> {
18
+ const file = await open(join(modelDir, 'config.json'), constants.O_RDONLY | constants.O_NONBLOCK);
19
+ try {
20
+ if (!(await file.stat()).isFile()) throw new Error('Model config must be a regular file');
21
+ return JSON.parse(await file.readFile('utf8'));
22
+ } finally {
23
+ await file.close();
24
+ }
25
+ }
26
+
27
+ /** The loader can supply its native header validator without changing family selection. */
28
+ export async function detectModelType(
29
+ modelPath: string,
30
+ readArchitecture: (path: string) => string = readGgufArchitecture,
31
+ ): Promise<ModelType> {
32
+ const isGguf = extname(modelPath).toLowerCase() === '.gguf';
33
+ let config: unknown;
34
+ try {
35
+ config = await readModelConfig(isGguf ? dirname(modelPath) : modelPath);
36
+ } catch (error) {
37
+ if (isGguf && (error as NodeJS.ErrnoException).code === 'ENOENT') {
38
+ const architecture = readArchitecture(modelPath);
39
+ const type = GGUF_ARCHITECTURE_MODEL_TYPES.get(architecture);
40
+ if (type === undefined) throw new Error(`Unsupported GGUF architecture "${architecture}" in ${modelPath}`);
41
+ return type;
42
+ }
43
+ throw new Error(`Cannot detect model type: config.json not found in ${modelPath}`);
44
+ }
45
+ return matchFamily(modelPath, config);
46
+ }
@@ -0,0 +1,329 @@
1
+ /** Shared, native-free discovery of local chat checkpoints. */
2
+
3
+ import type { Dirent } from 'node:fs';
4
+ import { readdir, stat } from 'node:fs/promises';
5
+ import { basename, join } from 'node:path';
6
+
7
+ import {
8
+ launchPresetFor,
9
+ familyTraitsFor,
10
+ NON_GENERATIVE_FAMILY_IDS,
11
+ MODEL_FAMILY_DATA,
12
+ type FamilyTraits,
13
+ type LaunchPreset,
14
+ type ModelType,
15
+ } from './family-data.js';
16
+ import { detectModelType, readGgufArchitecture, readModelConfig } from './model-detection.js';
17
+
18
+ /** Native-free inventory shared by the agent, setup UI, and inference host. */
19
+ export interface LocalChatModel {
20
+ name: string;
21
+ path: string;
22
+ modelType: ModelType;
23
+ preset: LaunchPreset;
24
+ traits: FamilyTraits;
25
+ contextWindow: number;
26
+ supportsImages: boolean;
27
+ }
28
+
29
+ interface DiscoveryMetadata {
30
+ contextWindow: number;
31
+ supportsImages: boolean;
32
+ draftOnly: boolean;
33
+ }
34
+
35
+ /**
36
+ * The Qwen3.5/Qwen3.8 discovery filter retains its XL policy. Gemma4 and Muse
37
+ * accept all supported tensor formats, including Q4_0 QAT checkpoints.
38
+ * Match the Unsloth Dynamic XL target names users download, while excluding
39
+ * ordinary Q4_K_M files and companion artifacts such as imatrix/mmproj/draft.
40
+ */
41
+ const QWEN35_XL_GGUF = /(?:^|[-_.])Q\d+_K_XL\.gguf$/i;
42
+ const GGUF_COMPANION_NAME = /(?:^|[-_.])(?:imatrix|mmproj|dflash|draft)(?:[-_.]|$)/i;
43
+ // Match the native loaders' primary files/shards. A draft or projector
44
+ // SafeTensors file beside a GGUF is not a converted target checkpoint.
45
+ const PRIMARY_SAFETENSORS = /^(?:model|weights)\.safetensors$|^model(?:-|\.safetensors-).+-of-.+\.safetensors$/;
46
+
47
+ function isQwen35XlGguf(name: string): boolean {
48
+ return QWEN35_XL_GGUF.test(name) && !GGUF_COMPANION_NAME.test(name);
49
+ }
50
+
51
+ function ggufModelName(name: string): string {
52
+ return name.slice(0, -'.gguf'.length);
53
+ }
54
+
55
+ function requiresGgufAssets(modelType: ModelType): boolean {
56
+ return modelType === 'gemma4' || modelType === 'muse_glimmer';
57
+ }
58
+
59
+ function matchesGgufFamily(path: string, modelType: ModelType): boolean {
60
+ const architecture = readGgufArchitecture(path);
61
+ return MODEL_FAMILY_DATA.some(
62
+ (family) =>
63
+ family.id === modelType &&
64
+ 'ggufArchitectures' in family &&
65
+ family.ggufArchitectures.some((supported) => supported === architecture),
66
+ );
67
+ }
68
+
69
+ async function hasGgufAssets(modelDir: string): Promise<boolean> {
70
+ try {
71
+ const assets = await Promise.all(['config.json', 'tokenizer.json'].map((name) => stat(join(modelDir, name))));
72
+ return assets.every((asset) => asset.isFile());
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ interface ModelFileInventory {
79
+ xlGgufs: string[];
80
+ targetGgufs: string[];
81
+ hasGguf: boolean;
82
+ hasSafetensors: boolean;
83
+ hasPrimarySafetensors: boolean;
84
+ }
85
+
86
+ async function modelFileInventory(modelDir: string): Promise<ModelFileInventory> {
87
+ try {
88
+ const files = (await readdir(modelDir, { withFileTypes: true }))
89
+ .filter((entry) => entry.isFile())
90
+ .map((entry) => entry.name);
91
+ return {
92
+ xlGgufs: files.filter(isQwen35XlGguf).sort(),
93
+ targetGgufs: files
94
+ .filter((name) => name.toLowerCase().endsWith('.gguf') && !GGUF_COMPANION_NAME.test(name))
95
+ .sort(),
96
+ hasGguf: files.some((name) => name.toLowerCase().endsWith('.gguf')),
97
+ hasSafetensors: files.some((name) => name.toLowerCase().endsWith('.safetensors')),
98
+ hasPrimarySafetensors: files.some((name) => PRIMARY_SAFETENSORS.test(name)),
99
+ };
100
+ } catch {
101
+ return { xlGgufs: [], targetGgufs: [], hasGguf: false, hasSafetensors: false, hasPrimarySafetensors: false };
102
+ }
103
+ }
104
+
105
+ function positiveInteger(value: unknown): number | undefined {
106
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined;
107
+ }
108
+
109
+ function nonEmptyRecord(value: unknown): value is Record<string, unknown> {
110
+ return typeof value === 'object' && value !== null && !Array.isArray(value) && Object.keys(value).length > 0;
111
+ }
112
+
113
+ /**
114
+ * Read cheap discovery metadata from `<modelPath>/config.json`.
115
+ *
116
+ * The trained context window comes from:
117
+ * root `max_position_embeddings` first (qwen3, lfm2), then
118
+ * `text_config.max_position_embeddings` (qwen3_5, qwen3_5_moe, gemma4
119
+ * unified), else the family fallback.
120
+ *
121
+ * Image support is advertised only when a family with a native multimodal
122
+ * implementation carries its valid, non-empty vision marker: `vision_config`
123
+ * for Qwen, and either `vision_config` or `unified_vision_config` for Gemma.
124
+ * This lets Pi's model picker and `--list-models` expose checkpoint capability
125
+ * without loading weights. The first resident load remains authoritative and
126
+ * reconciles this optimistic config-level advertisement via
127
+ * `session.supportsImages()` (for example, when conversion stripped an
128
+ * incompatible vision tower).
129
+ *
130
+ * `detectModelType` already parsed this file, so a read/parse failure here
131
+ * (e.g. a racing rewrite) lands on the context fallback and text-only input
132
+ * instead of dropping the model or guessing a positive capability.
133
+ */
134
+ async function readDiscoveryMetadata(
135
+ modelPath: string,
136
+ modelType: ModelType,
137
+ fallbackContextWindow: number,
138
+ ): Promise<DiscoveryMetadata> {
139
+ try {
140
+ const config = (await readModelConfig(modelPath)) as Record<string, unknown>;
141
+ const root = positiveInteger(config.max_position_embeddings);
142
+ const textConfig = config.text_config;
143
+ const nested = nonEmptyRecord(textConfig) ? positiveInteger(textConfig.max_position_embeddings) : undefined;
144
+ const hasVisionConfig = nonEmptyRecord(config.vision_config);
145
+ const supportsImages =
146
+ modelType === 'gemma4'
147
+ ? hasVisionConfig || nonEmptyRecord(config.unified_vision_config)
148
+ : (modelType === 'qwen3_5' || modelType === 'qwen3_5_moe') && hasVisionConfig;
149
+ const draftOnly = Array.isArray(config.architectures) && config.architectures.includes('DFlash2DraftModel');
150
+
151
+ return {
152
+ contextWindow: root ?? nested ?? fallbackContextWindow,
153
+ supportsImages,
154
+ draftOnly,
155
+ };
156
+ } catch {
157
+ return { contextWindow: fallbackContextWindow, supportsImages: false, draftOnly: false };
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Scan `modelsDir` for chat-capable model subdirectories, Gemma4/Muse GGUFs, and
163
+ * dense Qwen3.5/Qwen3.8 `Q<number>_K_XL.gguf` files. GGUF files may live directly
164
+ * under `modelsDir` or one level inside a downloaded GGUF repository. Each is
165
+ * registered by filename stem so quant variants remain independently selectable.
166
+ *
167
+ * An unreadable dir yields `[]`. Entries with an undetectable config, a
168
+ * non-generative type, or no launch preset are skipped silently (warnings only
169
+ * when `MLX_DEBUG` is set). No weights are loaded. Results are sorted by name.
170
+ */
171
+ export async function discoverLocalChatModels(modelsDir: string): Promise<LocalChatModel[]> {
172
+ const debug = Boolean(process.env.MLX_DEBUG);
173
+
174
+ let entries: Dirent[];
175
+ try {
176
+ entries = await readdir(modelsDir, { withFileTypes: true });
177
+ } catch {
178
+ return [];
179
+ }
180
+ // Collision resolution below gives the first occurrence the bare filename
181
+ // stem. Directory enumeration order is unspecified, so sort before assigning
182
+ // IDs to keep persisted `mlx/<id>` selections stable across filesystems.
183
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
184
+
185
+ const out: LocalChatModel[] = [];
186
+ const usedNames = new Set<string>();
187
+
188
+ const append = async (
189
+ preferredName: string,
190
+ path: string,
191
+ metadataRoot: string,
192
+ modelType: ModelType,
193
+ scopeName: string,
194
+ ): Promise<void> => {
195
+ if (NON_GENERATIVE_FAMILY_IDS.has(modelType)) return;
196
+
197
+ // Fail-closed guards: dead-by-construction for chat families (the
198
+ // family-data row type requires traits + a preset), live for any foreign
199
+ // string that slips through detection.
200
+ const preset = launchPresetFor(modelType);
201
+ if (!preset) {
202
+ if (debug) console.warn(`[mlx] skip ${path}: no launch preset for ${modelType}`);
203
+ return;
204
+ }
205
+ const traits = familyTraitsFor(modelType);
206
+ if (!traits) {
207
+ if (debug) console.warn(`[mlx] skip ${path}: no FAMILY_TRAITS entry for ${modelType}`);
208
+ return;
209
+ }
210
+
211
+ const metadata = await readDiscoveryMetadata(metadataRoot, modelType, traits.fallbackContextWindow);
212
+ if (metadata.draftOnly) {
213
+ if (debug) console.warn(`[mlx] skip ${path}: companion draft checkpoint is not a chat model`);
214
+ return;
215
+ }
216
+
217
+ let name = preferredName;
218
+ if (usedNames.has(name)) {
219
+ name = `${scopeName}-${preferredName}`;
220
+ let suffix = 2;
221
+ while (usedNames.has(name)) name = `${scopeName}-${preferredName}-${suffix++}`;
222
+ }
223
+ usedNames.add(name);
224
+ // Automatic companions can be installed or removed after this startup
225
+ // scan. The host resolves them on each load; draftModelPath is reserved
226
+ // for caller-supplied paths that the loader must treat as authoritative.
227
+ out.push({
228
+ name,
229
+ path,
230
+ modelType,
231
+ preset,
232
+ traits,
233
+ contextWindow: metadata.contextWindow,
234
+ supportsImages: metadata.supportsImages,
235
+ });
236
+ };
237
+
238
+ for (const entry of entries) {
239
+ if (entry.isFile() && entry.name.toLowerCase().endsWith('.gguf') && !GGUF_COMPANION_NAME.test(entry.name)) {
240
+ const full = join(modelsDir, entry.name);
241
+ try {
242
+ const modelType = await detectModelType(full);
243
+ // A shared sibling config can describe another target or a projector.
244
+ // Never advertise a file under a loader that disagrees with its header.
245
+ if (!matchesGgufFamily(full, modelType)) continue;
246
+ if (requiresGgufAssets(modelType) && !(await hasGgufAssets(modelsDir))) {
247
+ if (debug)
248
+ console.warn(
249
+ `[mlx] skip ${full}: native ${modelType} GGUF requires sibling config.json and tokenizer.json`,
250
+ );
251
+ continue;
252
+ }
253
+ if (
254
+ modelType === 'gemma4' ||
255
+ modelType === 'muse_glimmer' ||
256
+ (modelType === 'qwen3_5' && isQwen35XlGguf(entry.name))
257
+ ) {
258
+ await append(ggufModelName(entry.name), full, modelsDir, modelType, basename(modelsDir));
259
+ } else if (debug) {
260
+ console.warn(`[mlx] skip ${full}: no supported direct GGUF target for ${modelType}`);
261
+ }
262
+ } catch (err) {
263
+ if (debug) console.warn(`[mlx] skip ${full}: ${(err as Error).message}`);
264
+ }
265
+ continue;
266
+ }
267
+ if (!entry.isDirectory()) continue;
268
+ const full = join(modelsDir, entry.name);
269
+
270
+ let modelType: ModelType;
271
+ try {
272
+ modelType = await detectModelType(full);
273
+ } catch (err) {
274
+ if (debug) console.warn(`[mlx] skip ${full}: ${(err as Error).message}`);
275
+ continue;
276
+ }
277
+
278
+ const inventory = await modelFileInventory(full);
279
+ const hasModelWeights = requiresGgufAssets(modelType) ? inventory.hasPrimarySafetensors : inventory.hasSafetensors;
280
+ if (requiresGgufAssets(modelType) && !hasModelWeights && inventory.targetGgufs.length > 0) {
281
+ if (!(await hasGgufAssets(full))) {
282
+ if (debug)
283
+ console.warn(`[mlx] skip ${full}: native ${modelType} GGUF requires sibling config.json and tokenizer.json`);
284
+ continue;
285
+ }
286
+ for (const gguf of inventory.targetGgufs) {
287
+ const path = join(full, gguf);
288
+ try {
289
+ if (!matchesGgufFamily(path, modelType)) continue;
290
+ await append(ggufModelName(gguf), path, full, modelType, entry.name);
291
+ } catch (err) {
292
+ if (debug) console.warn(`[mlx] skip ${path}: ${(err as Error).message}`);
293
+ }
294
+ }
295
+ continue;
296
+ }
297
+ const { xlGgufs } = inventory;
298
+ if (xlGgufs.length > 0 && !inventory.hasPrimarySafetensors) {
299
+ if (modelType !== 'qwen3_5') {
300
+ if (debug) {
301
+ console.warn(`[mlx] skip ${full}: direct XL GGUF loading is not supported for ${modelType}`);
302
+ }
303
+ continue;
304
+ }
305
+ for (const gguf of xlGgufs) {
306
+ const path = join(full, gguf);
307
+ try {
308
+ if (matchesGgufFamily(path, modelType)) await append(ggufModelName(gguf), path, full, modelType, entry.name);
309
+ } catch (err) {
310
+ if (debug) console.warn(`[mlx] skip ${path}: ${(err as Error).message}`);
311
+ }
312
+ }
313
+ continue;
314
+ }
315
+
316
+ // Present each supported GGUF variant separately in the picker. Keep
317
+ // converted model directories discoverable when they retain
318
+ // an imatrix/source GGUF beside their actual SafeTensors weights.
319
+ if (inventory.hasGguf && !hasModelWeights) {
320
+ if (debug) console.warn(`[mlx] skip ${full}: no supported direct GGUF target`);
321
+ continue;
322
+ }
323
+
324
+ await append(basename(full), full, full, modelType, entry.name);
325
+ }
326
+
327
+ out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
328
+ return out;
329
+ }