Skip to content

Commit 84d3b7b

Browse files
authored
Merge pull request #5 from DaveZheng/feat/cli-ux-spinner-memory-check
feat: spinner UX for pip install/model loading, memory check
2 parents 5fbeea5 + d5696aa commit 84d3b7b

8 files changed

Lines changed: 964 additions & 29 deletions

File tree

package-lock.json

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/device.test.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, it } from "node:test";
22
import assert from "node:assert";
3-
import { getDeviceInfo, recommendModel } from "./device.js";
3+
import { getDeviceInfo, recommendModel, getAvailableMemoryGB, lookupModelSize, MODEL_TIERS } from "./device.js";
44

55
describe("getDeviceInfo", () => {
66
it("returns chip and totalMemoryGB on macOS", async () => {
@@ -41,3 +41,47 @@ describe("recommendModel", () => {
4141
assert.strictEqual(rec.quantization, "8bit");
4242
});
4343
});
44+
45+
describe("getAvailableMemoryGB", () => {
46+
it("returns a positive number on macOS", async () => {
47+
const gb = await getAvailableMemoryGB();
48+
assert.ok(typeof gb === "number", "should return a number");
49+
assert.ok(gb > 0, `should be positive, got ${gb}`);
50+
assert.ok(gb < 512, `should be reasonable (< 512GB), got ${gb}`);
51+
});
52+
});
53+
54+
describe("lookupModelSize", () => {
55+
it("returns size for a known model", () => {
56+
const size = lookupModelSize("mlx-community/Qwen2.5-Coder-7B-Instruct-4bit");
57+
assert.strictEqual(size, 4);
58+
});
59+
60+
it("returns size for another known model", () => {
61+
const size = lookupModelSize("mlx-community/Qwen2.5-Coder-14B-Instruct-4bit");
62+
assert.strictEqual(size, 8);
63+
});
64+
65+
it("returns undefined for unknown models", () => {
66+
assert.strictEqual(lookupModelSize("some/custom-model"), undefined);
67+
});
68+
69+
it("returns undefined for empty string", () => {
70+
assert.strictEqual(lookupModelSize(""), undefined);
71+
});
72+
});
73+
74+
describe("MODEL_TIERS", () => {
75+
it("is exported and non-empty", () => {
76+
assert.ok(Array.isArray(MODEL_TIERS));
77+
assert.ok(MODEL_TIERS.length > 0);
78+
});
79+
80+
it("every tier has required fields", () => {
81+
for (const tier of MODEL_TIERS) {
82+
assert.ok(typeof tier.modelId === "string");
83+
assert.ok(typeof tier.estimatedSizeGB === "number");
84+
assert.ok(tier.estimatedSizeGB > 0);
85+
}
86+
});
87+
});

src/device.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ interface ModelTier {
2525

2626
// Actual sizes verified from HuggingFace (2026-02-06)
2727
// Budget = total RAM × 0.75 (reserve 25% for OS + apps)
28-
const MODEL_TIERS: ModelTier[] = [
28+
export const MODEL_TIERS: ModelTier[] = [
2929
{
3030
minRAM: 128,
3131
modelId: "mlx-community/Qwen3-Coder-Next-8bit",
@@ -82,3 +82,39 @@ export function recommendModel(totalMemoryGB: number): ModelRecommendation {
8282
}
8383
return MODEL_TIERS[MODEL_TIERS.length - 1];
8484
}
85+
86+
/**
87+
* Parse macOS `vm_stat` output to estimate available memory in GB.
88+
* Counts free + inactive + purgeable + speculative pages, which is more
89+
* accurate than `os.freemem()` on macOS (which only reports "free" pages).
90+
*/
91+
export async function getAvailableMemoryGB(): Promise<number> {
92+
const { stdout } = await execFileAsync("vm_stat");
93+
94+
// First line: "Mach Virtual Memory Statistics: (page size of 16384 bytes)"
95+
const pageSizeMatch = stdout.match(/page size of (\d+) bytes/);
96+
const pageSize = pageSizeMatch ? parseInt(pageSizeMatch[1], 10) : 16384;
97+
98+
const get = (label: string): number => {
99+
const re = new RegExp(`${label}:\\s+(\\d+)`);
100+
const m = stdout.match(re);
101+
return m ? parseInt(m[1], 10) : 0;
102+
};
103+
104+
const pages =
105+
get("Pages free") +
106+
get("Pages inactive") +
107+
get("Pages purgeable") +
108+
get("Pages speculative");
109+
110+
return (pages * pageSize) / (1024 ** 3);
111+
}
112+
113+
/**
114+
* Look up estimated model size in GB from MODEL_TIERS.
115+
* Returns undefined for custom/unknown models.
116+
*/
117+
export function lookupModelSize(modelId: string): number | undefined {
118+
const tier = MODEL_TIERS.find((t) => t.modelId === modelId);
119+
return tier?.estimatedSizeGB;
120+
}

src/index.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/env node
22
import http from "node:http";
33
import { loadConfig, saveConfig } from "./config.js";
4-
import { getDeviceInfo, recommendModel } from "./device.js";
4+
import { getDeviceInfo, recommendModel, getAvailableMemoryGB, lookupModelSize } from "./device.js";
55
import { ensureDependencies, ensureServer, stopServer } from "./server.js";
66
import { startProxy, setShuttingDown } from "./proxy.js";
77
import { execFileSync, spawn } from "node:child_process";
@@ -85,7 +85,27 @@ async function main(): Promise<void> {
8585
}
8686

8787
// Ensure python + mlx-lm are available (creates venv on first run)
88-
ensureDependencies();
88+
await ensureDependencies();
89+
90+
// Memory check before loading model
91+
const modelSizeGB = lookupModelSize(config.model);
92+
if (modelSizeGB !== undefined) {
93+
const availableGB = await getAvailableMemoryGB();
94+
if (availableGB < modelSizeGB) {
95+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
96+
const answer = await new Promise<string>((resolve) =>
97+
rl.question(
98+
`Warning: ${availableGB.toFixed(1)}GB free memory, model needs ~${modelSizeGB}GB. Continue anyway? (Y/n) `,
99+
resolve,
100+
),
101+
);
102+
rl.close();
103+
if (answer.toLowerCase() === "n") {
104+
console.log("Aborted.");
105+
return;
106+
}
107+
}
108+
}
89109

90110
// Ensure mlx-lm.server is running
91111
await ensureServer(config.model, config.serverPort);

0 commit comments

Comments
 (0)