@mlx-node/cli 0.0.2 → 0.0.4
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/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +80 -0
- package/dist/commands/convert.d.ts +2 -0
- package/dist/commands/convert.d.ts.map +1 -0
- package/dist/commands/convert.js +328 -0
- package/dist/commands/download-dataset.d.ts +2 -0
- package/dist/commands/download-dataset.d.ts.map +1 -0
- package/dist/commands/download-dataset.js +121 -0
- package/dist/commands/download-model.d.ts +2 -0
- package/dist/commands/download-model.d.ts.map +1 -0
- package/dist/commands/download-model.js +327 -0
- package/dist/utils.d.ts +3 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +17 -0
- package/package.json +5 -2
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import pkgJson from '../package.json' with { type: 'json' };
|
|
3
|
+
const args = process.argv.slice(2);
|
|
4
|
+
const command = args[0];
|
|
5
|
+
const subcommand = args[1];
|
|
6
|
+
function printHelp() {
|
|
7
|
+
console.log(`
|
|
8
|
+
mlx - MLX-Node CLI v${pkgJson.version}
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
mlx <command> [options]
|
|
12
|
+
|
|
13
|
+
Commands:
|
|
14
|
+
download model Download a model from HuggingFace
|
|
15
|
+
download dataset Download a dataset from HuggingFace
|
|
16
|
+
convert Convert model weights to MLX format
|
|
17
|
+
|
|
18
|
+
Options:
|
|
19
|
+
-h, --help Show this help message
|
|
20
|
+
-v, --version Show version number
|
|
21
|
+
|
|
22
|
+
Examples:
|
|
23
|
+
mlx download model -m Qwen/Qwen3-0.6B
|
|
24
|
+
mlx download dataset -d openai/gsm8k
|
|
25
|
+
mlx convert -i .cache/models/qwen3-0.6b -o .cache/models/qwen3-0.6b-mlx -d bf16
|
|
26
|
+
`);
|
|
27
|
+
}
|
|
28
|
+
async function main() {
|
|
29
|
+
if (!command || command === '--help' || command === '-h') {
|
|
30
|
+
printHelp();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (command === '--version' || command === '-v') {
|
|
34
|
+
console.log(pkgJson.version);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
switch (command) {
|
|
38
|
+
case 'download': {
|
|
39
|
+
if (!subcommand || subcommand === '--help' || subcommand === '-h') {
|
|
40
|
+
console.log(`
|
|
41
|
+
Usage:
|
|
42
|
+
mlx download model Download a model from HuggingFace
|
|
43
|
+
mlx download dataset Download a dataset from HuggingFace
|
|
44
|
+
|
|
45
|
+
Run mlx download <subcommand> --help for more information.
|
|
46
|
+
`);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const rest = args.slice(2);
|
|
50
|
+
if (subcommand === 'model') {
|
|
51
|
+
const { run } = await import('./commands/download-model.js');
|
|
52
|
+
await run(rest);
|
|
53
|
+
}
|
|
54
|
+
else if (subcommand === 'dataset') {
|
|
55
|
+
const { run } = await import('./commands/download-dataset.js');
|
|
56
|
+
await run(rest);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
console.error(`Unknown download subcommand: ${subcommand}`);
|
|
60
|
+
console.error('Available: model, dataset');
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
case 'convert': {
|
|
66
|
+
const rest = args.slice(1);
|
|
67
|
+
const { run } = await import('./commands/convert.js');
|
|
68
|
+
await run(rest);
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
default:
|
|
72
|
+
console.error(`Unknown command: ${command}`);
|
|
73
|
+
printHelp();
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
main().catch((error) => {
|
|
78
|
+
console.error(error);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"convert.d.ts","sourceRoot":"","sources":["../../src/commands/convert.ts"],"names":[],"mappings":"AAoEA,wBAAsB,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,iBAgSvC"}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
import { convertModel, convertForeignWeights, convertGgufToSafetensors } from '@mlx-node/core';
|
|
5
|
+
function printHelp() {
|
|
6
|
+
console.log(`
|
|
7
|
+
Convert Model Weights to MLX Format
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
mlx convert --input <path> --output <dir> [options]
|
|
11
|
+
|
|
12
|
+
Required Arguments:
|
|
13
|
+
--input, -i <path> Input model directory or .gguf file
|
|
14
|
+
--output, -o <dir> Output directory for converted model
|
|
15
|
+
|
|
16
|
+
Optional Arguments:
|
|
17
|
+
--dtype, -d <type> Target dtype (default: bfloat16)
|
|
18
|
+
Options: float32, float16, bfloat16
|
|
19
|
+
--model-type, -m Model type (auto-detected if not specified)
|
|
20
|
+
Options: paddleocr-vl, pp-lcnet-ori, uvdoc, qwen3_5, qwen3_5_moe
|
|
21
|
+
--verbose, -v Enable verbose logging
|
|
22
|
+
--help, -h Show this help message
|
|
23
|
+
|
|
24
|
+
Vision Arguments:
|
|
25
|
+
--mmproj <path> Path to mmproj GGUF file (vision encoder weights)
|
|
26
|
+
Converts and merges vision weights into output directory
|
|
27
|
+
|
|
28
|
+
Quantization Arguments:
|
|
29
|
+
--quantize, -q Enable quantization of converted weights
|
|
30
|
+
--q-bits <int> Quantization bits (default: 4 for affine, 8 for mxfp8)
|
|
31
|
+
--q-group-size <int> Group size (default: 64 for affine, 32 for mxfp8)
|
|
32
|
+
--q-mode <string> Mode: "affine" (default) or "mxfp8"
|
|
33
|
+
--q-recipe <string> Per-layer mixed-bit quantization recipe
|
|
34
|
+
Options: mixed_2_6, mixed_3_4, mixed_3_6, mixed_4_6, qwen3_5, unsloth
|
|
35
|
+
"unsloth" defaults to 3-bit base (gate/up=3b, down=4b,
|
|
36
|
+
embed=5b, lm_head=6b, attn/SSM=bf16)
|
|
37
|
+
--imatrix-path <path> imatrix GGUF file for AWQ-style pre-scaling
|
|
38
|
+
Improves quantization quality using calibration data
|
|
39
|
+
|
|
40
|
+
Model Types:
|
|
41
|
+
(default) SafeTensors dtype conversion (HuggingFace models)
|
|
42
|
+
paddleocr-vl PaddleOCR-VL weight sanitization
|
|
43
|
+
qwen3_5 Qwen3.5 dense model (FP8 dequant, key remapping)
|
|
44
|
+
qwen3_5_moe Qwen3.5 MoE model (FP8 dequant, expert stacking)
|
|
45
|
+
pp-lcnet-ori PP-LCNet orientation classifier (Paddle -> SafeTensors)
|
|
46
|
+
uvdoc UVDoc unwarping model (Paddle/PyTorch -> SafeTensors)
|
|
47
|
+
|
|
48
|
+
GGUF Support:
|
|
49
|
+
When --input points to a .gguf file, the converter automatically parses the
|
|
50
|
+
GGUF binary format and converts tensors to SafeTensors. Supports BF16, F16,
|
|
51
|
+
F32, Q4_0, Q4_1, and Q8_0 tensor types. Tokenizer files are copied from
|
|
52
|
+
alongside the GGUF file if present.
|
|
53
|
+
|
|
54
|
+
Examples:
|
|
55
|
+
mlx convert -i .cache/models/qwen3-0.6b -o .cache/models/qwen3-0.6b-mlx
|
|
56
|
+
mlx convert -i .cache/models/Qwen3.5-35B-A3B-FP8 -o .cache/models/Qwen3.5-35B-A3B-4bit -m qwen3_5_moe -q --q-bits 4
|
|
57
|
+
mlx convert -m pp-lcnet-ori -i .cache/models/PP-LCNet -o ./models/PP-LCNet_x1_0_doc_ori/
|
|
58
|
+
mlx convert -i model.gguf -o ./models/converted-mlx
|
|
59
|
+
mlx convert -i model-BF16.gguf -o ./models/converted-4bit -q --q-bits 4
|
|
60
|
+
mlx convert -i model-BF16.gguf -o ./models/mixed-4-6 -q --q-recipe mixed_4_6
|
|
61
|
+
mlx convert -i .cache/models/qwen3.5-9b -o ./models/qwen35-recipe -q --q-recipe qwen3_5 -m qwen3_5
|
|
62
|
+
mlx convert -i model-BF16.gguf -o ./models/awq-4bit -q --q-recipe unsloth --imatrix-path imatrix.gguf
|
|
63
|
+
mlx convert -i .cache/models/Qwen3.5-27B -o ./models/qwen3.5-unsloth -q --q-recipe unsloth --mmproj mmproj-BF16.gguf
|
|
64
|
+
`);
|
|
65
|
+
}
|
|
66
|
+
export async function run(argv) {
|
|
67
|
+
const { values: args } = parseArgs({
|
|
68
|
+
args: argv,
|
|
69
|
+
options: {
|
|
70
|
+
input: { type: 'string', short: 'i' },
|
|
71
|
+
output: { type: 'string', short: 'o' },
|
|
72
|
+
dtype: { type: 'string', short: 'd' },
|
|
73
|
+
'model-type': { type: 'string', short: 'm' },
|
|
74
|
+
verbose: { type: 'boolean', short: 'v', default: false },
|
|
75
|
+
quantize: { type: 'boolean', short: 'q', default: false },
|
|
76
|
+
'q-bits': { type: 'string' },
|
|
77
|
+
'q-group-size': { type: 'string' },
|
|
78
|
+
'q-mode': { type: 'string' },
|
|
79
|
+
'q-recipe': { type: 'string' },
|
|
80
|
+
'imatrix-path': { type: 'string' },
|
|
81
|
+
mmproj: { type: 'string' },
|
|
82
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
if (args.help) {
|
|
86
|
+
printHelp();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (!args.input || !args.output) {
|
|
90
|
+
console.error('Error: Both --input and --output are required\n');
|
|
91
|
+
console.error('Use --help for usage information');
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
const inputPath = resolve(args.input);
|
|
95
|
+
const outputDir = resolve(args.output);
|
|
96
|
+
const verbose = args.verbose;
|
|
97
|
+
const parsePositiveInt = (flag, raw) => {
|
|
98
|
+
if (raw === undefined)
|
|
99
|
+
return undefined;
|
|
100
|
+
if (!/^[1-9]\d*$/.test(raw)) {
|
|
101
|
+
console.error(`Error: ${flag} requires a positive integer value`);
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
return Number(raw);
|
|
105
|
+
};
|
|
106
|
+
const quantBits = parsePositiveInt('--q-bits', args['q-bits']);
|
|
107
|
+
const quantGroupSize = parsePositiveInt('--q-group-size', args['q-group-size']);
|
|
108
|
+
const quantMode = args['q-mode'];
|
|
109
|
+
if (quantMode !== undefined && quantMode !== 'affine' && quantMode !== 'mxfp8') {
|
|
110
|
+
console.error('Error: --q-mode must be "affine" or "mxfp8"');
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
const quantRecipe = args['q-recipe'];
|
|
114
|
+
const validRecipes = ['mixed_2_6', 'mixed_3_4', 'mixed_3_6', 'mixed_4_6', 'qwen3_5', 'unsloth'];
|
|
115
|
+
if (quantRecipe !== undefined) {
|
|
116
|
+
if (!args.quantize) {
|
|
117
|
+
console.error('Error: --q-recipe requires --quantize (-q) to be enabled');
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
if (quantMode === 'mxfp8') {
|
|
121
|
+
console.error('Error: --q-recipe is incompatible with --q-mode mxfp8');
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
if (!validRecipes.includes(quantRecipe)) {
|
|
125
|
+
console.error(`Error: Unknown recipe "${quantRecipe}". Available: ${validRecipes.join(', ')}`);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
// Unsloth recipe defaults to 3-bit base (MLP gate/up at 3-bit, down at 4-bit,
|
|
129
|
+
// embed_tokens at 5-bit, lm_head at 6-bit, attention/SSM kept bf16).
|
|
130
|
+
// Based on Unsloth's per-tensor KLD analysis showing ffn_up/gate are
|
|
131
|
+
// "generally ok to quantize to 3-bit" and IQ3_XXS is the "best compromise".
|
|
132
|
+
if (quantRecipe === 'unsloth' && !args['q-bits']) {
|
|
133
|
+
console.log('Note: unsloth recipe defaults to 3-bit base (override with --q-bits)');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// Apply recipe-specific defaults for bits when not explicitly set.
|
|
137
|
+
// Unsloth recipe: 3-bit base → MLP gate/up=3b, down=4b, embed=5b, lm_head=6b
|
|
138
|
+
const effectiveQuantBits = quantBits ?? (quantRecipe === 'unsloth' ? 3 : undefined);
|
|
139
|
+
const mmprojPath = args.mmproj ? resolve(args.mmproj) : undefined;
|
|
140
|
+
if (mmprojPath !== undefined) {
|
|
141
|
+
if (!existsSync(mmprojPath)) {
|
|
142
|
+
console.error(`Error: mmproj file not found: ${mmprojPath}`);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
if (!mmprojPath.endsWith('.gguf')) {
|
|
146
|
+
console.error('Error: --mmproj must point to a .gguf file');
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const imatrixPath = args['imatrix-path'] ? resolve(args['imatrix-path']) : undefined;
|
|
151
|
+
if (imatrixPath !== undefined) {
|
|
152
|
+
if (!existsSync(imatrixPath)) {
|
|
153
|
+
console.error(`Error: imatrix file not found: ${imatrixPath}`);
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
156
|
+
if (!imatrixPath.endsWith('.gguf')) {
|
|
157
|
+
console.error('Error: --imatrix-path must point to a .gguf file');
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const startTime = Date.now();
|
|
162
|
+
// GGUF file detection
|
|
163
|
+
if (inputPath.endsWith('.gguf')) {
|
|
164
|
+
if (!existsSync(inputPath)) {
|
|
165
|
+
console.error(`Error: GGUF file not found: ${inputPath}`);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
const dtype = args.dtype || 'bfloat16';
|
|
169
|
+
console.log(`Converting GGUF to SafeTensors`);
|
|
170
|
+
console.log(`Input: ${inputPath}`);
|
|
171
|
+
console.log(`Output: ${outputDir}`);
|
|
172
|
+
console.log(`Dtype: ${dtype}`);
|
|
173
|
+
if (args.quantize) {
|
|
174
|
+
const qMode = quantMode || 'affine';
|
|
175
|
+
const qBits = effectiveQuantBits || (qMode === 'mxfp8' ? 8 : 4);
|
|
176
|
+
const qGs = quantGroupSize || (qMode === 'mxfp8' ? 32 : 64);
|
|
177
|
+
console.log(`Quantize: ${qBits}-bit ${qMode} (group_size=${qGs})${quantRecipe ? `, recipe=${quantRecipe}` : ''}`);
|
|
178
|
+
}
|
|
179
|
+
if (imatrixPath) {
|
|
180
|
+
console.log(`imatrix: ${imatrixPath}`);
|
|
181
|
+
}
|
|
182
|
+
if (mmprojPath) {
|
|
183
|
+
console.log(`mmproj: ${mmprojPath}`);
|
|
184
|
+
}
|
|
185
|
+
console.log('');
|
|
186
|
+
try {
|
|
187
|
+
const result = await convertGgufToSafetensors({
|
|
188
|
+
inputPath,
|
|
189
|
+
outputDir,
|
|
190
|
+
dtype,
|
|
191
|
+
verbose,
|
|
192
|
+
quantize: args.quantize,
|
|
193
|
+
quantBits: effectiveQuantBits,
|
|
194
|
+
quantGroupSize,
|
|
195
|
+
quantMode,
|
|
196
|
+
quantRecipe,
|
|
197
|
+
imatrixPath,
|
|
198
|
+
vlmKeyPrefix: !!mmprojPath,
|
|
199
|
+
});
|
|
200
|
+
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
|
|
201
|
+
console.log(`\n✓ Converted ${result.numTensors} tensors (source: ${result.sourceFormat})`);
|
|
202
|
+
console.log(`✓ Total parameters: ${result.numParameters.toLocaleString()}`);
|
|
203
|
+
console.log(`✓ Output directory: ${result.outputPath}`);
|
|
204
|
+
console.log(`✓ Duration: ${duration}s`);
|
|
205
|
+
if (verbose) {
|
|
206
|
+
console.log('\nConverted tensors:');
|
|
207
|
+
for (const name of result.tensorNames) {
|
|
208
|
+
console.log(` - ${name}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// Convert mmproj (vision encoder) if provided
|
|
212
|
+
if (mmprojPath) {
|
|
213
|
+
console.log('\nConverting mmproj (vision encoder)...');
|
|
214
|
+
const visionResult = await convertGgufToSafetensors({
|
|
215
|
+
inputPath: mmprojPath,
|
|
216
|
+
outputDir,
|
|
217
|
+
dtype: 'bfloat16',
|
|
218
|
+
verbose,
|
|
219
|
+
quantize: false,
|
|
220
|
+
outputFilename: 'vision.safetensors',
|
|
221
|
+
});
|
|
222
|
+
console.log(`✓ Converted ${visionResult.numTensors} vision tensors`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
console.error('\nGGUF conversion failed:', error.message);
|
|
227
|
+
if (error.stack && verbose) {
|
|
228
|
+
console.error('\nStack trace:', error.stack);
|
|
229
|
+
}
|
|
230
|
+
process.exit(1);
|
|
231
|
+
}
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
// Auto-detect model type from config.json if not specified
|
|
235
|
+
let modelType = args['model-type'];
|
|
236
|
+
if (!modelType) {
|
|
237
|
+
try {
|
|
238
|
+
const configPath = resolve(inputPath, 'config.json');
|
|
239
|
+
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
240
|
+
if (config.model_type === 'paddleocr_vl') {
|
|
241
|
+
modelType = 'paddleocr-vl';
|
|
242
|
+
console.log(`Auto-detected model type: ${modelType} (from config.json)`);
|
|
243
|
+
}
|
|
244
|
+
else if (config.model_type === 'qwen3_5_moe' || config.model_type === 'qwen3_5') {
|
|
245
|
+
modelType = config.model_type;
|
|
246
|
+
console.log(`Auto-detected model type: ${modelType} (from config.json)`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
// config.json not found or invalid
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// Foreign weight formats (Paddle .pdparams/.pdiparams, PyTorch .pkl)
|
|
254
|
+
if (modelType === 'pp-lcnet-ori' || modelType === 'uvdoc') {
|
|
255
|
+
if (!existsSync(inputPath)) {
|
|
256
|
+
console.error(`Error: Input path not found: ${inputPath}`);
|
|
257
|
+
process.exit(1);
|
|
258
|
+
}
|
|
259
|
+
const label = modelType === 'pp-lcnet-ori'
|
|
260
|
+
? 'PP-LCNet Orientation Classifier (Paddle -> SafeTensors)'
|
|
261
|
+
: 'UVDoc Unwarping Model (-> SafeTensors)';
|
|
262
|
+
console.log(`Converting: ${label}`);
|
|
263
|
+
console.log(`Input: ${inputPath}`);
|
|
264
|
+
console.log(`Output: ${outputDir}\n`);
|
|
265
|
+
const result = convertForeignWeights({
|
|
266
|
+
inputPath,
|
|
267
|
+
outputDir,
|
|
268
|
+
modelType,
|
|
269
|
+
verbose,
|
|
270
|
+
});
|
|
271
|
+
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
|
|
272
|
+
console.log(`\n✓ Converted ${result.numTensors} tensors`);
|
|
273
|
+
console.log(`✓ Output directory: ${result.outputPath}`);
|
|
274
|
+
console.log(`✓ Duration: ${duration}s`);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
// Default: SafeTensors dtype conversion
|
|
278
|
+
const dtype = args.dtype || 'bfloat16';
|
|
279
|
+
console.log(`Input: ${inputPath}`);
|
|
280
|
+
console.log(`Output: ${outputDir}`);
|
|
281
|
+
console.log(`Dtype: ${dtype}`);
|
|
282
|
+
if (modelType) {
|
|
283
|
+
console.log(`Model Type: ${modelType}`);
|
|
284
|
+
}
|
|
285
|
+
if (args.quantize) {
|
|
286
|
+
const qMode = quantMode || 'affine';
|
|
287
|
+
const qBits = effectiveQuantBits || (qMode === 'mxfp8' ? 8 : 4);
|
|
288
|
+
const qGs = quantGroupSize || (qMode === 'mxfp8' ? 32 : 64);
|
|
289
|
+
console.log(`Quantize: ${qBits}-bit ${qMode} (group_size=${qGs})${quantRecipe ? `, recipe=${quantRecipe}` : ''}`);
|
|
290
|
+
}
|
|
291
|
+
if (imatrixPath) {
|
|
292
|
+
console.log(`imatrix: ${imatrixPath}`);
|
|
293
|
+
}
|
|
294
|
+
console.log('');
|
|
295
|
+
try {
|
|
296
|
+
const result = await convertModel({
|
|
297
|
+
inputDir: inputPath,
|
|
298
|
+
outputDir,
|
|
299
|
+
dtype,
|
|
300
|
+
verbose,
|
|
301
|
+
modelType,
|
|
302
|
+
quantize: args.quantize,
|
|
303
|
+
quantBits: effectiveQuantBits,
|
|
304
|
+
quantGroupSize,
|
|
305
|
+
quantMode,
|
|
306
|
+
quantRecipe,
|
|
307
|
+
imatrixPath,
|
|
308
|
+
});
|
|
309
|
+
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
|
|
310
|
+
console.log(`\n✓ Converted ${result.numTensors} tensors`);
|
|
311
|
+
console.log(`✓ Total parameters: ${result.numParameters.toLocaleString()}`);
|
|
312
|
+
console.log(`✓ Output directory: ${result.outputPath}`);
|
|
313
|
+
console.log(`✓ Duration: ${duration}s`);
|
|
314
|
+
if (verbose) {
|
|
315
|
+
console.log('\nConverted tensors:');
|
|
316
|
+
for (const name of result.tensorNames) {
|
|
317
|
+
console.log(` - ${name}`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
console.error('\nConversion failed:', error.message);
|
|
323
|
+
if (error.stack && verbose) {
|
|
324
|
+
console.error('\nStack trace:', error.stack);
|
|
325
|
+
}
|
|
326
|
+
process.exit(1);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"download-dataset.d.ts","sourceRoot":"","sources":["../../src/commands/download-dataset.ts"],"names":[],"mappings":"AA+DA,wBAAsB,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,iBAmFvC"}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { readdir, stat, copyFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join, dirname, resolve } from 'node:path';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import { snapshotDownload } from '@huggingface/hub';
|
|
6
|
+
import { convertParquetToJsonl } from '@mlx-node/core';
|
|
7
|
+
import { ensureDir } from '../utils.js';
|
|
8
|
+
const DEFAULT_DATASET = 'openai/gsm8k';
|
|
9
|
+
const DEFAULT_REVISION = 'main';
|
|
10
|
+
const DEFAULT_CACHE_DIR = join(homedir(), '.cache', 'huggingface');
|
|
11
|
+
const FILE_SPECS = [
|
|
12
|
+
{ output: 'train.jsonl', parquetPrefix: 'train-' },
|
|
13
|
+
{ output: 'test.jsonl', parquetPrefix: 'test-' },
|
|
14
|
+
];
|
|
15
|
+
function datasetSlug(name) {
|
|
16
|
+
return name.replace(/\//g, '-').toLowerCase();
|
|
17
|
+
}
|
|
18
|
+
function printHelp() {
|
|
19
|
+
console.log(`
|
|
20
|
+
Download a dataset from HuggingFace
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
mlx download dataset [options]
|
|
24
|
+
|
|
25
|
+
Options:
|
|
26
|
+
-d, --dataset <name> HuggingFace dataset name (default: ${DEFAULT_DATASET})
|
|
27
|
+
-r, --revision <rev> Dataset revision (default: ${DEFAULT_REVISION})
|
|
28
|
+
-o, --output <dir> Output directory (default: data/<dataset-slug>)
|
|
29
|
+
--cache-dir <dir> HuggingFace cache directory (default: ~/.cache/huggingface)
|
|
30
|
+
-h, --help Show this help message
|
|
31
|
+
|
|
32
|
+
Examples:
|
|
33
|
+
mlx download dataset
|
|
34
|
+
mlx download dataset --dataset openai/gsm8k
|
|
35
|
+
mlx download dataset --dataset tatsu-lab/alpaca --output data/alpaca
|
|
36
|
+
`);
|
|
37
|
+
}
|
|
38
|
+
async function findFirstMatch(root, predicate) {
|
|
39
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
40
|
+
for (const entry of entries) {
|
|
41
|
+
const fullPath = join(root, entry.name);
|
|
42
|
+
if ((entry.isFile() || entry.isSymbolicLink()) && predicate(entry.name, fullPath)) {
|
|
43
|
+
return fullPath;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
if (!entry.isDirectory())
|
|
48
|
+
continue;
|
|
49
|
+
const fullPath = join(root, entry.name);
|
|
50
|
+
const found = await findFirstMatch(fullPath, predicate);
|
|
51
|
+
if (found)
|
|
52
|
+
return found;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
export async function run(argv) {
|
|
57
|
+
const { values: args } = parseArgs({
|
|
58
|
+
args: argv,
|
|
59
|
+
options: {
|
|
60
|
+
dataset: {
|
|
61
|
+
type: 'string',
|
|
62
|
+
short: 'd',
|
|
63
|
+
default: process.env.MLX_DATASET ?? DEFAULT_DATASET,
|
|
64
|
+
},
|
|
65
|
+
revision: {
|
|
66
|
+
type: 'string',
|
|
67
|
+
short: 'r',
|
|
68
|
+
default: process.env.MLX_DATASET_REVISION ?? DEFAULT_REVISION,
|
|
69
|
+
},
|
|
70
|
+
output: {
|
|
71
|
+
type: 'string',
|
|
72
|
+
short: 'o',
|
|
73
|
+
},
|
|
74
|
+
'cache-dir': {
|
|
75
|
+
type: 'string',
|
|
76
|
+
},
|
|
77
|
+
help: {
|
|
78
|
+
type: 'boolean',
|
|
79
|
+
short: 'h',
|
|
80
|
+
default: false,
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
if (args.help) {
|
|
85
|
+
printHelp();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const dataset = args.dataset;
|
|
89
|
+
const revision = args.revision;
|
|
90
|
+
const outputDir = resolve(args.output ?? process.env.MLX_DATASET_OUTPUT ?? join('data', datasetSlug(dataset)));
|
|
91
|
+
console.log(`Downloading ${dataset}@${revision} snapshot from Hugging Face…`);
|
|
92
|
+
const cacheDir = args['cache-dir'] ? resolve(args['cache-dir']) : DEFAULT_CACHE_DIR;
|
|
93
|
+
const snapshotPath = await snapshotDownload({
|
|
94
|
+
repo: { type: 'dataset', name: dataset },
|
|
95
|
+
revision,
|
|
96
|
+
cacheDir,
|
|
97
|
+
});
|
|
98
|
+
console.log(`Snapshot available at ${snapshotPath}`);
|
|
99
|
+
await ensureDir(outputDir);
|
|
100
|
+
for (const spec of FILE_SPECS) {
|
|
101
|
+
const destinationPath = join(outputDir, spec.output);
|
|
102
|
+
await ensureDir(dirname(destinationPath));
|
|
103
|
+
const original = await findFirstMatch(snapshotPath, (name) => name === spec.output);
|
|
104
|
+
if (original) {
|
|
105
|
+
await copyFile(original, destinationPath);
|
|
106
|
+
const stats = await stat(destinationPath);
|
|
107
|
+
console.log(`Copied ${spec.output} (${Math.round(stats.size / 1024)} KiB) → ${destinationPath}`);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const parquetSource = await findFirstMatch(snapshotPath, (name) => name.endsWith('.parquet') && name.startsWith(spec.parquetPrefix));
|
|
111
|
+
if (!parquetSource) {
|
|
112
|
+
throw new Error(`Could not locate ${spec.output} or matching Parquet file (prefix ${spec.parquetPrefix}) inside snapshot ${snapshotPath}`);
|
|
113
|
+
}
|
|
114
|
+
console.log(`Converting ${parquetSource} → ${destinationPath}`);
|
|
115
|
+
convertParquetToJsonl(parquetSource, destinationPath);
|
|
116
|
+
const stats = await stat(destinationPath);
|
|
117
|
+
console.log(`Saved ${spec.output} (${Math.round(stats.size / 1024)} KiB) → ${destinationPath}`);
|
|
118
|
+
}
|
|
119
|
+
console.log('Done.');
|
|
120
|
+
console.log(`Dataset files stored under ${outputDir}`);
|
|
121
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"download-model.d.ts","sourceRoot":"","sources":["../../src/commands/download-model.ts"],"names":[],"mappings":"AAoLA,wBAAsB,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,iBAmLvC"}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readdir, copyFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join, resolve, dirname } from 'node:path';
|
|
5
|
+
import { parseArgs } from 'node:util';
|
|
6
|
+
import { listFiles, whoAmI, downloadFileToCacheDir } from '@huggingface/hub';
|
|
7
|
+
import { input } from '@inquirer/prompts';
|
|
8
|
+
import { AsyncEntry } from '@napi-rs/keyring';
|
|
9
|
+
import { ensureDir, formatBytes } from '../utils.js';
|
|
10
|
+
const DEFAULT_CACHE_DIR = join(homedir(), '.cache', 'huggingface');
|
|
11
|
+
const DEFAULT_MODEL = 'Qwen/Qwen3-0.6B';
|
|
12
|
+
const keyringEntry = new AsyncEntry('mlx-node', 'huggingface-token');
|
|
13
|
+
function printHelp() {
|
|
14
|
+
console.log(`
|
|
15
|
+
Download a model from HuggingFace
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
mlx download model [options]
|
|
19
|
+
|
|
20
|
+
Options:
|
|
21
|
+
-m, --model <name> HuggingFace model name (default: ${DEFAULT_MODEL})
|
|
22
|
+
-o, --output <dir> Output directory (default: .cache/models/<model-slug>)
|
|
23
|
+
-g, --glob <pattern> Filter files by glob pattern (can be repeated)
|
|
24
|
+
--cache-dir <dir> HuggingFace cache directory (default: ~/.cache/huggingface)
|
|
25
|
+
-h, --help Show this help message
|
|
26
|
+
--set-token Set HuggingFace token
|
|
27
|
+
|
|
28
|
+
Glob Filtering:
|
|
29
|
+
Use --glob to download only specific files from a repo. This is especially
|
|
30
|
+
useful for GGUF repos that contain many quantization variants. Patterns use
|
|
31
|
+
simple wildcard matching (* matches any characters).
|
|
32
|
+
|
|
33
|
+
Multiple --glob flags can be combined; a file is included if it matches ANY
|
|
34
|
+
of the patterns.
|
|
35
|
+
|
|
36
|
+
Examples:
|
|
37
|
+
mlx download model
|
|
38
|
+
mlx download model --model Qwen/Qwen3-1.7B --output .cache/models/qwen3-1.7b
|
|
39
|
+
|
|
40
|
+
# Download only the BF16 GGUF variant
|
|
41
|
+
mlx download model -m unsloth/Qwen3.5-9B-GGUF -g "*BF16*"
|
|
42
|
+
|
|
43
|
+
# Download only Q4_K_M and Q8_0 variants
|
|
44
|
+
mlx download model -m unsloth/Qwen3.5-9B-GGUF -g "*Q4_K_M*" -g "*Q8_0*"
|
|
45
|
+
|
|
46
|
+
# Download all .gguf files (skip everything else)
|
|
47
|
+
mlx download model -m unsloth/Qwen3.5-9B-GGUF -g "*.gguf"
|
|
48
|
+
`);
|
|
49
|
+
}
|
|
50
|
+
async function setToken() {
|
|
51
|
+
const token = await input({
|
|
52
|
+
message: 'Enter your HuggingFace token:',
|
|
53
|
+
required: true,
|
|
54
|
+
theme: {
|
|
55
|
+
validationFailureMode: 'clear',
|
|
56
|
+
},
|
|
57
|
+
validate: async (value) => {
|
|
58
|
+
if (!value) {
|
|
59
|
+
return 'Token is required';
|
|
60
|
+
}
|
|
61
|
+
if (!value.startsWith('hf_')) {
|
|
62
|
+
return 'HuggingFace token must start with "hf_"';
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const { auth } = await whoAmI({ accessToken: value });
|
|
66
|
+
if (!auth) {
|
|
67
|
+
return 'Invalid token';
|
|
68
|
+
}
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return 'Invalid token';
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
if (token) {
|
|
77
|
+
await keyringEntry.setPassword(token);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const CORE_FILES = [
|
|
81
|
+
'config.json',
|
|
82
|
+
'tokenizer.json',
|
|
83
|
+
'tokenizer_config.json',
|
|
84
|
+
'special_tokens_map.json',
|
|
85
|
+
'vocab.json',
|
|
86
|
+
'merges.txt',
|
|
87
|
+
];
|
|
88
|
+
/** Convert a simple glob pattern (with * wildcards) to a RegExp */
|
|
89
|
+
function globToRegex(pattern) {
|
|
90
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
|
91
|
+
return new RegExp(`^${escaped}$`, 'i');
|
|
92
|
+
}
|
|
93
|
+
/** Check if a filename matches any of the glob patterns */
|
|
94
|
+
function matchesAnyGlob(filename, patterns) {
|
|
95
|
+
return patterns.some((re) => re.test(filename));
|
|
96
|
+
}
|
|
97
|
+
async function getModelFiles(modelName, accessToken, globPatterns) {
|
|
98
|
+
let totalSize = 0;
|
|
99
|
+
const filesToDownload = [];
|
|
100
|
+
const allFiles = [];
|
|
101
|
+
// Compile glob patterns if provided
|
|
102
|
+
const globs = globPatterns?.map(globToRegex);
|
|
103
|
+
for await (const file of listFiles({ repo: { type: 'model', name: modelName }, accessToken })) {
|
|
104
|
+
allFiles.push(file);
|
|
105
|
+
if (globs) {
|
|
106
|
+
// When glob patterns are active, include files that match the pattern
|
|
107
|
+
// OR are essential metadata files (config, tokenizer)
|
|
108
|
+
const basename = file.path.split('/').pop() || file.path;
|
|
109
|
+
if (matchesAnyGlob(basename, globs) || matchesAnyGlob(file.path, globs)) {
|
|
110
|
+
filesToDownload.push(file);
|
|
111
|
+
if (file.size)
|
|
112
|
+
totalSize += file.size;
|
|
113
|
+
}
|
|
114
|
+
else if (CORE_FILES.includes(basename)) {
|
|
115
|
+
// Always include core config/tokenizer files
|
|
116
|
+
filesToDownload.push(file);
|
|
117
|
+
if (file.size)
|
|
118
|
+
totalSize += file.size;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
// Default behavior: download model files
|
|
123
|
+
if (CORE_FILES.includes(file.path) ||
|
|
124
|
+
file.path.endsWith('.safetensors') ||
|
|
125
|
+
file.path.endsWith('.json') ||
|
|
126
|
+
file.path.endsWith('.pdiparams') ||
|
|
127
|
+
file.path.endsWith('.yml')) {
|
|
128
|
+
filesToDownload.push(file);
|
|
129
|
+
if (file.size) {
|
|
130
|
+
totalSize += file.size;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { totalSize, filesToDownload, allFiles };
|
|
136
|
+
}
|
|
137
|
+
async function verifyDownload(outputDir, weightFiles) {
|
|
138
|
+
console.log('\nVerifying download...');
|
|
139
|
+
let allPresent = true;
|
|
140
|
+
const configPath = join(outputDir, 'config.json');
|
|
141
|
+
if (!existsSync(configPath)) {
|
|
142
|
+
console.error(' ✗ Missing required file: config.json');
|
|
143
|
+
allPresent = false;
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
console.log(' ✓ config.json');
|
|
147
|
+
}
|
|
148
|
+
if (weightFiles.length === 0) {
|
|
149
|
+
console.error(' ✗ No weight files found');
|
|
150
|
+
allPresent = false;
|
|
151
|
+
}
|
|
152
|
+
for (const file of weightFiles) {
|
|
153
|
+
const path = join(outputDir, file);
|
|
154
|
+
if (!existsSync(path)) {
|
|
155
|
+
console.error(` ✗ Missing weight file: ${file}`);
|
|
156
|
+
allPresent = false;
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
console.log(` ✓ ${file}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return allPresent;
|
|
163
|
+
}
|
|
164
|
+
export async function run(argv) {
|
|
165
|
+
const { values: args } = parseArgs({
|
|
166
|
+
args: argv,
|
|
167
|
+
options: {
|
|
168
|
+
model: {
|
|
169
|
+
type: 'string',
|
|
170
|
+
short: 'm',
|
|
171
|
+
default: DEFAULT_MODEL,
|
|
172
|
+
},
|
|
173
|
+
output: {
|
|
174
|
+
type: 'string',
|
|
175
|
+
short: 'o',
|
|
176
|
+
},
|
|
177
|
+
glob: {
|
|
178
|
+
type: 'string',
|
|
179
|
+
short: 'g',
|
|
180
|
+
multiple: true,
|
|
181
|
+
},
|
|
182
|
+
help: {
|
|
183
|
+
type: 'boolean',
|
|
184
|
+
short: 'h',
|
|
185
|
+
default: false,
|
|
186
|
+
},
|
|
187
|
+
'set-token': {
|
|
188
|
+
type: 'boolean',
|
|
189
|
+
default: false,
|
|
190
|
+
},
|
|
191
|
+
'cache-dir': {
|
|
192
|
+
type: 'string',
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
if (args.help) {
|
|
197
|
+
printHelp();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (args['set-token']) {
|
|
201
|
+
await setToken();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const modelName = args.model;
|
|
205
|
+
const globPatterns = args.glob;
|
|
206
|
+
const modelSlug = modelName.split('/').pop().toLowerCase();
|
|
207
|
+
const outputDir = resolve(args.output ?? join('.cache', 'models', modelSlug));
|
|
208
|
+
const HUGGINGFACE_TOKEN = (await keyringEntry.getPassword()) ?? undefined;
|
|
209
|
+
if (!HUGGINGFACE_TOKEN) {
|
|
210
|
+
console.warn('No HuggingFace token found, the model will download with anonymous access');
|
|
211
|
+
}
|
|
212
|
+
const title = `${modelName} Model Download from HuggingFace`;
|
|
213
|
+
const boxWidth = Math.max(title.length + 6, 58);
|
|
214
|
+
const padding = Math.floor((boxWidth - title.length - 2) / 2);
|
|
215
|
+
const rightPadding = boxWidth - title.length - padding;
|
|
216
|
+
console.log('╔' + '═'.repeat(boxWidth) + '╗');
|
|
217
|
+
console.log('║' + ' '.repeat(padding) + title + ' '.repeat(rightPadding) + '║');
|
|
218
|
+
console.log('╚' + '═'.repeat(boxWidth) + '╝\n');
|
|
219
|
+
console.log(`Model: ${modelName}`);
|
|
220
|
+
if (globPatterns?.length) {
|
|
221
|
+
console.log(`Filter: ${globPatterns.join(', ')}`);
|
|
222
|
+
}
|
|
223
|
+
console.log(`Output: ${outputDir}\n`);
|
|
224
|
+
// Check if already downloaded
|
|
225
|
+
if (existsSync(outputDir)) {
|
|
226
|
+
const files = await readdir(outputDir);
|
|
227
|
+
const hasConfig = files.includes('config.json');
|
|
228
|
+
const hasSingleModel = files.includes('model.safetensors');
|
|
229
|
+
const hasShardedModel = files.includes('model.safetensors.index.json');
|
|
230
|
+
const hasPaddleModel = files.includes('inference.pdiparams');
|
|
231
|
+
const hasGguf = files.some((f) => f.endsWith('.gguf'));
|
|
232
|
+
if (hasConfig && (hasSingleModel || hasShardedModel || hasPaddleModel)) {
|
|
233
|
+
console.log('Model already downloaded!\n');
|
|
234
|
+
console.log('To re-download, delete the output directory first:');
|
|
235
|
+
console.log(` rm -rf ${outputDir}\n`);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (hasGguf && !globPatterns?.length) {
|
|
239
|
+
console.log('GGUF file(s) already downloaded!\n');
|
|
240
|
+
console.log('To re-download, delete the output directory first:');
|
|
241
|
+
console.log(` rm -rf ${outputDir}\n`);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
// For glob downloads, check if all glob-matched files are present
|
|
245
|
+
if (hasGguf && globPatterns?.length) {
|
|
246
|
+
const globs = globPatterns.map(globToRegex);
|
|
247
|
+
const matchedExisting = files.filter((f) => matchesAnyGlob(f, globs) || CORE_FILES.includes(f));
|
|
248
|
+
if (matchedExisting.length > 1) {
|
|
249
|
+
console.log('Matched files already downloaded!\n');
|
|
250
|
+
console.log('To re-download, delete the output directory first:');
|
|
251
|
+
console.log(` rm -rf ${outputDir}\n`);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
await ensureDir(outputDir);
|
|
257
|
+
console.log('Fetching file list from HuggingFace...\n');
|
|
258
|
+
const { totalSize, filesToDownload, allFiles } = await getModelFiles(modelName, HUGGINGFACE_TOKEN, globPatterns);
|
|
259
|
+
if (filesToDownload.length === 0) {
|
|
260
|
+
console.error('No files matched the given criteria.\n');
|
|
261
|
+
if (globPatterns?.length) {
|
|
262
|
+
const ggufFiles = allFiles.filter((f) => f.path.endsWith('.gguf'));
|
|
263
|
+
if (ggufFiles.length > 0) {
|
|
264
|
+
console.log('Available GGUF files in this repo:');
|
|
265
|
+
for (const f of ggufFiles) {
|
|
266
|
+
console.log(` ${f.path} (${formatBytes(f.size)})`);
|
|
267
|
+
}
|
|
268
|
+
console.log(`\nTry: mlx download model -m ${modelName} -g "<pattern>"`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
process.exit(1);
|
|
272
|
+
}
|
|
273
|
+
// Show what will be downloaded
|
|
274
|
+
if (globPatterns?.length) {
|
|
275
|
+
console.log(`Matched ${filesToDownload.length} file(s):`);
|
|
276
|
+
for (const f of filesToDownload) {
|
|
277
|
+
console.log(` ${f.path} (${formatBytes(f.size)})`);
|
|
278
|
+
}
|
|
279
|
+
console.log('');
|
|
280
|
+
}
|
|
281
|
+
const sizeStr = formatBytes(totalSize);
|
|
282
|
+
console.log(`Downloading ${filesToDownload.length} file(s) (~${sizeStr})...\n`);
|
|
283
|
+
const cacheDir = args['cache-dir'] ? resolve(args['cache-dir']) : DEFAULT_CACHE_DIR;
|
|
284
|
+
const weightFiles = [];
|
|
285
|
+
const total = filesToDownload.length;
|
|
286
|
+
for (let i = 0; i < total; i++) {
|
|
287
|
+
const file = filesToDownload[i];
|
|
288
|
+
const fileSizeStr = file.size ? formatBytes(file.size) : '';
|
|
289
|
+
console.log(` [${i + 1}/${total}] ${file.path}${fileSizeStr ? ` (${fileSizeStr})` : ''}...`);
|
|
290
|
+
const snapshotPath = await downloadFileToCacheDir({
|
|
291
|
+
repo: { type: 'model', name: modelName },
|
|
292
|
+
path: file.path,
|
|
293
|
+
cacheDir,
|
|
294
|
+
accessToken: HUGGINGFACE_TOKEN,
|
|
295
|
+
});
|
|
296
|
+
const destPath = join(outputDir, file.path);
|
|
297
|
+
await ensureDir(dirname(destPath));
|
|
298
|
+
await copyFile(snapshotPath, destPath);
|
|
299
|
+
if (file.path.endsWith('.safetensors') || file.path.endsWith('.pdiparams') || file.path.endsWith('.gguf')) {
|
|
300
|
+
weightFiles.push(file.path);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// For GGUF downloads, skip strict verification (no config.json required in GGUF repos)
|
|
304
|
+
const hasGgufFiles = weightFiles.some((f) => f.endsWith('.gguf'));
|
|
305
|
+
if (hasGgufFiles) {
|
|
306
|
+
console.log(`\nDownload complete! ${weightFiles.length} file(s) saved to ${outputDir}\n`);
|
|
307
|
+
console.log('To convert GGUF to MLX SafeTensors format:');
|
|
308
|
+
for (const wf of weightFiles) {
|
|
309
|
+
const ggufPath = join(outputDir, wf);
|
|
310
|
+
console.log(` mlx convert -i ${ggufPath} -o ${outputDir}-mlx`);
|
|
311
|
+
}
|
|
312
|
+
console.log('');
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
console.log(`Format: Base model (needs MLX conversion)`);
|
|
316
|
+
console.log('Note: After download, convert to MLX format:');
|
|
317
|
+
console.log(` mlx convert --input ${outputDir} --output ${outputDir}-mlx-bf16\n`);
|
|
318
|
+
const success = await verifyDownload(outputDir, weightFiles);
|
|
319
|
+
if (success) {
|
|
320
|
+
console.log('\nModel downloaded successfully!\n');
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
console.error('\nDownload incomplete. Please try again.\n');
|
|
324
|
+
process.exit(1);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
package/dist/utils.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAGA,wBAAsB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAI3D;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CASjD"}
|
package/dist/utils.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { mkdir } from 'node:fs/promises';
|
|
3
|
+
export async function ensureDir(path) {
|
|
4
|
+
if (!existsSync(path)) {
|
|
5
|
+
await mkdir(path, { recursive: true });
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export function formatBytes(bytes) {
|
|
9
|
+
const units = ['B', 'KB', 'MB', 'GB'];
|
|
10
|
+
let size = bytes;
|
|
11
|
+
let unitIndex = 0;
|
|
12
|
+
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
13
|
+
size /= 1024;
|
|
14
|
+
unitIndex++;
|
|
15
|
+
}
|
|
16
|
+
return `${size.toFixed(2)} ${units[unitIndex]}`;
|
|
17
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mlx-node/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4",
|
|
4
4
|
"homepage": "https://github.com/mlx-node/mlx-node",
|
|
5
5
|
"bugs": {
|
|
6
6
|
"url": "https://github.com/mlx-node/mlx-node/issues"
|
|
@@ -24,7 +24,10 @@
|
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@huggingface/hub": "^2.10.7",
|
|
26
26
|
"@inquirer/prompts": "^8.3.0",
|
|
27
|
-
"@mlx-node/core": "0.0.
|
|
27
|
+
"@mlx-node/core": "0.0.4",
|
|
28
28
|
"@napi-rs/keyring": "^1.2.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "@types/node@25.5.0"
|
|
29
32
|
}
|
|
30
33
|
}
|