Skip to content

Commit ec30930

Browse files
committed
Add hardware-aware model recommendations and an Automatic model runtime
recommendModels() now estimates per-model memory/KV-cache/offload fit and recommends a concrete runtime per outcome (vLLM for tensor-parallel fits, llama.cpp for partial/CPU-only). New resolveAutomaticRuntime()/detectModelFormat() in system-specs.ts implement the Automatic runtime policy: GGUF -> llama.cpp, safetensors + NVIDIA/AMD -> vLLM, MLX format + Apple Silicon -> MLX, Ollama-library models -> Ollama (MLX on Apple Silicon), anything else -> Transformers.
1 parent bfa3bdb commit ec30930

6 files changed

Lines changed: 305 additions & 215 deletions

File tree

‎app/src/huggingface.test.ts‎

Lines changed: 53 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,117 +1,76 @@
1-
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2-
import * as fs from "node:fs";
3-
import * as os from "node:os";
4-
import * as path from "node:path";
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
52
import { downloadGgufFile } from "./huggingface";
6-
7-
function fakeResponse(opts: { status: number; headers?: Record<string, string>; chunks?: string[] }) {
8-
const chunks = (opts.chunks ?? []).map((c) => new TextEncoder().encode(c));
9-
let i = 0;
10-
return {
11-
ok: opts.status >= 200 && opts.status < 300,
12-
status: opts.status,
13-
headers: { get: (name: string) => opts.headers?.[name.toLowerCase()] ?? null },
14-
body: {
15-
getReader: () => ({
16-
read: async () => (i < chunks.length ? { done: false, value: chunks[i++] } : { done: true, value: undefined }),
17-
}),
18-
},
19-
} as unknown as Response;
20-
}
21-
22-
describe("downloadGgufFile", () => {
23-
let dir: string;
24-
let destPath: string;
25-
3+
import { downloadGgufFileNative } from "./native-downloader";
4+
5+
// The actual HTTP/resume/verify logic now lives in the Rust native addon
6+
// (lib/), covered by its own test suite there (mock-HTTP-server-based, see
7+
// lib/src/download/tests.rs) — a JS-side `vi.stubGlobal("fetch", ...)` can't
8+
// reach code that no longer runs in JS. What's left to verify from the TS
9+
// side is just this file's adapter: does it call the native function with
10+
// the right arguments, and does it translate the native progress/error
11+
// shapes into what callers (main.ts's hf:downloadFile handler) expect.
12+
vi.mock("./native-downloader", () => ({
13+
downloadGgufFileNative: vi.fn(),
14+
}));
15+
16+
const nativeMock = vi.mocked(downloadGgufFileNative);
17+
18+
describe("downloadGgufFile (native adapter)", () => {
2619
beforeEach(() => {
27-
dir = fs.mkdtempSync(path.join(os.tmpdir(), "hf-download-test-"));
28-
destPath = path.join(dir, "model.gguf");
29-
});
30-
31-
afterEach(() => {
32-
vi.unstubAllGlobals();
20+
nativeMock.mockReset();
3321
});
3422

35-
it("downloads fresh, reports progress, and renames the .part file to the final name on success", async () => {
36-
const progress: { receivedBytes: number; totalBytes: number | null }[] = [];
37-
vi.stubGlobal(
38-
"fetch",
39-
vi.fn(async () => fakeResponse({ status: 200, headers: { "content-length": "10" }, chunks: ["hello", "world"] }))
40-
);
23+
it("calls the native addon with modelId/filename/destPath and no token when none is given", async () => {
24+
nativeMock.mockResolvedValue(undefined);
4125

42-
await downloadGgufFile("org/model", "model.gguf", destPath, (p) => progress.push(p));
26+
await downloadGgufFile("org/model", "model.gguf", "/tmp/model.gguf", () => {});
4327

44-
expect(fs.existsSync(destPath)).toBe(true);
45-
expect(fs.existsSync(destPath + ".part")).toBe(false);
46-
expect(fs.readFileSync(destPath, "utf8")).toBe("helloworld");
47-
expect(progress.at(-1)).toEqual({ receivedBytes: 10, totalBytes: 10 });
28+
expect(nativeMock).toHaveBeenCalledWith(
29+
"org/model",
30+
"model.gguf",
31+
"/tmp/model.gguf",
32+
undefined,
33+
expect.any(Function)
34+
);
4835
});
4936

50-
it("resumes from an existing .part file using a Range request instead of starting over", async () => {
51-
fs.writeFileSync(destPath + ".part", "hello");
52-
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
53-
expect((init?.headers as Record<string, string>).Range).toBe("bytes=5-");
54-
return fakeResponse({ status: 206, headers: { "content-range": "bytes 5-9/10" }, chunks: ["world"] });
55-
});
56-
vi.stubGlobal("fetch", fetchMock);
37+
it("passes a null token through as undefined, matching the native addon's Option<String> signature", async () => {
38+
nativeMock.mockResolvedValue(undefined);
5739

58-
await downloadGgufFile("org/model", "model.gguf", destPath, () => {});
40+
await downloadGgufFile("org/model", "model.gguf", "/tmp/model.gguf", () => {}, null);
5941

60-
expect(fs.readFileSync(destPath, "utf8")).toBe("helloworld");
42+
expect(nativeMock.mock.calls[0][3]).toBeUndefined();
6143
});
6244

63-
it("discards a stale partial and starts over when the server ignores the Range request", async () => {
64-
fs.writeFileSync(destPath + ".part", "OLD-STALE-DATA");
65-
vi.stubGlobal(
66-
"fetch",
67-
vi.fn(async () => fakeResponse({ status: 200, headers: { "content-length": "5" }, chunks: ["fresh"] }))
68-
);
45+
it("passes a real token through unchanged", async () => {
46+
nativeMock.mockResolvedValue(undefined);
6947

70-
await downloadGgufFile("org/model", "model.gguf", destPath, () => {});
48+
await downloadGgufFile("org/model", "model.gguf", "/tmp/model.gguf", () => {}, "secret-token");
7149

72-
expect(fs.readFileSync(destPath, "utf8")).toBe("fresh");
50+
expect(nativeMock.mock.calls[0][3]).toBe("secret-token");
7351
});
7452

75-
it("keeps the .part file (doesn't delete it) when the connection drops mid-stream, so a retry can resume", async () => {
76-
vi.stubGlobal("fetch", vi.fn(async () => ({
77-
ok: true,
78-
status: 200,
79-
headers: { get: () => "20" },
80-
body: {
81-
getReader: () => ({
82-
read: async () => {
83-
throw new Error("connection reset");
84-
},
85-
}),
86-
},
87-
} as unknown as Response)));
88-
89-
await expect(downloadGgufFile("org/model", "model.gguf", destPath, () => {})).rejects.toThrow("connection reset");
90-
expect(fs.existsSync(destPath + ".part")).toBe(true);
91-
expect(fs.existsSync(destPath)).toBe(false);
92-
});
53+
it("adapts native progress events, mapping a missing totalBytes to null", async () => {
54+
const progress: { receivedBytes: number; totalBytes: number | null }[] = [];
55+
nativeMock.mockImplementation(async (_modelId, _filename, _dest, _token, onProgress) => {
56+
onProgress(null, { receivedBytes: 5, totalBytes: undefined });
57+
onProgress(null, { receivedBytes: 10, totalBytes: 10 });
58+
});
9359

94-
it("keeps the .part file and throws when the stream ends short of the expected size", async () => {
95-
vi.stubGlobal(
96-
"fetch",
97-
vi.fn(async () => fakeResponse({ status: 200, headers: { "content-length": "100" }, chunks: ["short"] }))
98-
);
60+
await downloadGgufFile("org/model", "model.gguf", "/tmp/model.gguf", (p) => progress.push(p));
9961

100-
await expect(downloadGgufFile("org/model", "model.gguf", destPath, () => {})).rejects.toThrow(/incomplete/);
101-
expect(fs.existsSync(destPath + ".part")).toBe(true);
62+
expect(progress).toEqual([
63+
{ receivedBytes: 5, totalBytes: null },
64+
{ receivedBytes: 10, totalBytes: 10 },
65+
]);
10266
});
10367

104-
it("discards a partial and retries once when the server responds 416 (range no longer satisfiable)", async () => {
105-
fs.writeFileSync(destPath + ".part", "stale");
106-
const fetchMock = vi
107-
.fn()
108-
.mockResolvedValueOnce(fakeResponse({ status: 416 }))
109-
.mockResolvedValueOnce(fakeResponse({ status: 200, headers: { "content-length": "5" }, chunks: ["fresh"] }));
110-
vi.stubGlobal("fetch", fetchMock);
111-
112-
await downloadGgufFile("org/model", "model.gguf", destPath, () => {});
68+
it("propagates the native call's rejection unchanged, so callers' error logging keeps working", async () => {
69+
const nativeError = new Error(
70+
'Download of "model.gguf" was incomplete (got 5 of 10 bytes) — try downloading it again to resume.'
71+
);
72+
nativeMock.mockRejectedValue(nativeError);
11373

114-
expect(fs.readFileSync(destPath, "utf8")).toBe("fresh");
115-
expect(fetchMock).toHaveBeenCalledTimes(2);
74+
await expect(downloadGgufFile("org/model", "model.gguf", "/tmp/model.gguf", () => {})).rejects.toBe(nativeError);
11675
});
11776
});

‎app/src/huggingface.ts‎

Lines changed: 10 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -47,98 +47,22 @@ export interface DownloadProgress {
4747
totalBytes: number | null;
4848
}
4949

50-
// Suffix for in-progress downloads. Writing directly to the final .gguf name
51-
// would let a truncated file — from a network drop, a crash, or the app
52-
// being force-quit mid-download, none of which run our error-path cleanup —
53-
// sit there indistinguishable from a real model, so listModels() would offer
54-
// it and loading it would fail with a confusing "corrupt GGUF" error instead
55-
// of the app just not knowing about it.
56-
export const PARTIAL_DOWNLOAD_SUFFIX = ".part";
57-
58-
function parseContentRangeTotal(headerValue: string | null): number | null {
59-
// "bytes 12345-67890/98765" — the part after the slash is the full size.
60-
const match = headerValue?.match(/\/(\d+)$/);
61-
return match ? Number(match[1]) : null;
62-
}
63-
50+
// The actual HTTP + resume/verify logic lives in the native Rust addon
51+
// (lib/) for speed — a plain single-stream port wouldn't outperform this,
52+
// but splitting large files across several parallel Range-request
53+
// connections meaningfully does. This wrapper keeps the exact signature the
54+
// rest of the app (main.ts's hf:downloadFile handler) already calls, so
55+
// nothing downstream needed to change.
6456
export async function downloadGgufFile(
6557
modelId: string,
6658
filename: string,
6759
destPath: string,
6860
onProgress: (progress: DownloadProgress) => void,
6961
token?: string | null
7062
): Promise<void> {
71-
const fs = await import("node:fs");
72-
const url = `https://huggingface.co/${modelId}/resolve/main/${encodeURIComponent(filename)}`;
73-
const partPath = destPath + PARTIAL_DOWNLOAD_SUFFIX;
74-
75-
// A .part file left over from a dropped connection or a force-quit is
76-
// real progress, not garbage — resume it with a Range request instead of
77-
// re-downloading from byte zero every time.
78-
let existingBytes = fs.existsSync(partPath) ? fs.statSync(partPath).size : 0;
79-
const headers: Record<string, string> = {};
80-
if (token) headers.Authorization = `Bearer ${token}`;
81-
if (existingBytes > 0) headers.Range = `bytes=${existingBytes}-`;
82-
83-
let res: Response;
84-
try {
85-
res = await fetch(url, { headers });
86-
} catch (err) {
87-
throw new Error(`Couldn't reach Hugging Face: ${(err as Error).message}`);
88-
}
89-
90-
if (existingBytes > 0 && res.status === 416) {
91-
// Our partial is already >= what the server has now (stale, or the
92-
// remote file changed) — it can't be resumed, so start clean once.
93-
fs.rmSync(partPath, { force: true });
94-
return downloadGgufFile(modelId, filename, destPath, onProgress, token);
95-
}
96-
let resuming = existingBytes > 0 && res.status === 206;
97-
if (existingBytes > 0 && !resuming) {
98-
// Server ignored the Range request and is sending the whole file
99-
// from the start — appending that to the stale partial would
100-
// corrupt it, so discard the partial and treat this as fresh.
101-
existingBytes = 0;
102-
}
103-
104-
if (!res.ok || !res.body) throw new Error(`Failed to download "${filename}" (HTTP ${res.status}).`);
105-
106-
const contentLength = Number(res.headers.get("content-length")) || null;
107-
const totalBytes = resuming
108-
? (parseContentRangeTotal(res.headers.get("content-range")) ?? (contentLength !== null ? existingBytes + contentLength : null))
109-
: contentLength;
110-
111-
let receivedBytes = existingBytes;
112-
const writeStream = fs.createWriteStream(partPath, { flags: resuming ? "a" : "w" });
113-
const reader = res.body.getReader();
114-
onProgress({ receivedBytes, totalBytes });
115-
116-
try {
117-
while (true) {
118-
const { done, value } = await reader.read();
119-
if (done) break;
120-
receivedBytes += value.byteLength;
121-
onProgress({ receivedBytes, totalBytes });
122-
await new Promise<void>((resolve, reject) => {
123-
writeStream.write(value, (err) => (err ? reject(err) : resolve()));
124-
});
125-
}
126-
} catch (err) {
127-
// Deliberately not deleting partPath — a dropped connection here
128-
// leaves real, resumable progress on disk for the next attempt.
129-
// Waited out rather than fire-and-forget so the partial bytes are
130-
// actually flushed to disk before this function returns.
131-
await new Promise<void>((resolve) => writeStream.end(() => resolve()));
132-
throw err;
133-
}
134-
await new Promise<void>((resolve, reject) => {
135-
writeStream.once("error", reject);
136-
writeStream.end(() => resolve());
63+
const { downloadGgufFileNative } = await import("./native-downloader");
64+
await downloadGgufFileNative(modelId, filename, destPath, token ?? undefined, (err, progress) => {
65+
if (err) return; // fatal errors surface via the returned promise's rejection instead
66+
onProgress({ receivedBytes: progress.receivedBytes, totalBytes: progress.totalBytes ?? null });
13767
});
138-
if (totalBytes !== null && receivedBytes !== totalBytes) {
139-
throw new Error(
140-
`Download of "${filename}" was incomplete (got ${receivedBytes} of ${totalBytes} bytes) — try downloading it again to resume.`
141-
);
142-
}
143-
fs.renameSync(partPath, destPath);
14468
}

0 commit comments

Comments
 (0)