Skip to content

Commit d39eb04

Browse files
committed
feat: complete hardware-aware GGUF downloads
1 parent 481a5b1 commit d39eb04

18 files changed

Lines changed: 463 additions & 66 deletions

app/src/download-queue.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, it, expect, vi } from "vitest";
22
import { createJob, updateJob, listJobs, type DownloadShard, type DownloadJobState } from "./download-jobs-store";
3-
import { init, broadcast, resumeInterruptedJobs } from "./download-queue";
3+
import { init, broadcast, configure, resumeInterruptedJobs } from "./download-queue";
44

55
function shard(overrides: Partial<DownloadShard> = {}): DownloadShard {
66
return {
@@ -28,6 +28,10 @@ function makeJob(state: DownloadJobState) {
2828
}
2929

3030
describe("download-queue: broadcast plumbing", () => {
31+
it("normalizes controls without crashing when a plain dev build has no native addon", () => {
32+
expect(configure({ concurrency: 99, bandwidthMbps: -5 })).toEqual({ concurrency: 8, bandwidthMbps: 0 });
33+
});
34+
3135
it("does nothing when no window has been registered via init()", () => {
3236
expect(() => broadcast()).not.toThrow();
3337
});

app/src/download-queue.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as downloadWorker from "./download-worker";
55
import { createJob, deleteJob, flushJobs, getJob, listJobs, updateJob, type DownloadJob } from "./download-jobs-store";
66
import { getDownloadManager } from "./native-downloader";
77
import { detectModelFormat, getSpecs, resolveAutomaticRuntime } from "./system-specs";
8+
import { logger } from "./logger";
89

910
export interface DownloadControls { concurrency: number; bandwidthMbps: number }
1011
export interface RecoveryStatus { recoveredJobs: number; recoveredAt: string | null }
@@ -41,9 +42,17 @@ export function configure(controls: DownloadControls): DownloadControls {
4142
const concurrency = Number.isFinite(rawConcurrency) ? Math.max(1, Math.min(8, Math.floor(rawConcurrency))) : 2;
4243
const rawBandwidth = Number(controls?.bandwidthMbps);
4344
const bandwidthMbps = Number.isFinite(rawBandwidth) ? Math.max(0, Math.min(100_000, rawBandwidth)) : 0;
44-
const manager = getDownloadManager();
45-
manager.setGlobalConcurrency(concurrency);
46-
manager.setBandwidthLimit(bandwidthMbps > 0 ? bandwidthMbps * 1024 * 1024 / 8 : undefined);
45+
try {
46+
const manager = getDownloadManager();
47+
manager.setGlobalConcurrency(concurrency);
48+
manager.setBandwidthLimit(bandwidthMbps > 0 ? bandwidthMbps * 1024 * 1024 / 8 : undefined);
49+
} catch (error) {
50+
// Plain TypeScript/dev/E2E builds intentionally do not compile the
51+
// Rust addon. Keep the rest of app startup healthy; attempting an
52+
// actual download will still surface the missing-addon error. The
53+
// packaged build always runs build:native and includes app/native.
54+
logger.warn(`Native download controls unavailable: ${(error as Error).message}`);
55+
}
4756
return { concurrency, bandwidthMbps };
4857
}
4958

app/src/ipc/system-handlers.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,21 @@ export function registerSystemIpc(): void {
2121
const settings = settingsStore.getSettings();
2222
return systemSpecs.recommendModelsWithML(specs, { contextLength: settings.contextLength, quantization: "Q4_K_M", runtime: settings.preferredRuntime ?? "automatic", goal: settings.recommendationGoal ?? "balanced" }, getBenchmarkObservations());
2323
});
24+
ipcMain.handle("system:assessGgufFiles", async (_event, rawInputs: unknown) => {
25+
if (!Array.isArray(rawInputs) || rawInputs.length > 200) throw new Error("Expected at most 200 GGUF files.");
26+
const inputs = rawInputs.map((value) => {
27+
if (!value || typeof value !== "object") throw new Error("Invalid GGUF assessment input.");
28+
const input = value as Record<string, unknown>;
29+
const modelId = requireString(input.modelId, "Hugging Face model id");
30+
const filename = requireString(input.filename, "GGUF filename");
31+
const sizeBytes = input.sizeBytes === null ? null : Number(input.sizeBytes);
32+
if (sizeBytes !== null && (!Number.isFinite(sizeBytes) || sizeBytes < 0)) throw new Error("Invalid GGUF file size.");
33+
return { modelId, filename, sizeBytes };
34+
});
35+
const specs = await systemSpecs.getSpecs();
36+
const settings = settingsStore.getSettings();
37+
return systemSpecs.assessGgufFiles(specs, inputs, settings.contextLength);
38+
});
2439
ipcMain.handle("system:getActivity", async () => {
2540
const ollamaRunning = await ollama.isRunning();
2641
const ollamaLoadedModels = ollamaRunning

app/src/llamacpp-manager.test.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,19 @@ import { describe, it, expect, beforeEach } from "vitest";
22
import * as fs from "node:fs";
33
import * as os from "node:os";
44
import * as path from "node:path";
5-
import { deleteModel, groupShardedModels, listModels, normalizeLlamaCppRuntimeConfig } from "./llamacpp-manager";
5+
import { deleteModel, groupShardedModels, listModels, normalizeLlamaCppRuntimeConfig, resolveGpuLayers } from "./llamacpp-manager";
6+
7+
describe("resolveGpuLayers", () => {
8+
it("reserves VRAM for the user's requested context in automatic mode", () => {
9+
expect(resolveGpuLayers("auto", undefined, 32_768)).toEqual({ fitContext: { contextSize: 32_768 } });
10+
});
11+
12+
it("keeps CPU, all-layer, and manual modes distinct", () => {
13+
expect(resolveGpuLayers("cpu", undefined, 8_192)).toBe(0);
14+
expect(resolveGpuLayers("max", undefined, 8_192)).toBe("max");
15+
expect(resolveGpuLayers("manual", 24, 8_192)).toBe(24);
16+
});
17+
});
618

719
describe("groupShardedModels", () => {
820
it("leaves a normal single-file model untouched", () => {

app/src/llamacpp-manager.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -146,13 +146,21 @@ export function normalizeLlamaCppRuntimeConfig(input: LlamaCppRuntimeConfig = {}
146146
return { maxThreads: integer(input.maxThreads, 512), vramReserveBytes: bytes(input.vramReserveBytes), ramReserveBytes: bytes(input.ramReserveBytes), numa };
147147
}
148148

149-
function resolveGpuLayers(mode: GpuLayerMode | undefined, manualLayers: number | undefined): LlamaModelOptions["gpuLayers"] {
149+
export function resolveGpuLayers(mode: GpuLayerMode | undefined, manualLayers: number | undefined, contextLength?: number): LlamaModelOptions["gpuLayers"] {
150150
const resolvedMode = mode ?? (manualLayers === undefined ? "auto" : manualLayers === 0 ? "cpu" : "manual");
151151
if (resolvedMode === "cpu") return 0;
152-
// node-llama-cpp's "auto" is already maximum memory-safe placement and
153-
// preserves the Llama instance's VRAM padding. It is therefore also the
154-
// correct implementation of the user-facing "Maximum safe offload".
155-
if (resolvedMode === "auto" || resolvedMode === "max") return "auto";
152+
// Explicitly include the requested context in node-llama-cpp's model
153+
// placement calculation. Plain "auto" only reserves space for an
154+
// automatically-sized context, which can over-offload weights and then
155+
// fail when the user requests a larger context.
156+
if (resolvedMode === "auto") {
157+
return contextLength && Number.isFinite(contextLength)
158+
? { fitContext: { contextSize: Math.max(512, Math.floor(contextLength)) } }
159+
: "auto";
160+
}
161+
// Advanced escape hatch: request every layer and let node-llama-cpp fail
162+
// clearly when current VRAM cannot hold it.
163+
if (resolvedMode === "max") return "max";
156164
if (!Number.isInteger(manualLayers) || manualLayers! < 0) throw new Error("Manual GPU layer mode requires a non-negative integer layer count.");
157165
return manualLayers;
158166
}
@@ -614,7 +622,7 @@ export async function chat(
614622
}
615623
if (lastUserIndex === -1) throw new Error("No user message to respond to.");
616624

617-
const resolvedGpuLayers = resolveGpuLayers(options?.gpuLayerMode, options?.gpuLayers);
625+
const resolvedGpuLayers = resolveGpuLayers(options?.gpuLayerMode, options?.gpuLayers, options?.contextLength);
618626
const cacheKey = modelCacheKey(modelPath, resolvedGpuLayers);
619627
const model = await loadModel(modelPath, resolvedGpuLayers);
620628
// A transition can be announced while this call is awaiting a queued or

app/src/preload.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,17 @@ export const api = {
145145
ipcRenderer.on(channel, listener);
146146
const promise = ipcRenderer
147147
.invoke("chat:send", { requestId, provider, model, messages, options, agentMode })
148-
.finally(() => ipcRenderer.removeListener(channel, listener));
148+
// The invoke reply and a final webContents.send() chunk use
149+
// separate IPC routes. The reply can arrive first even when
150+
// main sent the chunk first; removing this listener
151+
// immediately dropped single-chunk tool calls and final usage
152+
// metadata. Keep it through one short renderer turn, and
153+
// delay promise resolution so Chat flushes those chunks before
154+
// it inspects the completed assistant message.
155+
.finally(() => new Promise<void>((resolve) => setTimeout(() => {
156+
ipcRenderer.removeListener(channel, listener);
157+
resolve();
158+
}, 25)));
149159
return { requestId, promise };
150160
},
151161

@@ -155,6 +165,7 @@ export const api = {
155165
system: {
156166
getSpecs: () => ipcRenderer.invoke("system:getSpecs"),
157167
getRecommendations: () => ipcRenderer.invoke("system:getRecommendations"),
168+
assessGgufFiles: (files: import("./system-specs").GgufAssessmentInput[]): Promise<import("./system-specs").GgufAssessment[]> => ipcRenderer.invoke("system:assessGgufFiles", files),
158169
getActivity: () => ipcRenderer.invoke("system:getActivity"),
159170
},
160171

app/src/providers/types.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,12 @@ export interface ChatOptions {
6363
maxTokens?: number;
6464
frequencyPenalty?: number;
6565
presencePenalty?: number;
66-
// Ollama-only: how much conversation history the model actually processes
67-
// (num_ctx). Cloud providers fix this per-model and don't expose it via API.
66+
// Local GGUF context window: Ollama maps this to num_ctx and the built-in
67+
// llama.cpp runtime maps it to createContext({ contextSize }). Cloud
68+
// providers fix this per model and don't expose it via API.
6869
contextLength?: number;
69-
// Ollama-only: how many model layers to offload to GPU (num_gpu).
70-
// undefined = let Ollama auto-decide, 0 = force CPU-only, a positive
71-
// number = offload that many layers (useful for tuning multi-GPU setups
72-
// or freeing VRAM for something else running alongside Ollama).
70+
// Local GGUF GPU placement. Ollama maps a manual count to num_gpu; the
71+
// built-in llama.cpp runtime additionally supports gpuLayerMode below.
7372
gpuLayers?: number;
7473
// Built-in node-llama-cpp controls. Other providers ignore these fields.
7574
gpuLayerMode?: GpuLayerMode;

app/src/system-specs.test.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from "vitest";
2-
import { classifyGpuVendor, computeGpuTopology, detectModelFormat, parseMigInstancesFromNvidiaSmiL, recommendModels, recommendModelsWithML, resolveAutomaticRuntime, type GpuInfo, type GpuTopology, type SystemSpecs } from "./system-specs";
2+
import { assessGgufFiles, classifyGpuVendor, computeGpuTopology, detectGgufQuantization, detectModelFormat, parseMigInstancesFromNvidiaSmiL, recommendModels, recommendModelsWithML, resolveAutomaticRuntime, type GpuInfo, type GpuTopology, type SystemSpecs } from "./system-specs";
33

44
function baseTopology(overrides: Partial<GpuTopology> = {}): GpuTopology {
55
return {
@@ -253,6 +253,41 @@ describe("detectModelFormat", () => {
253253
});
254254
});
255255

256+
describe("assessGgufFiles", () => {
257+
it("extracts common GGUF quantizations without confusing model names", () => {
258+
expect(detectGgufQuantization("Kimi-Q3_K_L.gguf")).toEqual({ label: "Q3_K_L", bits: 3.69 });
259+
expect(detectGgufQuantization("model-IQ2_XXS.gguf").label).toBe("IQ2_XXS");
260+
expect(detectGgufQuantization("model-UD-Q4_K_XL.gguf").label).toBe("UD-Q4_K_XL");
261+
expect(detectGgufQuantization("model.gguf").label).toBe("GGUF");
262+
});
263+
264+
it("reports a full-GPU fit and a non-zero speed estimate", () => {
265+
const specs = baseSpecs({
266+
freeRAMGB: 32,
267+
gpu: { name: "RTX 4090", vramGB: 24, vendor: "nvidia" },
268+
gpus: [{ name: "RTX 4090", vramGB: 24, vendor: "nvidia" }],
269+
totalVramGB: 24,
270+
largestGpuVramGB: 24,
271+
});
272+
const [result] = assessGgufFiles(specs, [{ modelId: "org/model", filename: "model-Q4_K_M.gguf", sizeBytes: 8_000_000_000 }]);
273+
expect(result.outcome).toBe("Runs fully on GPU");
274+
expect(result.fits).toBe(true);
275+
expect(result.totalRequiredGB).toBeGreaterThan(8);
276+
expect(result.estimatedTokensPerSecond).toBeGreaterThan(0);
277+
});
278+
279+
it("marks an oversized file unsafe and handles missing sizes honestly", () => {
280+
const [oversized, unknown] = assessGgufFiles(baseSpecs({ totalRAMGB: 16, freeRAMGB: 8 }), [
281+
{ modelId: "org/model", filename: "huge-Q8_0.gguf", sizeBytes: 80_000_000_000 },
282+
{ modelId: "org/model", filename: "unknown.gguf", sizeBytes: null },
283+
]);
284+
expect(oversized.outcome).toBe("Likely out of memory");
285+
expect(oversized.estimatedTokensPerSecond).toBe(0);
286+
expect(unknown.canAssess).toBe(false);
287+
expect(unknown.fits).toBeNull();
288+
});
289+
});
290+
256291
describe("resolveAutomaticRuntime", () => {
257292
const linuxNvidia = { platform: "linux" as const, arch: "x64", gpus: [{ name: "RTX 4090", vramGB: 24, vendor: "nvidia" as const }] };
258293
const linuxAmd = { platform: "linux" as const, arch: "x64", gpus: [{ name: "Radeon RX 7900", vramGB: 24, vendor: "amd" as const }] };

0 commit comments

Comments
 (0)