Skip to content

Commit 1a14af8

Browse files
committed
Add multi-GPU detection/VRAM summing, GPU offload control, and Hugging Face model pull support
1 parent 544cd9f commit 1a14af8

11 files changed

Lines changed: 259 additions & 49 deletions

File tree

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ Beyond chat, Modelforge includes an **agentic mode** — the model can read/writ
4343

4444
**Agent mode** — see the [dedicated section](#agent-mode) below.
4545

46+
**Models & hardware**
47+
- Model recommendations based on your actual hardware — RAM and VRAM are detected and summed **across all GPUs**, not just the first one, so multi-GPU machines get accurate suggestions.
48+
- **GPU offload control** — set how many model layers Ollama offloads to GPU (`num_gpu`) per chat, per project, or as a global default; leave it blank to let Ollama decide automatically.
49+
- **Pull models directly from Hugging Face** — paste any GGUF model's Hugging Face URL (or `hf.co/user/repo`) into the model search box alongside the curated catalog and Ollama's own library.
50+
- Models with reliable tool/function-calling support are flagged with a 🔧 badge, so picking a good Agent mode model doesn't require guesswork.
51+
4652
**Customization & control**
4753
- English and Turkish UI localization.
4854
- Configurable Ollama host — point at a remote server instead of localhost.

‎app/src/ollama-manager.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ export async function chat(
231231
frequency_penalty: options?.frequencyPenalty,
232232
presence_penalty: options?.presencePenalty,
233233
num_ctx: options?.contextLength,
234+
num_gpu: options?.gpuLayers,
234235
},
235236
}),
236237
signal,

‎app/src/providers/types.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ export interface ChatOptions {
5656
// Ollama-only: how much conversation history the model actually processes
5757
// (num_ctx). Cloud providers fix this per-model and don't expose it via API.
5858
contextLength?: number;
59+
// Ollama-only: how many model layers to offload to GPU (num_gpu).
60+
// undefined = let Ollama auto-decide, 0 = force CPU-only, a positive
61+
// number = offload that many layers (useful for tuning multi-GPU setups
62+
// or freeing VRAM for something else running alongside Ollama).
63+
gpuLayers?: number;
5964
}
6065

6166
export type ChatFn = (

‎app/src/settings-store.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export interface AppSettings {
1717
frequencyPenalty: number;
1818
presencePenalty: number;
1919
contextLength: number;
20+
// undefined = auto (let Ollama decide how many layers to offload to GPU).
21+
gpuLayers?: number;
2022
systemPrompt: string;
2123
promptPresets: PromptPreset[];
2224
theme: "light" | "dark" | "system";

‎app/src/system-specs.test.ts‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe, it, expect } from "vitest";
2+
import { recommendModels, type SystemSpecs } from "./system-specs";
3+
4+
function baseSpecs(overrides: Partial<SystemSpecs> = {}): SystemSpecs {
5+
return {
6+
totalRAMGB: 16,
7+
freeRAMGB: 8,
8+
cpuModel: "Test CPU",
9+
cpuCores: 8,
10+
platform: "linux",
11+
arch: "x64",
12+
gpu: null,
13+
gpus: [],
14+
totalVramGB: null,
15+
...overrides,
16+
};
17+
}
18+
19+
describe("recommendModels", () => {
20+
it("falls back to RAM-based sizing when there's no GPU", () => {
21+
const result = recommendModels(baseSpecs({ totalRAMGB: 16 }));
22+
expect(result.usableVRAMGB).toBe(0);
23+
expect(result.usableRAMGB).toBeCloseTo(11.2);
24+
});
25+
26+
it("sums VRAM across multiple GPUs instead of using only the first one", () => {
27+
const specs = baseSpecs({
28+
gpu: { name: "GPU 0", vramGB: 24, vendor: "nvidia" },
29+
gpus: [
30+
{ name: "GPU 0", vramGB: 24, vendor: "nvidia" },
31+
{ name: "GPU 1", vramGB: 24, vendor: "nvidia" },
32+
],
33+
totalVramGB: 48,
34+
});
35+
const result = recommendModels(specs);
36+
// 48GB total * 0.9 headroom factor
37+
expect(result.usableVRAMGB).toBeCloseTo(43.2);
38+
});
39+
40+
it("uses whichever of RAM or VRAM gives more usable headroom", () => {
41+
const specs = baseSpecs({
42+
totalRAMGB: 8,
43+
gpu: { name: "Big GPU", vramGB: 80, vendor: "nvidia" },
44+
gpus: [{ name: "Big GPU", vramGB: 80, vendor: "nvidia" }],
45+
totalVramGB: 80,
46+
});
47+
const result = recommendModels(specs);
48+
expect(result.effectiveGB).toBeCloseTo(result.usableVRAMGB);
49+
expect(result.usableVRAMGB).toBeGreaterThan(result.usableRAMGB);
50+
});
51+
52+
it("marks a model as runsOnGpu only when it fits within usable VRAM", () => {
53+
const specs = baseSpecs({
54+
totalRAMGB: 80,
55+
gpu: { name: "Small GPU", vramGB: 4, vendor: "nvidia" },
56+
gpus: [{ name: "Small GPU", vramGB: 4, vendor: "nvidia" }],
57+
totalVramGB: 4,
58+
});
59+
const result = recommendModels(specs);
60+
const tiny = result.models.find((m) => m.name === "llama3.2:1b")!;
61+
const large = result.models.find((m) => m.name === "llama3.1:70b")!;
62+
expect(tiny.runsOnGpu).toBe(true);
63+
expect(large.runsOnGpu).toBe(false);
64+
// Still fits overall thanks to the large RAM pool, just not on the GPU.
65+
expect(large.fits).toBe(true);
66+
});
67+
});

‎app/src/system-specs.ts‎

Lines changed: 45 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@ export interface SystemSpecs {
1414
cpuCores: number;
1515
platform: NodeJS.Platform;
1616
arch: string;
17+
// Kept for backward compatibility with anything expecting a single GPU —
18+
// this is just gpus[0]. Prefer `gpus` and `totalVramGB` for anything
19+
// multi-GPU-aware.
1720
gpu: GpuInfo | null;
21+
gpus: GpuInfo[];
22+
totalVramGB: number | null;
1823
}
1924

2025
export interface ModelCatalogEntry {
@@ -72,17 +77,25 @@ function execFileP(cmd: string, args: string[]): Promise<string | null> {
7277
});
7378
}
7479

