feat(vscode): add Local Models settings page for managing Ollama models

This commit is contained in:
Saoud Rizwan
2026-10-19 16:59:12 +00:00
parent 41e75efc53
commit 387bdedd8b
16 changed files with 986 additions and 1 deletions
+1
View File
@@ -524,6 +524,7 @@
"nice-grpc": "^2.1.12",
"nice-grpc-common": "^2.0.3",
"node-machine-id": "^1.1.12",
"ollama": "^0.6.4",
"open": "^10.1.2",
"open-graph-scraper": "^6.9.0",
"openai": "^6.21.0",
+65
View File
@@ -65,6 +65,71 @@ service ModelsService {
rpc writeProviderConfig(WriteProviderConfigRequest) returns (ProviderConfigResponse);
// Commits a mode-specific model ID with optional user-authored metadata overrides
rpc commitModelSelection(CommitModelSelectionRequest) returns (Empty);
// Reports the local machine's hardware and the Ollama server's installed, loaded, and downloading models
rpc getLocalModelsStatus(StringRequest) returns (LocalModelsStatus);
// Downloads an Ollama model, streaming progress. Re-subscribes if the model is already downloading.
rpc pullOllamaModel(OllamaModelRequest) returns (stream OllamaPullProgress);
// Cancels an in-progress Ollama model download
rpc cancelOllamaPull(OllamaModelRequest) returns (Empty);
// Loads an Ollama model into memory
rpc loadOllamaModel(OllamaModelRequest) returns (Empty);
// Unloads an Ollama model from memory
rpc unloadOllamaModel(OllamaModelRequest) returns (Empty);
// Deletes a downloaded Ollama model
rpc deleteOllamaModel(OllamaModelRequest) returns (Empty);
}
message OllamaModelRequest {
string base_url = 1;
string model = 2;
optional int64 context_window = 3;
}
message LocalGpuInfo {
string name = 1;
double memory_gb = 2;
}
message LocalHardwareInfo {
string platform = 1;
string arch = 2;
string cpu_model = 3;
int32 cpu_cores = 4;
double total_memory_gb = 5;
// True on Apple Silicon, where the GPU shares system memory
bool unified_memory = 6;
repeated LocalGpuInfo gpus = 7;
}
message OllamaInstalledModel {
string name = 1;
int64 size_bytes = 2;
string parameter_size = 3;
string quantization = 4;
}
message OllamaRunningModel {
string name = 1;
int64 size_bytes = 2;
int64 size_vram_bytes = 3;
int64 expires_at_ms = 4;
int64 context_length = 5;
}
message OllamaPullProgress {
string model = 1;
string status = 2;
int64 completed = 3;
int64 total = 4;
}
message LocalModelsStatus {
LocalHardwareInfo hardware = 1;
bool ollama_reachable = 2;
string ollama_version = 3;
repeated OllamaInstalledModel installed = 4;
repeated OllamaRunningModel running = 5;
repeated OllamaPullProgress active_pulls = 6;
}
// List of VS Code LM models
@@ -0,0 +1,9 @@
import { Empty } from "@shared/proto/cline/common"
import { OllamaModelRequest } from "@shared/proto/cline/models"
import { cancelOllamaPull as cancelPull } from "@/services/local-models/ollama"
import { Controller } from ".."
export async function cancelOllamaPull(_controller: Controller, request: OllamaModelRequest): Promise<Empty> {
cancelPull(request.model)
return Empty.create()
}
@@ -0,0 +1,9 @@
import { Empty } from "@shared/proto/cline/common"
import { OllamaModelRequest } from "@shared/proto/cline/models"
import { createOllamaClient } from "@/services/local-models/ollama"
import { Controller } from ".."
export async function deleteOllamaModel(_controller: Controller, request: OllamaModelRequest): Promise<Empty> {
await createOllamaClient(request.baseUrl).delete({ model: request.model })
return Empty.create()
}
@@ -0,0 +1,39 @@
import { StringRequest } from "@shared/proto/cline/common"
import { LocalModelsStatus, OllamaInstalledModel, OllamaRunningModel } from "@shared/proto/cline/models"
import { getLocalHardwareInfo } from "@/services/local-models/hardware"
import { createOllamaClient, getActiveOllamaPulls } from "@/services/local-models/ollama"
import { Controller } from ".."
export async function getLocalModelsStatus(_controller: Controller, request: StringRequest): Promise<LocalModelsStatus> {
const hardware = await getLocalHardwareInfo()
const client = createOllamaClient(request.value)
try {
const [{ version }, list, ps] = await Promise.all([client.version(), client.list(), client.ps()])
return LocalModelsStatus.create({
hardware,
ollamaReachable: true,
ollamaVersion: version,
installed: list.models.map((model) =>
OllamaInstalledModel.create({
name: model.name,
sizeBytes: model.size,
parameterSize: model.details?.parameter_size ?? "",
quantization: model.details?.quantization_level ?? "",
}),
),
running: ps.models.map((model) =>
OllamaRunningModel.create({
name: model.name,
sizeBytes: model.size,
sizeVramBytes: model.size_vram,
expiresAtMs: model.expires_at ? new Date(model.expires_at).getTime() : 0,
// Reported by Ollama 0.7+, but not yet in the SDK's types.
contextLength: (model as { context_length?: number }).context_length ?? 0,
}),
),
activePulls: getActiveOllamaPulls(),
})
} catch {
return LocalModelsStatus.create({ hardware, ollamaReachable: false, activePulls: getActiveOllamaPulls() })
}
}
@@ -0,0 +1,20 @@
import { Empty } from "@shared/proto/cline/common"
import { OllamaModelRequest } from "@shared/proto/cline/models"
import { createOllamaClient } from "@/services/local-models/ollama"
import { Controller } from ".."
/**
* Loads a model into memory by sending it an empty prompt. `num_ctx` must
* match what Cline sends with each request, otherwise Ollama reloads the model
* on the first message.
*/
export async function loadOllamaModel(_controller: Controller, request: OllamaModelRequest): Promise<Empty> {
await createOllamaClient(request.baseUrl).generate({
model: request.model,
prompt: "",
// Stay loaded until explicitly unloaded. Cline's own requests reset this to the server's keep-alive.
keep_alive: -1,
...(request.contextWindow ? { options: { num_ctx: request.contextWindow } } : {}),
})
return Empty.create()
}
@@ -0,0 +1,52 @@
import { OllamaModelRequest, OllamaPullProgress } from "@shared/proto/cline/models"
import { getOrStartOllamaPull } from "@/services/local-models/ollama"
import { getRequestRegistry, StreamingResponseHandler } from "../grpc-handler"
import { Controller } from "../index"
/**
* Streams download progress for an Ollama model. Cancelling the stream only
* unsubscribes; the download keeps going until it finishes or
* `cancelOllamaPull` is called.
*/
export async function pullOllamaModel(
_controller: Controller,
request: OllamaModelRequest,
responseStream: StreamingResponseHandler<OllamaPullProgress>,
requestId?: string,
): Promise<void> {
const model = request.model.trim()
if (!model) {
throw new Error("Model name is required")
}
const pull = getOrStartOllamaPull(request.baseUrl, model)
let subscribed = true
const listener = (progress: OllamaPullProgress) => {
void responseStream(progress, false).catch(() => {})
}
const unsubscribe = () => {
subscribed = false
pull.listeners.delete(listener)
}
pull.listeners.add(listener)
if (requestId) {
getRequestRegistry().registerRequest(requestId, unsubscribe, { type: "ollama_pull" }, responseStream)
}
listener(pull.progress)
try {
await pull.done
if (subscribed) {
await responseStream(OllamaPullProgress.create({ model, status: "success" }), true)
}
} catch (error) {
if (subscribed) {
throw error
}
} finally {
if (requestId) {
getRequestRegistry().cancelRequest(requestId)
}
unsubscribe()
}
}
@@ -0,0 +1,9 @@
import { Empty } from "@shared/proto/cline/common"
import { OllamaModelRequest } from "@shared/proto/cline/models"
import { createOllamaClient } from "@/services/local-models/ollama"
import { Controller } from ".."
export async function unloadOllamaModel(_controller: Controller, request: OllamaModelRequest): Promise<Empty> {
await createOllamaClient(request.baseUrl).generate({ model: request.model, prompt: "", keep_alive: 0 })
return Empty.create()
}
@@ -0,0 +1,48 @@
import { execFile } from "child_process"
import os from "os"
import { promisify } from "util"
import { LocalGpuInfo, LocalHardwareInfo } from "@/shared/proto/cline/models"
const execFileAsync = promisify(execFile)
const BYTES_PER_GB = 1024 ** 3
let hardwarePromise: Promise<LocalHardwareInfo> | undefined
async function detectNvidiaGpus(): Promise<LocalGpuInfo[]> {
try {
const { stdout } = await execFileAsync("nvidia-smi", ["--query-gpu=name,memory.total", "--format=csv,noheader,nounits"], {
timeout: 3000,
windowsHide: true,
})
return stdout
.split("\n")
.map((line) => line.split(",").map((part) => part.trim()))
.filter(([name, memoryMib]) => name && Number(memoryMib) > 0)
.map(([name, memoryMib]) => LocalGpuInfo.create({ name, memoryGb: Number(memoryMib) / 1024 }))
} catch {
return []
}
}
async function detectHardware(): Promise<LocalHardwareInfo> {
const platform = os.platform()
const arch = os.arch()
const cpus = os.cpus()
// Apple Silicon GPUs share system memory, so there is no separate VRAM to query.
const unifiedMemory = platform === "darwin" && arch === "arm64"
return LocalHardwareInfo.create({
platform,
arch,
cpuModel: cpus[0]?.model.trim() ?? "",
cpuCores: cpus.length,
totalMemoryGb: os.totalmem() / BYTES_PER_GB,
unifiedMemory,
gpus: unifiedMemory ? [] : await detectNvidiaGpus(),
})
}
/** Hardware doesn't change while the extension runs, so detection happens once. */
export function getLocalHardwareInfo(): Promise<LocalHardwareInfo> {
hardwarePromise ??= detectHardware()
return hardwarePromise
}
@@ -0,0 +1,92 @@
import { Ollama } from "ollama"
import { fetch } from "@/shared/net"
import { OllamaPullProgress } from "@/shared/proto/cline/models"
export const DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434"
const PROGRESS_THROTTLE_MS = 250
type PullListener = (progress: OllamaPullProgress) => void
interface ActivePull {
progress: OllamaPullProgress
listeners: Set<PullListener>
abort: () => void
done: Promise<void>
}
// Downloads outlive the settings page that started them (an 18 GB pull takes a
// while), so they are tracked here and any reopened page can re-subscribe.
const activePulls = new Map<string, ActivePull>()
export function createOllamaClient(baseUrl: string | undefined): Ollama {
const host = baseUrl?.trim().replace(/\/+$/, "") || DEFAULT_OLLAMA_BASE_URL
return new Ollama({ host, fetch })
}
export function getActiveOllamaPulls(): OllamaPullProgress[] {
return [...activePulls.values()].map((pull) => pull.progress)
}
export function getOrStartOllamaPull(baseUrl: string | undefined, model: string): ActivePull {
const existing = activePulls.get(model)
if (existing) {
return existing
}
const client = createOllamaClient(baseUrl)
let aborted = false
let abortStream: (() => void) | undefined
const pull: ActivePull = {
progress: OllamaPullProgress.create({ model, status: "starting" }),
listeners: new Set(),
abort: () => {
aborted = true
abortStream?.()
},
done: Promise.resolve(),
}
pull.done = (async () => {
try {
const stream = await client.pull({ model, stream: true })
abortStream = () => stream.abort()
if (aborted) {
stream.abort()
}
let lastNotifiedAt = 0
for await (const part of stream) {
const statusChanged = part.status !== pull.progress.status
pull.progress = OllamaPullProgress.create({
model,
status: part.status,
// Layer-less status lines ("pulling manifest") carry no byte counts; keep the last ones.
completed: part.completed ?? (statusChanged ? 0 : pull.progress.completed),
total: part.total ?? (statusChanged ? 0 : pull.progress.total),
})
const now = Date.now()
if (statusChanged || now - lastNotifiedAt >= PROGRESS_THROTTLE_MS) {
lastNotifiedAt = now
for (const listener of pull.listeners) {
listener(pull.progress)
}
}
}
} catch (error) {
if (aborted) {
throw new Error("Download cancelled")
}
throw error
} finally {
activePulls.delete(model)
}
})()
// Rejections are surfaced to subscribers; avoid an unhandled rejection when nobody is listening.
pull.done.catch(() => {})
activePulls.set(model, pull)
return pull
}
export function cancelOllamaPull(model: string): void {
activePulls.get(model)?.abort()
}
@@ -0,0 +1,38 @@
import { describe, expect, it } from "bun:test"
import { evaluateLocalModelFit, getRecommendedLocalModel, type LocalHardware, normalizeOllamaModelName } from "./local-models"
const mac = (totalMemoryGb: number): LocalHardware => ({ totalMemoryGb, unifiedMemory: true, gpus: [] })
const pc = (totalMemoryGb: number, vramGb?: number): LocalHardware => ({
totalMemoryGb,
unifiedMemory: false,
gpus: vramGb ? [{ name: "GPU", memoryGb: vramGb }] : [],
})
describe("local models", () => {
it("rates fit against GPU memory first, then system memory", () => {
expect(evaluateLocalModelFit(23, pc(64, 24))).toBe("great")
expect(evaluateLocalModelFit(23, pc(32, 12))).toBe("slow")
expect(evaluateLocalModelFit(56, pc(16, 8))).toBe("too-large")
expect(evaluateLocalModelFit(10, pc(32))).toBe("slow")
})
it("recommends Qwen3.8 27B when it fits on the GPU", () => {
expect(getRecommendedLocalModel(mac(32))?.id).toBe("qwen3.8:27b")
expect(getRecommendedLocalModel(pc(32, 24))?.id).toBe("qwen3.8:27b")
})
it("falls back to smaller models on smaller machines", () => {
expect(getRecommendedLocalModel(mac(24))?.id).toBe("gpt-oss:20b")
expect(getRecommendedLocalModel(mac(16))?.id).toBe("qwen3:8b")
})
it("prefers mixture-of-experts models when only the CPU can run them", () => {
expect(getRecommendedLocalModel(pc(32))?.id).toBe("gpt-oss:20b")
expect(getRecommendedLocalModel(pc(4))).toBeUndefined()
})
it("treats untagged Ollama names as :latest", () => {
expect(normalizeOllamaModelName("laguna-xs-2.1")).toBe("laguna-xs-2.1:latest")
expect(normalizeOllamaModelName("qwen3.8:27b")).toBe("qwen3.8:27b")
})
})
+132
View File
@@ -0,0 +1,132 @@
/**
* Curated Ollama models that work well with Cline, each with the context
* window Cline should run it at. Ordered by how strongly we recommend them, so
* the first one that fits a machine is its recommendation.
*
* `memoryGb` estimates weights plus KV cache at `contextWindow`; it is what
* decides whether a model fits on the user's hardware.
*/
export interface LocalModelCatalogEntry {
id: string
name: string
description: string
sizeGb: number
memoryGb: number
contextWindow: number
tags: string[]
}
export const LOCAL_MODEL_CATALOG: LocalModelCatalogEntry[] = [
{
id: "qwen3.8:27b",
name: "Qwen3.8 27B",
description: "The strongest all-round local model for agentic coding. Reliable tool use across long tasks.",
sizeGb: 18,
memoryGb: 23,
contextWindow: 65536,
tags: ["Best overall", "Vision"],
},
{
id: "laguna-xs-2.1",
name: "Laguna XS 2.1",
description:
"Mixture-of-experts model from Poolside built for long-horizon coding. Only 3B active parameters, so it's fast.",
sizeGb: 20,
memoryGb: 24,
contextWindow: 65536,
tags: ["Fast"],
},
{
id: "qwen3.6:35b-a3b",
name: "Qwen3.6 35B A3B",
description: "Fast mixture-of-experts Qwen with solid agentic coding. A good pick when speed matters more than depth.",
sizeGb: 24,
memoryGb: 26,
contextWindow: 65536,
tags: ["Fast", "Vision"],
},
{
id: "devstral-small-2:24b",
name: "Devstral Small 2",
description: "Mistral's agentic coding model, tuned for exploring codebases and editing many files.",
sizeGb: 15,
memoryGb: 21,
contextWindow: 32768,
tags: ["Vision"],
},
{
id: "gpt-oss:20b",
name: "gpt-oss 20B",
description: "OpenAI's open-weight reasoning model. Light on memory and quick, a great fit for 16-24 GB machines.",
sizeGb: 14,
memoryGb: 16,
contextWindow: 65536,
tags: ["Fast"],
},
{
id: "qwen3-coder-next",
name: "Qwen3-Coder-Next",
description: "80B mixture-of-experts coder with 3B active parameters, for machines with 64 GB or more.",
sizeGb: 52,
memoryGb: 56,
contextWindow: 65536,
tags: ["Fast"],
},
{
id: "qwen3:8b",
name: "Qwen3 8B",
description: "Small enough for 8-16 GB machines. Handles simple edits, but struggles with larger multi-step tasks.",
sizeGb: 5.2,
memoryGb: 10,
contextWindow: 32768,
tags: ["Lightweight"],
},
]
/** Context window for installed models that aren't in the catalog; matches the SDK's Ollama default. */
export const DEFAULT_LOCAL_CONTEXT_WINDOW = 32768
export interface LocalHardware {
totalMemoryGb: number
unifiedMemory: boolean
gpus: { name: string; memoryGb: number }[]
}
/**
* - `great`: fits entirely in GPU (or Apple unified) memory
* - `slow`: only fits by spilling onto the CPU
* - `too-large`: doesn't fit in memory at all
*/
export type LocalModelFit = "great" | "slow" | "too-large"
export function getLocalMemoryBudget(hardware: LocalHardware): { gpuGb: number; totalGb: number } {
if (hardware.unifiedMemory) {
// macOS lets the GPU use roughly three quarters of unified memory.
return { gpuGb: hardware.totalMemoryGb * 0.75, totalGb: hardware.totalMemoryGb * 0.85 }
}
const gpuGb = hardware.gpus.reduce((sum, gpu) => sum + gpu.memoryGb, 0)
return { gpuGb, totalGb: gpuGb + hardware.totalMemoryGb * 0.7 }
}
export function evaluateLocalModelFit(memoryGb: number, hardware: LocalHardware): LocalModelFit {
const { gpuGb, totalGb } = getLocalMemoryBudget(hardware)
if (memoryGb <= gpuGb) {
return "great"
}
return memoryGb <= totalGb ? "slow" : "too-large"
}
export function getRecommendedLocalModel(hardware: LocalHardware): LocalModelCatalogEntry | undefined {
const great = LOCAL_MODEL_CATALOG.find((entry) => evaluateLocalModelFit(entry.memoryGb, hardware) === "great")
if (great) {
return great
}
// Mixture-of-experts models only compute a few billion parameters per token, so they hold up far better on CPU.
const slow = LOCAL_MODEL_CATALOG.filter((entry) => evaluateLocalModelFit(entry.memoryGb, hardware) === "slow")
return slow.find((entry) => entry.tags.includes("Fast")) ?? slow[0]
}
/** Ollama treats an untagged name as `:latest`. */
export function normalizeOllamaModelName(name: string): string {
return name.includes(":") ? name : `${name}:latest`
}
@@ -4,6 +4,7 @@ import { ResetStateRequest } from "@shared/proto/cline/state"
import type { UserOrganization } from "@shared/proto/index.cline"
import {
CheckCheck,
Cpu,
FlaskConical,
HardDriveDownload,
Info,
@@ -29,6 +30,7 @@ import ApiConfigurationSection from "./sections/ApiConfigurationSection"
import DebugSection from "./sections/DebugSection"
import FeatureSettingsSection from "./sections/FeatureSettingsSection"
import GeneralSettingsSection from "./sections/GeneralSettingsSection"
import LocalModelsSection from "./sections/LocalModelsSection"
import { RemoteConfigSection } from "./sections/RemoteConfigSection"
import TerminalSettingsSection from "./sections/TerminalSettingsSection"
import { getNextSettingsNavigationRequestId, resolveSettingsTarget, type SettingsNavigationRequest } from "./settingsTargets"
@@ -36,7 +38,7 @@ import { getNextSettingsNavigationRequestId, resolveSettingsTarget, type Setting
const IS_DEV = process.env.IS_DEV
// Tab definitions
type SettingsTabID = "api-config" | "features" | "terminal" | "general" | "about" | "debug" | "remote-config"
type SettingsTabID = "api-config" | "local-models" | "features" | "terminal" | "general" | "about" | "debug" | "remote-config"
interface SettingsTab {
id: SettingsTabID
name: string
@@ -54,6 +56,13 @@ const SETTINGS_TABS: SettingsTab[] = [
headerText: "API Configuration",
icon: SlidersHorizontal,
},
{
id: "local-models",
name: "Local Models",
tooltipText: "Local Models",
headerText: "Local Models",
icon: Cpu,
},
{
id: "features",
name: "Features",
@@ -130,6 +139,7 @@ const SettingsView = ({ navigationRequest, onDone }: SettingsViewProps) => {
const TAB_CONTENT_MAP: Record<SettingsTabID, React.FC<any>> = useMemo(
() => ({
"api-config": ApiConfigurationSection,
"local-models": LocalModelsSection,
general: GeneralSettingsSection,
features: FeatureSettingsSection,
terminal: TerminalSettingsSection,
@@ -0,0 +1,455 @@
import {
DEFAULT_LOCAL_CONTEXT_WINDOW,
evaluateLocalModelFit,
getLocalMemoryBudget,
getRecommendedLocalModel,
LOCAL_MODEL_CATALOG,
type LocalModelCatalogEntry,
type LocalModelFit,
normalizeOllamaModelName,
} from "@shared/local-models"
import { StringRequest } from "@shared/proto/cline/common"
import {
type LocalHardwareInfo,
type LocalModelsStatus,
OllamaModelRequest,
type OllamaRunningModel,
} from "@shared/proto/cline/models"
import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { Download, Loader2, Play, RefreshCw, Square, Trash2, X } from "lucide-react"
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useEvent } from "react-use"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { useProviderConfig } from "@/hooks/useProviderConfig"
import { ModelsServiceClient } from "@/services/grpc-client"
import Section from "../Section"
interface LocalModelsSectionProps {
renderSectionHeader: (tabId: string) => JSX.Element | null
}
interface PullState {
status: string
completed: number
total: number
}
type ModelAction = "using" | "loading" | "unloading" | "deleting"
const FIT_LABELS: Record<LocalModelFit, { text: string; className: string }> = {
great: { text: "Runs well on this machine", className: "text-success" },
slow: { text: "Will run slowly on this machine", className: "text-warning" },
"too-large": { text: "Too large for this machine", className: "text-error" },
}
const formatGb = (bytes: number) => `${(bytes / 1e9).toFixed(1)} GB`
const formatContext = (tokens: number) => `${Math.round(tokens / 1024)}K context`
function describeHardware(hardware: LocalHardwareInfo): string {
const memory = `${Math.round(hardware.totalMemoryGb)} GB`
if (hardware.unifiedMemory) {
return `${hardware.cpuModel} · ${memory} unified memory`
}
if (hardware.gpus.length > 0) {
const gpus = hardware.gpus.map((gpu) => `${gpu.name} (${Math.round(gpu.memoryGb)} GB)`).join(", ")
return `${gpus} · ${memory} RAM`
}
return `${hardware.cpuModel} · ${memory} RAM · No supported GPU detected`
}
function describeRunning(running: OllamaRunningModel): string {
const placement =
running.sizeVramBytes <= 0
? "on CPU"
: running.sizeVramBytes >= running.sizeBytes
? "on GPU"
: `${Math.round((running.sizeVramBytes / running.sizeBytes) * 100)}% on GPU`
const parts = [`Loaded ${placement}`, formatGb(running.sizeBytes)]
if (running.contextLength > 0) {
parts.push(formatContext(running.contextLength))
}
// Ollama reports models pinned with keep_alive -1 as expiring centuries from now.
const oneYearMs = 365 * 24 * 60 * 60 * 1000
if (running.expiresAtMs > 0 && running.expiresAtMs - Date.now() < oneYearMs) {
const time = new Date(running.expiresAtMs).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })
parts.push(`unloads at ${time} if idle`)
}
return parts.join(" · ")
}
const LocalModelsSection = ({ renderSectionHeader }: LocalModelsSectionProps) => {
const { apiConfiguration, mode, planActSeparateModelsSetting } = useExtensionState()
const { config, write, commitSelection } = useProviderConfig("ollama")
const baseUrl = config?.baseUrl ?? apiConfiguration?.ollamaBaseUrl ?? ""
const [status, setStatus] = useState<LocalModelsStatus | undefined>(undefined)
const [refreshing, setRefreshing] = useState(false)
const [pulls, setPulls] = useState<Record<string, PullState>>({})
const [actions, setActions] = useState<Record<string, ModelAction>>({})
const [errors, setErrors] = useState<Record<string, string>>({})
const pullSubscriptions = useRef(new Map<string, () => void>())
const setModelError = useCallback((model: string, message: string | undefined) => {
setErrors(({ [model]: _, ...rest }) => (message ? { ...rest, [model]: message } : rest))
}, [])
const refresh = useCallback(async () => {
setRefreshing(true)
try {
setStatus(await ModelsServiceClient.getLocalModelsStatus(StringRequest.create({ value: baseUrl })))
} catch (error) {
console.error("Failed to read local models status:", error)
} finally {
setRefreshing(false)
}
}, [baseUrl])
const subscribeToPull = useCallback(
(model: string) => {
if (pullSubscriptions.current.has(model)) {
return
}
setModelError(model, undefined)
setPulls((prev) => ({ ...prev, [model]: prev[model] ?? { status: "starting", completed: 0, total: 0 } }))
const finish = () => {
pullSubscriptions.current.delete(model)
setPulls(({ [model]: _, ...rest }) => rest)
void refresh()
}
const cancel = ModelsServiceClient.pullOllamaModel(OllamaModelRequest.create({ baseUrl, model }), {
onResponse: (progress) => {
if (progress.status !== "success") {
setPulls((prev) => ({ ...prev, [model]: progress }))
}
},
onError: (error) => {
if (error.message !== "Download cancelled") {
setModelError(model, error.message)
}
},
onComplete: finish,
})
pullSubscriptions.current.set(model, cancel)
},
[baseUrl, refresh, setModelError],
)
useEffect(() => {
void refresh()
}, [refresh])
// Unsubscribing leaves downloads running on the host; reopening this page picks them back up.
useEffect(() => {
for (const pull of status?.activePulls ?? []) {
subscribeToPull(pull.model)
}
}, [status, subscribeToPull])
useEffect(() => {
const subscriptions = pullSubscriptions.current
return () => {
for (const cancel of subscriptions.values()) {
cancel()
}
subscriptions.clear()
}
}, [])
// Loaded models can be unloaded by Ollama's idle timeout or from outside Cline.
useEvent("focus", () => void refresh())
const nextExpiryMs = useMemo(() => {
const expiries = (status?.running ?? []).map((model) => model.expiresAtMs).filter((ms) => ms > Date.now())
return expiries.length > 0 ? Math.min(...expiries) : undefined
}, [status])
useEffect(() => {
if (!nextExpiryMs || nextExpiryMs - Date.now() > 60 * 60 * 1000) {
return
}
const timer = setTimeout(() => void refresh(), nextExpiryMs - Date.now() + 1000)
return () => clearTimeout(timer)
}, [nextExpiryMs, refresh])
const runAction = useCallback(
async (model: string, action: ModelAction, run: () => Promise<unknown>) => {
setActions((prev) => ({ ...prev, [model]: action }))
setModelError(model, undefined)
try {
await run()
} catch (error) {
setModelError(model, error instanceof Error ? error.message : String(error))
} finally {
setActions(({ [model]: _, ...rest }) => rest)
void refresh()
}
},
[refresh, setModelError],
)
const currentProvider = mode === "plan" ? apiConfiguration?.planModeApiProvider : apiConfiguration?.actModeApiProvider
const selectedModelId = (mode === "plan" ? config?.planSelection : config?.actSelection)?.modelId
const activeModel = currentProvider === "ollama" && selectedModelId ? normalizeOllamaModelName(selectedModelId) : undefined
const contextWindowFor = useCallback(
(model: string) => {
if (model === activeModel && config?.contextWindow) {
return config.contextWindow
}
return (
LOCAL_MODEL_CATALOG.find((entry) => normalizeOllamaModelName(entry.id) === model)?.contextWindow ??
DEFAULT_LOCAL_CONTEXT_WINDOW
)
},
[activeModel, config?.contextWindow],
)
const loadModel = useCallback(
(model: string) =>
ModelsServiceClient.loadOllamaModel(
OllamaModelRequest.create({ baseUrl, model, contextWindow: contextWindowFor(model) }),
),
[baseUrl, contextWindowFor],
)
const useWithCline = useCallback(
(model: string, contextWindow: number) =>
runAction(model, "using", async () => {
await write({ contextWindow })
const modes = planActSeparateModelsSetting ? [mode] : (["plan", "act"] as const)
for (const targetMode of modes) {
await commitSelection(targetMode, { providerId: "ollama", modelId: model })
}
// Warm the model up so the first message doesn't wait on a cold load.
await ModelsServiceClient.loadOllamaModel(OllamaModelRequest.create({ baseUrl, model, contextWindow }))
}),
[baseUrl, commitSelection, mode, planActSeparateModelsSetting, runAction, write],
)
const installed = useMemo(
() => new Map((status?.installed ?? []).map((model) => [normalizeOllamaModelName(model.name), model])),
[status],
)
const running = useMemo(
() => new Map((status?.running ?? []).map((model) => [normalizeOllamaModelName(model.name), model])),
[status],
)
const hardware = status?.hardware
const recommended = hardware ? getRecommendedLocalModel(hardware) : undefined
const catalogIds = new Set(LOCAL_MODEL_CATALOG.map((entry) => normalizeOllamaModelName(entry.id)))
const otherInstalled = [...installed.entries()].filter(([name]) => !catalogIds.has(name))
const reachable = status?.ollamaReachable ?? false
const renderModelRow = (
model: string,
{ title, entry, details }: { title: string; entry?: LocalModelCatalogEntry; details: ReactNode },
) => {
const name = normalizeOllamaModelName(model)
const isInstalled = installed.has(name)
const runningModel = running.get(name)
const pull = pulls[model]
const action = actions[name]
const error = errors[model] ?? errors[name]
const isActive = name === activeModel
const fit = entry && hardware ? evaluateLocalModelFit(entry.memoryGb, hardware) : undefined
const pullPercent = pull && pull.total > 0 ? (pull.completed / pull.total) * 100 : 0
return (
<div className="flex flex-col gap-1.5 p-3 rounded-sm border border-editor-group-border" key={model}>
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold">{title}</span>
{entry && entry === recommended && <Badge variant="cline">Recommended for you</Badge>}
{isActive && <Badge variant="success">In use</Badge>}
{entry?.tags.map((tag) => (
<Badge key={tag} variant="outline">
{tag}
</Badge>
))}
</div>
{entry && <p className="text-xs text-description m-0">{entry.description}</p>}
<div className="text-xs text-description">
{details}
{fit && <span className={FIT_LABELS[fit].className}> · {FIT_LABELS[fit].text}</span>}
</div>
{runningModel && (
<div className="text-xs flex items-center gap-1.5">
<span className="inline-block size-2 rounded-full bg-success" />
{describeRunning(runningModel)}
</div>
)}
{pull && (
<div className="flex flex-col gap-1">
<Progress className="h-1.5" value={pullPercent} />
<div className="text-xs text-description flex justify-between gap-2">
<span className="truncate">
{pull.status.startsWith("pulling") && pull.total > 0
? `Downloading ${formatGb(pull.completed)} of ${formatGb(pull.total)}`
: pull.status}
</span>
{pull.total > 0 && <span>{Math.floor(pullPercent)}%</span>}
</div>
</div>
)}
{error && <p className="text-xs text-error m-0 break-words">{error}</p>}
<div className="flex gap-2 flex-wrap mt-1">
{!isInstalled && !pull && (
<Button
disabled={!reachable}
onClick={() => subscribeToPull(model)}
size="sm"
variant={fit === "too-large" ? "secondary" : "default"}>
<Download />
Download{entry ? ` (${entry.sizeGb} GB)` : ""}
</Button>
)}
{pull && (
<Button
onClick={() =>
void ModelsServiceClient.cancelOllamaPull(OllamaModelRequest.create({ baseUrl, model }))
}
size="sm"
variant="secondary">
<X />
Cancel
</Button>
)}
{isInstalled && !isActive && (
<Button
disabled={!!action}
onClick={() => void useWithCline(name, entry?.contextWindow ?? DEFAULT_LOCAL_CONTEXT_WINDOW)}
size="sm">
{action === "using" ? <Loader2 className="animate-spin" /> : null}
Use with Cline
</Button>
)}
{isInstalled && !runningModel && (
<Button
disabled={!!action}
onClick={() => void runAction(name, "loading", () => loadModel(name))}
size="sm"
variant="secondary">
{action === "loading" ? <Loader2 className="animate-spin" /> : <Play />}
{action === "loading" ? "Loading…" : "Load"}
</Button>
)}
{runningModel && (
<Button
disabled={!!action}
onClick={() =>
void runAction(name, "unloading", () =>
ModelsServiceClient.unloadOllamaModel(OllamaModelRequest.create({ baseUrl, model: name })),
)
}
size="sm"
variant="secondary">
{action === "unloading" ? <Loader2 className="animate-spin" /> : <Square />}
Unload
</Button>
)}
{isInstalled && (
<Button
aria-label={`Delete ${title}`}
disabled={!!action}
onClick={() =>
void runAction(name, "deleting", () =>
ModelsServiceClient.deleteOllamaModel(OllamaModelRequest.create({ baseUrl, model: name })),
)
}
size="sm"
title="Delete from disk"
variant="ghost">
{action === "deleting" ? <Loader2 className="animate-spin" /> : <Trash2 />}
</Button>
)}
</div>
</div>
)
}
const budget = hardware ? getLocalMemoryBudget(hardware) : undefined
return (
<div>
{renderSectionHeader("local-models")}
<Section>
<p className="text-sm text-description m-0">
Run models on your own machine with{" "}
<VSCodeLink href="https://ollama.com" style={{ display: "inline", fontSize: "inherit" }}>
Ollama
</VSCodeLink>
. Download one, then click <span className="font-semibold">Use with Cline</span> to switch Cline to it with
the right settings.
</p>
{hardware && (
<div className="flex flex-col gap-1 p-3 rounded-sm bg-code-block-background">
<span className="text-xs uppercase tracking-wide text-description">Your machine</span>
<span className="text-sm">{describeHardware(hardware)}</span>
{budget && (
<span className="text-xs text-description">
{budget.gpuGb > 0
? `Models needing up to ~${Math.floor(budget.gpuGb)} GB run fully on the GPU.`
: "Without a supported GPU, models run on the CPU and respond slowly."}
</span>
)}
</div>
)}
<div className="flex items-center gap-2 text-sm">
<span className={`inline-block size-2 rounded-full ${reachable ? "bg-success" : "bg-error"}`} />
<span className="flex-1">
{!status
? "Checking for Ollama…"
: reachable
? `Ollama ${status.ollamaVersion} is running`
: "Ollama isn't running"}
</span>
<Button aria-label="Refresh" disabled={refreshing} onClick={() => void refresh()} size="icon" variant="icon">
<RefreshCw className={refreshing ? "animate-spin" : ""} />
</Button>
</div>
{status && !reachable && (
<p className="text-xs text-description m-0">
<VSCodeLink href="https://ollama.com/download" style={{ display: "inline", fontSize: "inherit" }}>
Install Ollama
</VSCodeLink>{" "}
and start it (or run <code>ollama serve</code>). Cline looks for it at{" "}
{baseUrl || "http://localhost:11434"}, which you can change in API Configuration.
</p>
)}
<h4 className="m-0 mt-2">Recommended models</h4>
{LOCAL_MODEL_CATALOG.map((entry) =>
renderModelRow(entry.id, {
title: entry.name,
entry,
details: `${entry.id} · ${entry.sizeGb} GB download · needs ~${entry.memoryGb} GB memory · ${formatContext(entry.contextWindow)}`,
}),
)}
{otherInstalled.length > 0 && (
<>
<h4 className="m-0 mt-2">Other downloaded models</h4>
{otherInstalled.map(([name, model]) =>
renderModelRow(name, {
title: model.name,
details: [formatGb(model.sizeBytes), model.parameterSize, model.quantization]
.filter(Boolean)
.join(" · "),
}),
)}
</>
)}
<p className="text-xs text-description m-0 mt-2">
Ollama unloads a model after a few minutes without requests to free up memory, so the next message may take a
moment while it loads again. Set the <code>OLLAMA_KEEP_ALIVE</code> environment variable for the Ollama server
to keep models loaded longer. The context window and server URL can be adjusted under API Configuration →
Ollama.
</p>
</Section>
</div>
)
}
export default LocalModelsSection
@@ -3,6 +3,7 @@ export const featureSettingControlId = (featureId: string) => `${featureSettingE
export const SETTINGS_NAVIGATION_TARGETS = {
"api-config": { tabId: "api-config" },
"local-models": { tabId: "local-models" },
features: { tabId: "features" },
checkpoints: {
tabId: "features",
+5
View File
@@ -428,6 +428,7 @@
"nice-grpc": "^2.1.12",
"nice-grpc-common": "^2.0.3",
"node-machine-id": "^1.1.12",
"ollama": "^0.6.4",
"open": "^10.1.2",
"open-graph-scraper": "^6.9.0",
"openai": "^6.21.0",
@@ -4251,6 +4252,8 @@
"obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="],
"ollama": ["ollama@0.6.4", "", { "dependencies": { "whatwg-fetch": "^3.6.20" } }, "sha512-KzM6b5Sq0TgipeMTdImuELr/V4eNqrTf37PRzUTHCPPieFmboYw6qPn+pX7+lg0nSP1jsKwy4AFkiQFXhqHeZA=="],
"ollama-ai-provider-v2": ["ollama-ai-provider-v2@4.0.1", "", { "dependencies": { "@ai-sdk/provider": "^4.0.2", "@ai-sdk/provider-utils": "^5.0.5" }, "peerDependencies": { "ai": "^7.0.0", "zod": "^4.0.16" } }, "sha512-JHw/Knt4kOTSwQ0oqorbdgzukK6iE73U3MPUyOuCEBpAQGAzm80CQDwyaXMwF80yFjY21RY9qfzkoLyPFFts/w=="],
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
@@ -5083,6 +5086,8 @@
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
"whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="],
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
"whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="],