@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.
- package/dist/chat-session.d.ts +1 -1
- package/dist/chat-session.d.ts.map +1 -1
- package/dist/chat-session.js +2 -2
- package/dist/draft-companion.d.ts +16 -0
- package/dist/draft-companion.d.ts.map +1 -0
- package/dist/draft-companion.js +76 -0
- package/dist/family-data.d.ts +2 -0
- package/dist/family-data.d.ts.map +1 -1
- package/dist/family-data.js +2 -0
- package/dist/gguf-metadata.d.ts +2 -0
- package/dist/gguf-metadata.d.ts.map +1 -0
- package/dist/gguf-metadata.js +128 -0
- package/dist/model-detection.d.ts +6 -0
- package/dist/model-detection.d.ts.map +1 -0
- package/dist/model-detection.js +38 -0
- package/dist/model-discovery.d.ts +24 -0
- package/dist/model-discovery.d.ts.map +1 -0
- package/dist/model-discovery.js +274 -0
- package/dist/models/model-loader.d.ts +6 -0
- package/dist/models/model-loader.d.ts.map +1 -1
- package/dist/models/model-loader.js +10 -32
- package/dist/models/paged-config-override.d.ts.map +1 -1
- package/dist/models/paged-config-override.js +21 -1
- package/dist/stream.d.ts.map +1 -1
- package/dist/stream.js +5 -5
- package/package.json +21 -3
- package/src/chat-session.ts +2369 -0
- package/src/draft-companion.ts +74 -0
- package/src/family-data.ts +542 -0
- package/src/gguf-metadata.ts +117 -0
- package/src/index.ts +151 -0
- package/src/model-detection.ts +46 -0
- package/src/model-discovery.ts +329 -0
- package/src/models/lfm2-configs.ts +110 -0
- package/src/models/model-loader.ts +256 -0
- package/src/models/paged-config-override.ts +387 -0
- package/src/models/qwen3-configs.ts +113 -0
- package/src/models/qwen3_5-configs.ts +60 -0
- package/src/profiling.ts +69 -0
- package/src/stream.ts +960 -0
- package/src/tools/index.ts +58 -0
- 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
|
+
}
|