75-
async function detectGpu(): Promise<GpuInfo | null> {
76-
// NVIDIA tooling works the same way on Windows and Linux when drivers are installed.
80+
// Returns every GPU nvidia-smi/PowerShell/system_profiler reports, not just
81+
// the first one — a single-GPU read silently undercounts available VRAM
82+
// (and therefore which models "fit") on any multi-GPU machine.
83+
async function detectGpus(): Promise<GpuInfo[]> {
84+
// NVIDIA tooling works the same way on Windows and Linux when drivers are
85+
// installed, and reports one CSV line per GPU when there's more than one.
7786
const nvidiaOut = await execFileP("nvidia-smi", [
7887
"--query-gpu=name,memory.total",
7988
"--format=csv,noheader,nounits",
8089
]);
8190
if (nvidiaOut) {
82-
const [name, memMiB] = nvidiaOut.split(",").map((s) => s.trim());
83-
if (name && memMiB && !Number.isNaN(Number(memMiB))) {
84-
return { name, vramGB: +(Number(memMiB) / 1024).toFixed(1), vendor: "nvidia" };
91+
const gpus: GpuInfo[] = [];
92+
for (const line of nvidiaOut.trim().split("\n")) {
93+
const [name, memMiB] = line.split(",").map((s) => s.trim());
94+
if (name && memMiB && !Number.isNaN(Number(memMiB))) {
95+
gpus.push({ name, vramGB: +(Number(memMiB) / 1024).toFixed(1), vendor: "nvidia" });
96+
}
8597
}
98+
if (gpus.length > 0) return gpus;
8699
}
87100

88101
if (os.platform() === "win32") {
@@ -94,17 +107,20 @@ async function detectGpu(): Promise<GpuInfo | null> {
94107
if (out) {
95108
try {
96109
const parsed = JSON.parse(out);
97-
const gpu = Array.isArray(parsed) ? parsed[0] : parsed;
98-
// AdapterRAM is a known-buggy 32-bit field on Windows for GPUs with >4GB VRAM,
99-
// so only trust it when it lands in a plausible range.
100-
const ramGB = gpu?.AdapterRAM ? gpu.AdapterRAM / 1e9 : 0;
101-
if (gpu?.Name) {
102-
return {
110+
const list = Array.isArray(parsed) ? parsed : [parsed];
111+
const gpus: GpuInfo[] = [];
112+
for (const gpu of list) {
113+
if (!gpu?.Name) continue;
114+
// AdapterRAM is a known-buggy 32-bit field on Windows for GPUs with >4GB VRAM,
115+
// so only trust it when it lands in a plausible range.
116+
const ramGB = gpu.AdapterRAM ? gpu.AdapterRAM / 1e9 : 0;
117+
gpus.push({
103118
name: gpu.Name,
104119
vramGB: ramGB > 0 && ramGB < 64 ? +ramGB.toFixed(1) : null,
105120
vendor: "unknown",
106-
};
121+
});
107122
}
123+
if (gpus.length > 0) return gpus;
108124
} catch {
109125
// ignore malformed output
110126
}
@@ -113,34 +129,43 @@ async function detectGpu(): Promise<GpuInfo | null> {
113129

114130
if (os.platform() === "darwin") {
115131
const out = await execFileP("system_profiler", ["SPDisplaysDataType"]);
116-
const nameMatch = out?.match(/Chipset Model:\s*(.+)/);
117-
if (nameMatch) {
118-
// Apple Silicon GPUs share unified memory with the CPU rather than dedicated VRAM.
119-
return { name: nameMatch[1].trim(), vramGB: null, vendor: "apple" };
132+
if (out) {
133+
const gpus: GpuInfo[] = [];
134+
// Apple Silicon GPUs share unified memory with the CPU rather than
135+
// dedicated VRAM, so there's no per-GPU size to report here.
136+
for (const match of out.matchAll(/Chipset Model:\s*(.+)/g)) {
137+
gpus.push({ name: match[1].trim(), vramGB: null, vendor: "apple" });
138+
}
139+
if (gpus.length > 0) return gpus;
120140
}
121141
}
122142

123-
return null;
143+
return [];
124144
}
125145

126146
export async function getSpecs(): Promise<SystemSpecs> {
127147
const cpus = os.cpus() || [];
128-
const gpu = await detectGpu();
148+
const gpus = await detectGpus();
149+
const knownVram = gpus.map((g) => g.vramGB).filter((v): v is number => v !== null);
150+
const totalVramGB = knownVram.length > 0 ? +knownVram.reduce((a, b) => a + b, 0).toFixed(1) : null;
151+
129152
return {
130153
totalRAMGB: +(os.totalmem() / 1e9).toFixed(1),
131154
freeRAMGB: +(os.freemem() / 1e9).toFixed(1),
132155
cpuModel: cpus[0] ? cpus[0].model : "Unknown CPU",
133156
cpuCores: cpus.length,
134157
platform: os.platform(),
135158
arch: os.arch(),
136-
gpu,
159+
gpu: gpus[0] ?? null,
160+
gpus,
161+
totalVramGB,
137162
};
138163
}
139164

140165
export function recommendModels(specs: SystemSpecs): ModelRecommendations {
141166
// Leave headroom for the OS and the app itself on both RAM and VRAM.
142167
const usableRAMGB = +(specs.totalRAMGB * 0.7).toFixed(1);
143-
const usableVRAMGB = specs.gpu?.vramGB ? +(specs.gpu.vramGB * 0.9).toFixed(1) : 0;
168+
const usableVRAMGB = specs.totalVramGB ? +(specs.totalVramGB * 0.9).toFixed(1) : 0;
144169
const effectiveGB = Math.max(usableRAMGB, usableVRAMGB);
145170

146171
const fitting = MODEL_CATALOG.filter((m) => m.minRAMGB <= effectiveGB);

‎frontend/src/components/layout.tsx‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,22 @@ function ProjectGroup({
271271
className="h-8 text-xs"
272272
/>
273273
</div>
274+
<div className="flex flex-col gap-1">
275+
<label className="text-xs text-muted-foreground">{t.gpuLayers}</label>
276+
<Input
277+
type="number"
278+
min={0}
279+
step={1}
280+
placeholder={t.gpuLayersAuto}
281+
value={params.gpuLayers ?? ""}
282+
onChange={(e) =>
283+
updateParam({ gpuLayers: e.target.value === "" ? undefined : Number(e.target.value) })
284+
}
285+
aria-label={t.gpuLayers}
286+
title={t.gpuLayersHelp}
287+
className="h-8 text-xs"
288+
/>
289+
</div>
274290
<div className="flex flex-col gap-1">
275291
<label className="text-xs text-muted-foreground">{t.frequencyPenalty}</label>
276292
<Input

‎frontend/src/lib/translations.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ export interface Dictionary {
1515
attachFiles: string;
1616
attachProjectFolder: string;
1717
noOllamaModelsInstalled: string;
18+
huggingFaceHint: string;
19+
pullFromHuggingFace: string;
20+
pullExactTag: string;
1821
agentMode: string;
1922
agentModeTooltip: string;
2023
changeFolder: string;
@@ -46,6 +49,9 @@ export interface Dictionary {
4649
topP: string;
4750
maxTokens: string;
4851
contextLength: string;
52+
gpuLayers: string;
53+
gpuLayersAuto: string;
54+
gpuLayersHelp: string;
4955
contextLengthOllamaOnly: string;
5056
frequencyPenalty: string;
5157
presencePenalty: string;
@@ -98,6 +104,9 @@ export const en: Dictionary = {
98104
attachFiles: "Attach files",
99105
attachProjectFolder: "Attach project folder",
100106
noOllamaModelsInstalled: "No Ollama models installed — go to Settings to install one.",
107+
huggingFaceHint: "Tip: paste any GGUF model's Hugging Face URL (or hf.co/user/repo) to pull it directly — you're not limited to the catalog above.",
108+
pullFromHuggingFace: "Pull this GGUF model directly from Hugging Face.",
109+
pullExactTag: "Not in the catalog — pull this exact model tag from Ollama's library.",
101110
agentMode: "Agent",
102111
agentModeTooltip: "Agent mode: gives the model file tools (read/write/list/search) and shell command execution, scoped to a folder you choose. Every tool call needs your approval.",
103112
changeFolder: "Change folder",
@@ -130,6 +139,9 @@ export const en: Dictionary = {
130139
topP: "Top P",
131140
maxTokens: "Max tokens",
132141
contextLength: "Context length",
142+
gpuLayers: "GPU layers",
143+
gpuLayersAuto: "Auto",
144+
gpuLayersHelp: "How many model layers to offload to the GPU (Ollama's num_gpu). Leave blank to let Ollama decide automatically; 0 forces CPU-only.",
133145
contextLengthOllamaOnly: " (Ollama only)",
134146
frequencyPenalty: "Frequency penalty",
135147
presencePenalty: "Presence penalty",
@@ -183,6 +195,9 @@ export const tr: Dictionary = {
183195
attachFiles: "Dosya ekle",
184196
attachProjectFolder: "Proje klasörü ekle",
185197
noOllamaModelsInstalled: "Yüklü Ollama modeli yok — birini yüklemek için Ayarlar'a gidin.",
198+
huggingFaceHint: "İpucu: herhangi bir GGUF modelinin Hugging Face bağlantısını (veya hf.co/kullanıcı/depo) yapıştırarak doğrudan indirebilirsiniz — yukarıdaki katalogla sınırlı değilsiniz.",
199+
pullFromHuggingFace: "Bu GGUF modelini doğrudan Hugging Face'ten indir.",
200+
pullExactTag: "Katalogda yok — bu tam model etiketini Ollama kütüphanesinden indirin.",
186201
agentMode: "Ajan",
187202
agentModeTooltip: "Ajan modu: modele seçtiğiniz bir klasörle sınırlı dosya araçları (okuma/yazma/listeleme/arama) ve kabuk komutu çalıştırma verir. Her araç çağrısı onayınızı gerektirir.",
188203
changeFolder: "Klasörü değiştir",
@@ -216,6 +231,9 @@ export const tr: Dictionary = {
216231
topP: "Top P",
217232
maxTokens: "Maksimum token",
218233
contextLength: "Bağlam uzunluğu",
234+
gpuLayers: "GPU katmanları",
235+
gpuLayersAuto: "Otomatik",
236+
gpuLayersHelp: "GPU'ya kaç model katmanının aktarılacağı (Ollama'nın num_gpu ayarı). Ollama'nın otomatik karar vermesi için boş bırakın; 0 sadece CPU kullanımını zorlar.",
219237
contextLengthOllamaOnly: " (yalnızca Ollama)",
220238
frequencyPenalty: "Sıklık cezası",
221239
presencePenalty: "Varlık cezası",

‎frontend/src/pages/Chat.tsx‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,7 @@ export default function Chat() {
506506
params.frequencyPenalty ?? project?.params?.frequencyPenalty ?? settings?.frequencyPenalty,
507507
presencePenalty: params.presencePenalty ?? project?.params?.presencePenalty ?? settings?.presencePenalty,
508508
contextLength: params.contextLength ?? project?.params?.contextLength ?? settings?.contextLength,
509+
gpuLayers: params.gpuLayers ?? project?.params?.gpuLayers ?? settings?.gpuLayers,
509510
};
510511
}
511512

@@ -1026,6 +1027,25 @@ export default function Chat() {
10261027
className="h-8 text-xs"
10271028
/>
10281029
</div>
1030+
<div className="flex flex-col gap-1">
1031+
<label className="text-xs text-muted-foreground">
1032+
{t.gpuLayers}
1033+
{parsedModel?.provider !== "ollama" ? " (Ollama only)" : ""}
1034+
</label>
1035+
<Input
1036+
type="number"
1037+
min={0}
1038+
step={1}
1039+
placeholder={t.gpuLayersAuto}
1040+
title={t.gpuLayersHelp}
1041+
value={params.gpuLayers ?? currentProject?.params?.gpuLayers ?? settings?.gpuLayers ?? ""}
1042+
onChange={(e) =>
1043+
updateParam({ gpuLayers: e.target.value === "" ? undefined : Number(e.target.value) })
1044+
}
1045+
disabled={parsedModel?.provider !== "ollama"}
1046+
className="h-8 text-xs"
1047+
/>
1048+
</div>
10291049
<div className="flex flex-col gap-1">
10301050
<label className="text-xs text-muted-foreground">Freq. penalty</label>
10311051
<Input

0 commit comments

Comments
 (0)