Commit 8664eaea3 for llama.cpp

commit 8664eaea303868e6d47ffdacdbc3d946894a96fb
Author: Aleksander Grygier <aleksander.grygier@gmail.com>
Date:   Wed Sep 30 12:42:17 2026 +0200

    ui : model download pipeline (#27959)

    * ui : model download pipeline

    Track HuggingFace downloads end to end: the server download/cancel endpoints,
    a status manager fed by the /models/sse download progress events, and a
    models-discover store holding the catalog and detail state for the discover
    view. Downloaded and in-flight entries are excluded from the loadable model
    list.

    Assisted-by: pi:GLM-5.3-Flash

    * ui : route sidecar tag lookup through the sidecars util, validate the paused list

    Assisted-by: pi:zai-org/GLM-5.3-Flash

diff --git a/tools/ui/src/app.d.ts b/tools/ui/src/app.d.ts
index 0a206381f..5c039063a 100644
--- a/tools/ui/src/app.d.ts
+++ b/tools/ui/src/app.d.ts
@@ -19,6 +19,8 @@ import type {
 	ApiLlamaCppServerProps,
 	ApiModelDataEntry,
 	ApiModelLoadStage,
+	ApiModelsDownloadRequest,
+	ApiModelsDownloadResponse,
 	ApiModelsListResponse,
 	ApiModelsLoadResponse,
 	ApiModelsSseData,
@@ -80,11 +82,15 @@ declare global {
 		ApiLlamaCppServerProps,
 		ApiModelDataEntry,
 		ApiModelLoadStage,
+		ModelDownloadProgress,
 		ApiModelsSseProgress,
 		ApiModelsSseData,
+		ApiModelsSseDownloadProgressData,
 		ApiModelsSseEvent,
 		ApiModelsListResponse,
 		ApiModelsLoadResponse,
+		ApiModelsDownloadRequest,
+		ApiModelsDownloadResponse,
 		ApiModelsUnloadResponse,
 		ApiProcessingState,
 		// Chat types
diff --git a/tools/ui/src/lib/constants/api-endpoints.constants.ts b/tools/ui/src/lib/constants/api-endpoints.constants.ts
index 8611d49fb..2f6efb94c 100644
--- a/tools/ui/src/lib/constants/api-endpoints.constants.ts
+++ b/tools/ui/src/lib/constants/api-endpoints.constants.ts
@@ -1,4 +1,7 @@
 export const API_MODELS = {
+	/** Download a model from HuggingFace (ROUTER mode, POST) or cancel/remove it (DELETE) */
+	DELETE: '/models',
+	DOWNLOAD: '/models',
 	LIST: '/v1/models',
 	LOAD: '/models/load',
 	SSE: '/models/sse',
diff --git a/tools/ui/src/lib/constants/cli-flags.constants.ts b/tools/ui/src/lib/constants/cli-flags.constants.ts
index c4af2b6f4..0b6b2af86 100644
--- a/tools/ui/src/lib/constants/cli-flags.constants.ts
+++ b/tools/ui/src/lib/constants/cli-flags.constants.ts
@@ -2,6 +2,12 @@ export const CLI_FLAGS = {
 	AGENT: '--agent',
 	API_KEY: '--api-key',
 	MCP_PROXY: '--ui-mcp-proxy',
+	/** Multimodal projector path; unlocks vision/audio for the model. */
+	MMPROJ: '--mmproj',
+	/** Draft model weights path (long form); the router records it per model. */
+	MODEL_DRAFT: '--model-draft',
+	/** Draft model weights path (short form). */
+	MODEL_DRAFT_SHORT: '-md',
 	SLOTS: '--slots',
 	TOOLS: '--tools'
 } as const;
diff --git a/tools/ui/src/lib/constants/storage.constants.ts b/tools/ui/src/lib/constants/storage.constants.ts
index 0aad7c770..5e6610d85 100644
--- a/tools/ui/src/lib/constants/storage.constants.ts
+++ b/tools/ui/src/lib/constants/storage.constants.ts
@@ -15,6 +15,9 @@ export const STORAGE_APP_NAME_DEPRECATED = 'LlamaCppWebui';
 export const DB_APP_NAME_DEPRECATED = 'LlamacppWebui';

 export const ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.alwaysAllowedTools`;
+
+/** Paused model download ids (`<repo>:<tag>`), restored on the next page load. */
+export const PAUSED_MODEL_DOWNLOADS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.pausedModelDownloads`;
 export const CONFIG_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.config`;
 export const DISABLED_TOOLS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledTools`;

diff --git a/tools/ui/src/lib/enums/index.ts b/tools/ui/src/lib/enums/index.ts
index db4f5b68c..947d1c757 100644
--- a/tools/ui/src/lib/enums/index.ts
+++ b/tools/ui/src/lib/enums/index.ts
@@ -71,6 +71,8 @@ export {

 export { ModelAuxSidecar, ModelCapability, ModelDraftSidecar, ModelModality } from './model.enums';

+export { ModelDownloadStopRequest } from './model.enums';
+
 export { ServerRole, ServerModelStatus, ServerModelsSseEventType } from './server.enums';

 export { ParameterSource, SyncableParameterType, SettingsFieldType } from './settings.enums';
diff --git a/tools/ui/src/lib/enums/model.enums.ts b/tools/ui/src/lib/enums/model.enums.ts
index 177fffe19..2440dc007 100644
--- a/tools/ui/src/lib/enums/model.enums.ts
+++ b/tools/ui/src/lib/enums/model.enums.ts
@@ -35,3 +35,13 @@ export enum ModelAuxSidecar {
 	/** Multimodal projector: unlocks vision and/or audio input modalities. */
 	MMPROJ = 'mmproj'
 }
+
+/**
+ * Why an in-flight download is being stopped, so the terminal `download_failed`
+ * feed event can be attributed: a user pause (resumable) or a user cancel
+ * (discard). Distinguishes these from a genuine download failure.
+ */
+export enum ModelDownloadStopRequest {
+	CANCEL = 'cancel',
+	PAUSE = 'pause'
+}
diff --git a/tools/ui/src/lib/enums/server.enums.ts b/tools/ui/src/lib/enums/server.enums.ts
index b7e80433c..b558b5123 100644
--- a/tools/ui/src/lib/enums/server.enums.ts
+++ b/tools/ui/src/lib/enums/server.enums.ts
@@ -13,6 +13,10 @@ export enum ServerRole {
  * Used as the `value` field in the status object from /models endpoint
  */
 export enum ServerModelStatus {
+	DOWNLOAD_FAILED = 'download_failed',
+	DOWNLOAD_FINISHED = 'download_finished',
+	DOWNLOADED = 'downloaded',
+	DOWNLOADING = 'downloading',
 	FAILED = 'failed',
 	LOADED = 'loaded',
 	LOADING = 'loading',
@@ -26,6 +30,8 @@ export enum ServerModelStatus {
  * tools/server/server-models.cpp from the C++ server.
  */
 export enum ServerModelsSseEventType {
+	DOWNLOAD_FAILED = 'download_failed',
+	DOWNLOAD_FINISHED = 'download_finished',
 	DOWNLOAD_PROGRESS = 'download_progress',
 	MODEL_REMOVE = 'model_remove',
 	MODEL_STATUS = 'model_status',
diff --git a/tools/ui/src/lib/services/models.service.ts b/tools/ui/src/lib/services/models.service.ts
index 21a9b22a1..96902a0b9 100644
--- a/tools/ui/src/lib/services/models.service.ts
+++ b/tools/ui/src/lib/services/models.service.ts
@@ -11,11 +11,13 @@ import { API_MODELS, MODEL_ID, type ModelSidecar } from '$lib/constants';
 import { ServerModelStatus } from '$lib/enums';
 import type { ParsedModelId } from '$lib/types/models';
 import {
+	apiDelete,
 	apiFetch,
 	apiPost,
 	extractSseDataPayload,
 	normalizeModelName,
 	sidecarFromFileToken,
+	sidecarFromTag,
 	splitSseRecords
 } from '$lib/utils';
 import { getAuthHeaders } from '$lib/utils/api-headers';
@@ -24,14 +26,8 @@ export class ModelsService {
 	private static readonly SSE_RECONNECT_MS = 1000;

 	/**
-	 * Build the `<repo>:<tag>` string expected by POST /models from a parsed
-	 * filename quant + optional sidecar type. Used by the model download
-	 * dialog so callers don't have to know about the tag conventions.
-	 *
-	 * @param repoId - HuggingFace repo id (e.g. `ggml-org/gemma-3-4b-it-GGUF`)
-	 * @param quant - Quantization token, e.g. `Q4_K_M`
-	 * @param sidecar - Sidecar type, as its lowercase filename token (e.g. `mtp`)
-	 * @returns Repo id possibly suffixed with `:tag`
+	 * Build the `<repo>:<tag>` string POST /models expects, so callers don't need
+	 * to know the tag conventions.
 	 */
 	static buildDownloadTag(
 		repoId: string,
@@ -47,6 +43,27 @@ export class ModelsService {
 		return `${repoId}:${tag}`;
 	}

+	/**
+	 * Cancel an in-flight download, or remove a downloaded/failed entry from the
+	 * model cache (ROUTER mode only): DELETE /models?model=<repo:tag>.
+	 */
+	static async cancelDownload(hfRepoWithTag: string): Promise<ApiModelsDownloadResponse> {
+		return apiDelete<ApiModelsDownloadResponse>(API_MODELS.DELETE, {
+			model: hfRepoWithTag
+		});
+	}
+
+	/**
+	 * Start a model download from HuggingFace (ROUTER mode only). The response
+	 * returns immediately; progress arrives over /models/sse. The server picks
+	 * the file matching the tag and also pulls the model's mmproj/draft sidecars.
+	 */
+	static async downloadModel(hfRepoWithTag: string): Promise<ApiModelsDownloadResponse> {
+		const payload: ApiModelsDownloadRequest = { model: hfRepoWithTag };
+
+		return apiPost<ApiModelsDownloadResponse>(API_MODELS.DOWNLOAD, payload);
+	}
+
 	/**
 	 * Check if a model is loaded based on its metadata.
 	 *
@@ -57,6 +74,10 @@ export class ModelsService {
 		return model.status.value === ServerModelStatus.LOADED;
 	}

+	static isModelLoading(model: ApiModelDataEntry): boolean {
+		return model.status.value === ServerModelStatus.LOADING;
+	}
+
 	/**
 	 *
 	 *
@@ -66,13 +87,15 @@ export class ModelsService {
 	 */

 	/**
-	 * Check if a model is currently loading.
-	 *
-	 * @param model - Model data entry from the API response
-	 * @returns True if the model status is LOADING
+	 * True when a router entry id marks a downloaded sidecar file, e.g.
+	 * `org/model:Q4_0-mtp` or `org/model:mmproj`, not a loadable model.
 	 */
-	static isModelLoading(model: ApiModelDataEntry): boolean {
-		return model.status.value === ServerModelStatus.LOADING;
+	static isSidecarEntry(modelId: string): boolean {
+		const idx = modelId.indexOf(MODEL_ID.QUANTIZATION_SEPARATOR);
+
+		if (idx === MODEL_ID.NOT_FOUND) return false;
+
+		return sidecarFromTag(modelId.slice(idx + 1)) !== null;
 	}

 	/**
diff --git a/tools/ui/src/lib/stores/models/index.svelte.ts b/tools/ui/src/lib/stores/models/index.svelte.ts
index d25b59cfc..d367ad987 100644
--- a/tools/ui/src/lib/stores/models/index.svelte.ts
+++ b/tools/ui/src/lib/stores/models/index.svelte.ts
@@ -204,6 +204,9 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
 			const response = await ModelsService.list();

 			this.routerModels = response.data;
+			// keep the selector options in sync: a downloaded / deleted model shows
+			// up here too, not only in the router model rows
+			this.models = this.buildModelOptions(response);
 			await this.props.fetchModalitiesForLoadedModels();

 			const visible = this.getVisibleModels();
@@ -359,27 +362,44 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
 	 * they differ only in which endpoint is called.
 	 */
 	private buildModelOptions(response: ApiModelsListResponse): ModelOption[] {
-		return response.data.map((item: ApiModelDataEntry, index: number) => {
-			const details = response.models?.[index];
-			const rawCapabilities = Array.isArray(details?.capabilities) ? details?.capabilities : [];
-			const displayNameSource =
-				details?.name && details.name.trim().length > 0 ? details.name : item.id;
-			const modelId = details?.model || item.id;
-
-			return {
-				aliases: item.aliases ?? [],
-				capabilities: rawCapabilities.filter((value: unknown): value is string => Boolean(value)),
-				description: details?.description,
-				details: details?.details,
-				id: item.id,
-				meta: item.meta ?? null,
-				modalities: this.props.buildArchitectureModalities(item.architecture),
-				model: modelId,
-				name: this.toDisplayName(displayNameSource),
-				parsedId: ModelsService.parseModelId(modelId),
-				tags: item.tags ?? []
-			};
-		});
+		const entries: {
+			details?: ApiModelsListResponse['models'][number];
+			item: ApiModelDataEntry;
+		}[] = response.data.map((item: ApiModelDataEntry, index: number) => ({
+			details: response.models?.[index],
+			item
+		}));
+
+		return (
+			entries
+				// sidecar entries mark downloaded sidecar files, not loadable models
+				.filter(({ item }) => !ModelsService.isSidecarEntry(item.id))
+				// in-flight downloads are not usable models yet; the selector tracks
+				// them in its "Download in progress" section instead
+				.filter(({ item }) => item.status?.value !== ServerModelStatus.DOWNLOADING)
+				.map(({ details, item }) => {
+					const rawCapabilities = Array.isArray(details?.capabilities) ? details?.capabilities : [];
+					const displayNameSource =
+						details?.name && details.name.trim().length > 0 ? details.name : item.id;
+					const modelId = details?.model || item.id;
+
+					return {
+						aliases: item.aliases ?? [],
+						capabilities: rawCapabilities.filter((value: unknown): value is string =>
+							Boolean(value)
+						),
+						description: details?.description,
+						details: details?.details,
+						id: item.id,
+						meta: item.meta ?? null,
+						modalities: this.props.buildArchitectureModalities(item.architecture),
+						model: modelId,
+						name: this.toDisplayName(displayNameSource),
+						parsedId: ModelsService.parseModelId(modelId),
+						tags: item.tags ?? []
+					};
+				})
+		);
 	}

 	/** Fetch models in MODEL mode (single model, standard OpenAI-compatible). */
diff --git a/tools/ui/src/lib/stores/models/status.svelte.ts b/tools/ui/src/lib/stores/models/status.svelte.ts
index d0160aa4d..b0ac08366 100644
--- a/tools/ui/src/lib/stores/models/status.svelte.ts
+++ b/tools/ui/src/lib/stores/models/status.svelte.ts
@@ -1,18 +1,26 @@
 /**
- * ModelStatusManager - Model load/unload operations and the /models/sse feed
+ * ModelStatusManager - model load/unload operations and the /models/sse feed
  *
- * Owns the status feed subscription, load progress tracking, and the
- * awaiters that settle load/unload operations. The feed drives status and
- * progress, so it replaces any post-operation polling. Created and owned by
- * modelsStore; the host owns the router model rows the feed updates.
+ * The feed drives status and progress, replacing any post-operation polling.
+ * Created and owned by modelsStore, which also owns the router rows it updates.
  */

-import { ServerModelsSseEventType, ServerModelStatus } from '$lib/enums';
+import {
+	CLI_FLAGS,
+	HF_UD_QUANT_PREFIX_REGEX,
+	MODEL_ID,
+	PATH_SEPARATOR,
+	PAUSED_MODEL_DOWNLOADS_LOCALSTORAGE_KEY
+} from '$lib/constants';
+import { ModelDownloadStopRequest, ServerModelsSseEventType, ServerModelStatus } from '$lib/enums';
+import { HuggingFaceService } from '$lib/services/huggingface.service';
 import { ModelsService } from '$lib/services/models.service';
 import type { ModelPropsManager } from '$lib/stores/models/props.svelte';
 // direct imports between stores, not via the barrel, to avoid circular deps
 import { serverStore } from '$lib/stores/server.svelte';
-import { SvelteMap } from 'svelte/reactivity';
+// explicit type imports: the app.d.ts globals resolve to `any`, so import the real types
+import type { ApiModelsSseDownloadProgressData, ModelDownloadProgress } from '$lib/types';
+import { SvelteMap, SvelteSet } from 'svelte/reactivity';
 import { toast } from 'svelte-sonner';

 /**
@@ -30,9 +38,59 @@ export interface ModelStatusHost {
 	toDisplayName(id: string): string;
 }

+/**
+ * Comparison key of a `<repo>:<tag>` download identifier: uppercased, with the
+ * `UD-` quant prefix stripped. The router derives cached model names from the
+ * actual file, which drops the prefix, so `repo:UD-Q4_K_XL` and `repo:Q4_K_XL`
+ * must compare equal.
+ */
+function downloadIdKey(repoWithTag: string): string {
+	const idx = repoWithTag.indexOf(MODEL_ID.QUANTIZATION_SEPARATOR);
+	const repo = idx === -1 ? repoWithTag : repoWithTag.slice(0, idx);
+	const tag = idx === -1 ? '' : repoWithTag.slice(idx + 1);
+
+	return `${repo.toUpperCase()}:${tag.toUpperCase().replace(HF_UD_QUANT_PREFIX_REGEX, '')}`;
+}
+
 export class ModelStatusManager {
+	/**
+	 * Sidecar files pulled by registered models, as `<repo>/<file>` keys.
+	 * Sidecars are not separate /v1/models entries - the router pulls them as
+	 * sidecars of a main model and records them in its `--model-draft` /
+	 * `--mmproj` args.
+	 */
+	private downloadedSidecars = $derived.by(() => {
+		const result = new SvelteSet<string>();
+
+		for (const m of this.host.routerModels) {
+			const args = m.status?.args;
+
+			if (!args) continue;
+
+			for (let i = 0; i < args.length - 1; i++) {
+				if (
+					args[i] !== CLI_FLAGS.MODEL_DRAFT &&
+					args[i] !== CLI_FLAGS.MODEL_DRAFT_SHORT &&
+					args[i] !== CLI_FLAGS.MMPROJ
+				) {
+					continue;
+				}
+
+				const parsed = HuggingFaceService.parseCachePath(args[i + 1]);
+
+				if (parsed) result.add(`${parsed.repo}${PATH_SEPARATOR}${parsed.file}`);
+			}
+		}
+
+		return result;
+	});
+	private downloadProgress = new SvelteMap<string, ModelDownloadProgress>();
+	/** `<repo>:<tag>` strings whose most recent download attempt failed (download_failed). */
+	private failedDownloads = new SvelteSet<string>();
 	private loadingStates = new SvelteMap<string, boolean>();
 	private loadProgress = new SvelteMap<string, ModelLoadProgress>();
+	/** Paused downloads with their last reported progress, or null when none arrived before the pause. */
+	private pausedDownloads = new SvelteMap<string, ModelDownloadProgress | null>();
 	// /models/sse feed state, the single source of truth for status and load progress
 	private statusAbort: AbortController | null = null;
 	private statusReaderActive = false;
@@ -40,8 +98,127 @@ export class ModelStatusManager {
 		string,
 		{ target: ServerModelStatus; resolve: () => void; reject: (e: Error) => void }
 	>();
+	/** Tags the user asked to stop (pause or cancel); the download_failed the stop triggers is intentional, not a failure. */
+	private stopRequests = new SvelteMap<string, ModelDownloadStopRequest>();
+
+	/**
+	 * Cancel an in-flight download or remove a downloaded/failed entry from
+	 * the cache (ROUTER mode only).
+	 */
+	async cancelDownload(repoWithTag: string): Promise<boolean> {
+		if (!serverStore.isRouterMode) {
+			toast.error('Model downloads are only available in router mode');
+
+			return false;
+		}
+
+		this.subscribe();

-	constructor(private host: ModelStatusHost) {}
+		// in-flight: the kill triggers download_failed over the feed; mark it as a
+		// user cancel so it settles silently instead of toasting a failure
+		if (this.downloadProgress.has(repoWithTag)) {
+			this.stopRequests.set(repoWithTag, ModelDownloadStopRequest.CANCEL);
+		}
+
+		// a downloaded model registers under the name the router derived from the
+		// cached file (e.g. the UD- quant prefix is dropped), so resolve the tag to
+		// the registered id before asking the server to remove it
+		const registeredId =
+			this.host.routerModels.find((m) => downloadIdKey(m.id) === downloadIdKey(repoWithTag))?.id ??
+			repoWithTag;
+
+		try {
+			const res = await ModelsService.cancelDownload(registeredId);
+			const ok = res.success === true;
+
+			if (ok) {
+				this.downloadProgress.delete(repoWithTag);
+				this.failedDownloads.delete(repoWithTag);
+				this.deletePausedDownload(repoWithTag);
+			}
+
+			return ok;
+		} catch (error) {
+			toast.error(`Failed to cancel: ${error instanceof Error ? error.message : 'unknown error'}`);
+
+			return false;
+		}
+	}
+
+	/**
+	 * The server force-kills a LOADING model on unload, and the feed reports the
+	 * settled status, so no waiter is registered here.
+	 */
+	async cancelLoad(modelId: string): Promise<void> {
+		if (!serverStore.isRouterMode) return;
+
+		this.subscribe();
+
+		try {
+			await ModelsService.unload(modelId);
+			toast.info(`Load cancelled: ${this.host.toDisplayName(modelId)}`);
+		} catch (error) {
+			toast.error(`Failed to cancel load: ${this.host.toDisplayName(modelId)}`);
+
+			throw error;
+		}
+	}
+
+	constructor(private host: ModelStatusHost) {
+		// the server has no notion of a paused download, so the ids survive in
+		// localStorage; the progress snapshot is stale after a reload and stays null
+		try {
+			const raw = localStorage.getItem(PAUSED_MODEL_DOWNLOADS_LOCALSTORAGE_KEY);
+			const parsed: unknown = JSON.parse(raw ?? '[]');
+
+			if (!Array.isArray(parsed)) return;
+
+			for (const repoWithTag of parsed.filter((id): id is string => typeof id === 'string')) {
+				this.pausedDownloads.set(repoWithTag, null);
+			}
+		} catch {
+			// unreadable or corrupt: start without the paused set
+		}
+	}
+
+	/**
+	 * POST /models starts the download in the background; the feed reports
+	 * progress, and models_reload refreshes the list once it finishes.
+	 * Re-posting a paused tag resumes from the partial files kept on disk.
+	 */
+	async downloadModel(repoWithTag: string): Promise<void> {
+		if (!serverStore.isRouterMode) {
+			toast.error('Model downloads are only available in router mode');
+
+			return;
+		}
+
+		// the feed must be live so the resulting models_reload event refreshes the list
+		this.subscribe();
+
+		// resuming a paused download: drop the paused state, and let the server
+		// discard its stale DOWNLOADED entry (via the list fetch) before re-posting
+		if (this.deletePausedDownload(repoWithTag) || this.stopRequests.delete(repoWithTag)) {
+			await this.host.fetchRouterModels();
+		}
+
+		try {
+			const res = await ModelsService.downloadModel(repoWithTag);
+
+			if (!res.success) {
+				throw new Error(res.error?.message ?? 'Server rejected the download request');
+			}
+
+			// flip the chip to "downloading" right away; the feed refines it with real progress
+			this.downloadProgress.set(repoWithTag, { downloadedBytes: 0, files: {}, totalBytes: 0 });
+
+			toast.success(`Download started: ${this.host.toDisplayName(repoWithTag)}`);
+		} catch (error) {
+			toast.error(`Download failed: ${repoWithTag}`);
+
+			throw error;
+		}
+	}

 	async ensureLoaded(modelId: string): Promise<void> {
 		if (this.host.isModelLoaded(modelId)) return;
@@ -50,16 +227,71 @@ export class ModelStatusManager {
 	}

 	/**
-	 * Current load progress for a model, or null when not loading.
+	 * Tracked downloads (in flight or paused) for the selector's
+	 * "Download in progress" section.
 	 */
+	getDownloadEntries(): {
+		isPaused: boolean;
+		progress: ModelDownloadProgress | null;
+		repoWithTag: string;
+	}[] {
+		const inFlight = Array.from(this.downloadProgress, ([repoWithTag, progress]) => ({
+			isPaused: false,
+			progress,
+			repoWithTag
+		}));
+		const paused = Array.from(this.pausedDownloads, ([repoWithTag, progress]) => ({
+			isPaused: true,
+			progress,
+			repoWithTag
+		}));
+
+		return [...inFlight, ...paused];
+	}
+
+	getDownloadProgress(repoWithTag: string): ModelDownloadProgress | null {
+		return this.downloadProgress.get(repoWithTag) ?? null;
+	}
+
 	getLoadProgress(modelId: string): ModelLoadProgress | null {
 		return this.loadProgress.get(modelId) ?? null;
 	}

+	getPausedDownloadProgress(repoWithTag: string): ModelDownloadProgress | null {
+		return this.pausedDownloads.get(repoWithTag) ?? null;
+	}
+
+	hasFailedDownload(repoWithTag: string): boolean {
+		return this.failedDownloads.has(repoWithTag);
+	}
+
+	/** Active while the feed reports download_progress for the tag. */
+	isDownloadInProgress(repoWithTag: string): boolean {
+		return this.downloadProgress.has(repoWithTag);
+	}
+
+	isDownloadPaused(repoWithTag: string): boolean {
+		return this.pausedDownloads.has(repoWithTag);
+	}
+
+	/**
+	 * True when the tag is already registered in the /v1/models list; both ids
+	 * are normalized, see downloadIdKey().
+	 */
+	isModelDownloaded(repoWithTag: string): boolean {
+		const key = downloadIdKey(repoWithTag);
+
+		return this.host.routerModels.some((m) => downloadIdKey(m.id) === key);
+	}
+
 	isOperationInProgress(modelId: string): boolean {
 		return this.loadingStates.get(modelId) ?? false;
 	}

+	isSidecarDownloaded(repoId: string, filePath: string): boolean {
+		return this.downloadedSidecars.has(`${repoId}/${filePath}`);
+	}
+
 	async load(modelId: string): Promise<void> {
 		if (this.host.isModelLoaded(modelId)) return;

@@ -91,9 +323,30 @@ export class ModelStatusManager {
 	}

 	/**
-	 * Open the /models/sse feed and keep it live with auto reconnect.
-	 * Idempotent and router mode only.
+	 * The server stops the download child but keeps the partial files, so
+	 * re-posting the tag resumes where it stopped. The feed reports the stop
+	 * as download_failed; the 'pause' stop request marks it as intentional.
 	 */
+	async pauseDownload(repoWithTag: string): Promise<void> {
+		if (!serverStore.isRouterMode) {
+			toast.error('Model downloads are only available in router mode');
+
+			return;
+		}
+
+		this.subscribe();
+
+		this.stopRequests.set(repoWithTag, ModelDownloadStopRequest.PAUSE);
+
+		try {
+			await ModelsService.unload(repoWithTag);
+		} catch {
+			this.stopRequests.delete(repoWithTag);
+			toast.error(`Failed to pause: ${repoWithTag}`);
+		}
+	}
+
+	/** Open the /models/sse feed with auto reconnect; idempotent, router mode only. */
 	subscribe(): void {
 		if (this.statusReaderActive) return;

@@ -133,25 +386,94 @@ export class ModelStatusManager {
 		}
 	}

-	/**
-	 * Close the /models/sse feed and drop transient progress.
-	 */
 	unsubscribe(): void {
 		this.statusReaderActive = false;
 		this.statusAbort?.abort();
 		this.statusAbort = null;
 		this.loadProgress.clear();
+		this.downloadProgress.clear();
+		this.failedDownloads.clear();
+		this.stopRequests.clear();
 	}

 	/**
-	 * Apply a status envelope: update the model row, track or clear progress,
-	 * settle any pending load or unload awaiter.
+	 * A user pause keeps the last progress and stays resumable, a user cancel
+	 * settles silently; genuine failures are marked so the UI can offer a retry.
 	 */
+	private applyDownloadFinished(event: ApiModelsSseEvent): void {
+		let request: ModelDownloadStopRequest | undefined;
+
+		if (event.event === ServerModelsSseEventType.DOWNLOAD_FAILED) {
+			request = this.stopRequests.get(event.model);
+			this.stopRequests.delete(event.model);
+		}
+
+		const progress = this.downloadProgress.get(event.model) ?? null;
+
+		this.downloadProgress.delete(event.model);
+
+		if (request === ModelDownloadStopRequest.CANCEL) {
+			// user cancel: settle silently, the feed's model_remove cleans up the entry
+			this.failedDownloads.delete(event.model);
+			this.deletePausedDownload(event.model);
+
+			return;
+		}
+
+		if (request === ModelDownloadStopRequest.PAUSE) {
+			this.setPausedDownload(event.model, progress);
+			this.failedDownloads.delete(event.model);
+
+			return;
+		}
+
+		this.deletePausedDownload(event.model);
+
+		const ok = event.event === ServerModelsSseEventType.DOWNLOAD_FINISHED;
+
+		if (ok) {
+			this.failedDownloads.delete(event.model);
+
+			// the finished download only registers in /v1/models on the next list
+			// fetch (the server reloads its model table then), so refetch to flip
+			// the quant chips to "downloaded" without waiting for a dialog reopen
+			void this.host.fetchRouterModels();
+
+			toast.success(`Download finished: ${this.host.toDisplayName(event.model)}`);
+		} else {
+			this.failedDownloads.add(event.model);
+			toast.error(`Download failed: ${this.host.toDisplayName(event.model)}`);
+		}
+	}
+
+	/** Aggregate per-file progress into downloaded/total byte counts. */
+	private applyDownloadProgress(event: ApiModelsSseEvent): void {
+		const data = event.data;
+
+		if (!data || !('progress' in data)) return;
+
+		const progress = (data as ApiModelsSseDownloadProgressData).progress;
+
+		let downloaded = 0;
+		let total = 0;
+
+		for (const file of Object.values(progress)) {
+			downloaded += file?.done ?? 0;
+			total += file?.total ?? 0;
+		}
+
+		this.downloadProgress.set(event.model, {
+			downloadedBytes: downloaded,
+			files: progress,
+			totalBytes: total
+		});
+	}
+
 	private applyModelStatus(event: ApiModelsSseEvent): void {
 		const model = event.model;
 		const data = event.data;

-		if (!model || !data?.status) return;
+		if (!model || !data || !('status' in data) || !data.status) return;

 		const status = data.status;

@@ -180,11 +502,7 @@ export class ModelStatusManager {
 		this.settleStatus(model, status);
 	}

-	/**
-	 * Route one feed record by event kind. Only the status_* events carry a
-	 * status payload, models_reload triggers a list refresh, model_remove drops
-	 * the row, download_* belong to the download surface, not here.
-	 */
+	/** Route one feed record by event kind. */
 	private applyStatusEvent(event: ApiModelsSseEvent): void {
 		switch (event.event) {
 			case ServerModelsSseEventType.STATUS_CHANGE:
@@ -202,13 +520,36 @@ export class ModelStatusManager {

 				break;
 			case ServerModelsSseEventType.DOWNLOAD_PROGRESS:
+				this.applyDownloadProgress(event);
+
+				break;
+			case ServerModelsSseEventType.DOWNLOAD_FINISHED:
+			case ServerModelsSseEventType.DOWNLOAD_FAILED:
+				this.applyDownloadFinished(event);
+
 				break;
 		}
 	}

-	/**
-	 * Reject and drop the awaiter for a model.
-	 */
+	private deletePausedDownload(repoWithTag: string): boolean {
+		if (!this.pausedDownloads.delete(repoWithTag)) return false;
+
+		this.persistPausedDownloads();
+
+		return true;
+	}
+
+	private persistPausedDownloads(): void {
+		try {
+			localStorage.setItem(
+				PAUSED_MODEL_DOWNLOADS_LOCALSTORAGE_KEY,
+				JSON.stringify(Array.from(this.pausedDownloads.keys()))
+			);
+		} catch {
+			// storage unavailable: the pauses just do not survive a reload
+		}
+	}
+
 	private rejectStatus(modelId: string, error: Error): void {
 		const waiter = this.statusWaiters.get(modelId);

@@ -218,27 +559,32 @@ export class ModelStatusManager {
 		}
 	}

-	/**
-	 * Drop a model row reported gone by the feed and settle its awaiters.
-	 */
 	private removeRouterModel(modelId: string): void {
 		if (this.host.routerModels.findIndex((m) => m.id === modelId) === -1) return;

 		this.host.routerModels = this.host.routerModels.filter((m) => m.id !== modelId);
 		this.loadProgress.delete(modelId);
+		this.downloadProgress.delete(modelId);
+		this.failedDownloads.delete(modelId);
+		this.deletePausedDownload(modelId);
+		this.stopRequests.delete(modelId);
 		this.rejectStatus(modelId, new Error(`Model removed: ${this.host.toDisplayName(modelId)}`));
+
+		// drop the row from the selector options too; they rebuild from the list
+		// response, which only a refetch provides
+		void this.host.fetchRouterModels();
 	}

-	/**
-	 * Read the feed and reconnect until unsubscribed.
-	 */
 	private async runStatusReader(signal: AbortSignal): Promise<void> {
 		await ModelsService.watchModelEvents(signal, (event) => this.applyStatusEvent(event));
 	}

-	/**
-	 * Update one model row status in place, reassigning to trigger reactivity.
-	 */
+	private setPausedDownload(repoWithTag: string, progress: ModelDownloadProgress | null): void {
+		this.pausedDownloads.set(repoWithTag, progress);
+		this.persistPausedDownloads();
+	}
+
+	// reassign the array: mutating an entry in place would not trigger reactivity
 	private setRouterModelStatus(modelId: string, status: ServerModelStatus): void {
 		const idx = this.host.routerModels.findIndex((m) => m.id === modelId);

@@ -254,9 +600,6 @@ export class ModelStatusManager {
 		this.host.routerModels = next;
 	}

-	/**
-	 * Resolve and drop the awaiter when the model reaches its target status.
-	 */
 	private settleStatus(modelId: string, status: ServerModelStatus): void {
 		const waiter = this.statusWaiters.get(modelId);

@@ -266,10 +609,7 @@ export class ModelStatusManager {
 		}
 	}

-	/**
-	 * Register an awaiter that resolves when the feed reports target status.
-	 * One operation runs per model at a time, so one awaiter per model is kept.
-	 */
+	// one operation runs per model at a time, so one waiter per model suffices
 	private waitForStatus(modelId: string, target: ServerModelStatus): Promise<void> {
 		return new Promise((resolve, reject) => {
 			this.statusWaiters.set(modelId, { reject, resolve, target });
diff --git a/tools/ui/src/lib/types/api.d.ts b/tools/ui/src/lib/types/api.d.ts
index 5191b03bd..b52485ade 100644
--- a/tools/ui/src/lib/types/api.d.ts
+++ b/tools/ui/src/lib/types/api.d.ts
@@ -138,6 +138,14 @@ export interface ApiModelsSseData {
 	exit_code?: number;
 }

+/**
+ * Per-file size snapshot reported by the download_progress SSE envelope.
+ * Keys are file URLs, values are byte counters (done <= total).
+ */
+export interface ApiModelsSseDownloadProgressData {
+	progress: Record<string, { done: number; total: number }>;
+}
+
 /**
  * Event kind multiplexed on the /models/sse feed.
  * Only the status_* events carry a status payload, models_reload signals a
@@ -150,7 +158,26 @@ export interface ApiModelsSseData {
 export interface ApiModelsSseEvent {
 	model: string;
 	event: ServerModelsSseEventType;
-	data: ApiModelsSseData;
+	data?: ApiModelsSseData | ApiModelsSseDownloadProgressData;
+}
+
+/**
+ * Request body for POST /models (model download).
+ * `model` is a HuggingFace repo id, optionally suffixed with `:<tag>` to
+ * pin a quantization or sidecar file (e.g. `ggml-org/gemma-3-4b-it-GGUF:Q4_K_M`).
+ */
+export interface ApiModelsDownloadRequest {
+	model: string;
+}
+
+/**
+ * Response from POST /models and DELETE /models. The POST endpoint returns
+ * immediately; the download itself runs in the background and emits events
+ * on /models/sse.
+ */
+export interface ApiModelsDownloadResponse {
+	success: boolean;
+	error?: { code: number; message: string; type: string };
 }

 export interface ApiModelDetails {
diff --git a/tools/ui/src/lib/types/index.ts b/tools/ui/src/lib/types/index.ts
index 28b7cf601..453af9bb8 100644
--- a/tools/ui/src/lib/types/index.ts
+++ b/tools/ui/src/lib/types/index.ts
@@ -14,7 +14,10 @@ export type {
 	ApiModelLoadStage,
 	ApiModelsSseProgress,
 	ApiModelsSseData,
+	ApiModelsSseDownloadProgressData,
 	ApiModelsSseEvent,
+	ApiModelsDownloadRequest,
+	ApiModelsDownloadResponse,
 	ApiModelDetails,
 	ApiLlamaCppServerProps,
 	ApiChatCompletionRequest,
@@ -102,6 +105,8 @@ export type {
 	ModelCapabilities,
 	ModelModalities,
 	ModelOption,
+	ModelDownloadFileProgress,
+	ModelDownloadProgress,
 	ModelLoadProgress,
 	ModalityCapabilities
 } from './models';
diff --git a/tools/ui/src/lib/types/models.d.ts b/tools/ui/src/lib/types/models.d.ts
index 03c723e1a..ca53ca880 100644
--- a/tools/ui/src/lib/types/models.d.ts
+++ b/tools/ui/src/lib/types/models.d.ts
@@ -26,17 +26,27 @@ export interface ModelOption {
 	tags?: string[];
 }

-/**
- * Ephemeral UI-only load progress for one model instance.
- * Lives only while a load runs, driven by the /models/sse feed.
- * stage is absent until the feed reports its first stage.
- */
+/** UI-only load progress for one model, driven by the /models/sse feed. */
 export interface ModelLoadProgress {
 	stages: ApiModelLoadStage[];
 	current: ApiModelLoadStage;
 	value: number;
 }

+/** Per-file bytes of an in-flight download. */
+export interface ModelDownloadFileProgress {
+	done: number;
+	total: number;
+}
+
+/** Progress of an in-flight download, summed across its files. */
+export interface ModelDownloadProgress {
+	downloadedBytes: number;
+	totalBytes: number;
+	/** Per-file progress keyed by file URL. */
+	files: Record<string, ModelDownloadFileProgress>;
+}
+
 export interface ParsedModelId {
 	raw: string;
 	orgName: string | null;
@@ -48,9 +58,7 @@ export interface ParsedModelId {
 	tags: string[];
 }

-/**
- * Modality capabilities for file validation
- */
+/** Modality capabilities for file validation. */
 export interface ModalityCapabilities {
 	hasVision: boolean;
 	hasAudio: boolean;
diff --git a/tools/ui/src/lib/utils/api-fetch.ts b/tools/ui/src/lib/utils/api-fetch.ts
index 9aa3a8579..fefea2fe1 100644
--- a/tools/ui/src/lib/utils/api-fetch.ts
+++ b/tools/ui/src/lib/utils/api-fetch.ts
@@ -137,6 +137,40 @@ export async function apiPost<T, B = unknown>(
 	});
 }

+/**
+ * Send a DELETE request to an API endpoint, optionally with query parameters.
+ *
+ * @param path - API path (query string is appended if `params` is provided)
+ * @param params - Optional record of query parameters
+ * @param options - Additional fetch options
+ * @returns Parsed JSON response
+ */
+export async function apiDelete<T>(
+	path: string,
+	params?: Record<string, string>,
+	options: ApiFetchOptions = {}
+): Promise<T> {
+	// the query is appended to the path so `apiFetch` applies its base-path prefix;
+	// `apiFetchWithParams` resolves an absolute URL and would bypass it
+	let query = '';
+
+	if (params) {
+		const search = new URLSearchParams();
+
+		for (const [key, value] of Object.entries(params)) {
+			if (value !== undefined && value !== null) {
+				search.set(key, value);
+			}
+		}
+
+		const qs = search.toString();
+
+		if (qs) query = `?${qs}`;
+	}
+
+	return apiFetch<T>(`${path}${query}`, { ...options, method: 'DELETE' });
+}
+
 /**
  * Parse error message from a failed response.
  * Tries to extract error message from JSON body, falls back to status text.
diff --git a/tools/ui/src/lib/utils/index.ts b/tools/ui/src/lib/utils/index.ts
index 06ab5d7b6..21d84dc8d 100644
--- a/tools/ui/src/lib/utils/index.ts
+++ b/tools/ui/src/lib/utils/index.ts
@@ -9,7 +9,7 @@

 // API utilities
 export { getAuthHeaders, getJsonHeaders, sanitizeHeaders } from './api-headers';
-export { ApiError, apiFetch, apiFetchWithParams, apiPost } from './api-fetch';
+export { ApiError, apiDelete, apiFetch, apiFetchWithParams, apiPost } from './api-fetch';
 export { validateApiKey } from './api-key-validation';

 // Attachment utilities
@@ -108,7 +108,7 @@ export {
 export { normalizeModelName, isValidModelName } from './model-names';

 // Sidecar token utilities
-export { isAuxSidecar, isDraftSidecar, sidecarFromFileToken } from './sidecars';
+export { isAuxSidecar, isDraftSidecar, sidecarFromFileToken, sidecarFromTag } from './sidecars';

 // Portal utilities
 export { portalToBody } from './portal-to-body';
diff --git a/tools/ui/src/lib/utils/sidecars.ts b/tools/ui/src/lib/utils/sidecars.ts
index a3eb3841f..52532b135 100644
--- a/tools/ui/src/lib/utils/sidecars.ts
+++ b/tools/ui/src/lib/utils/sidecars.ts
@@ -1,4 +1,4 @@
-import { type ModelSidecar, SIDECAR_TOKENS } from '$lib/constants';
+import { MODEL_ID, type ModelSidecar, SIDECAR_TOKENS } from '$lib/constants';
 import { ModelAuxSidecar, ModelDraftSidecar } from '$lib/enums';

 const SIDECAR_TOKEN_SET = new Set<string>(SIDECAR_TOKENS);
@@ -10,6 +10,17 @@ export function sidecarFromFileToken(token: string): ModelSidecar | null {
 	return SIDECAR_TOKEN_SET.has(token) ? (token as ModelSidecar) : null;
 }

+/**
+ * Sidecar a download tag points at: the segment after the last dash,
+ * e.g. `q4_0-mtp` -> `mtp`, `mmproj` -> `mmproj`. Returns null for quant-only
+ * tags and tags whose tail is not a sidecar token.
+ */
+export function sidecarFromTag(tag: string): ModelSidecar | null {
+	const token = tag.toLowerCase().split(MODEL_ID.SEGMENT_SEPARATOR).pop() ?? '';
+
+	return sidecarFromFileToken(token);
+}
+
 export function isDraftSidecar(sidecar: ModelSidecar): sidecar is ModelDraftSidecar {
 	return DRAFT_SIDECAR_SET.has(sidecar);
 }
diff --git a/tools/ui/tests/unit/model-sidecar-grammar.test.ts b/tools/ui/tests/unit/model-sidecar-grammar.test.ts
new file mode 100644
index 000000000..1ad59171a
--- /dev/null
+++ b/tools/ui/tests/unit/model-sidecar-grammar.test.ts
@@ -0,0 +1,140 @@
+import { ModelAuxSidecar, ModelDraftSidecar, SidecarForm } from '$lib/enums';
+import { HuggingFaceService } from '$lib/services/huggingface.service';
+import { ModelsService } from '$lib/services/models.service';
+import { sidecarFromTag } from '$lib/utils';
+import { describe, expect, it } from 'vitest';
+
+const { buildDownloadTag, isSidecarEntry } = ModelsService;
+const { extractQuantMeta } = HuggingFaceService;
+
+// the sidecar filename grammar mirrors the server (common/download.cpp):
+// the token must be lowercase, and it can sit at the start, between name
+// segments, or at the end of the file name
+describe('extractQuantMeta', () => {
+	it('parses the prefix form', () => {
+		expect(extractQuantMeta('mtp-Model-Q4_0.gguf')).toStrictEqual({
+			quant: 'Q4_0',
+			shared: false,
+			sidecar: ModelDraftSidecar.MTP,
+			sidecarForm: SidecarForm.PREFIX
+		});
+	});
+
+	it('parses the infix form', () => {
+		expect(extractQuantMeta('Model-mtp-Q4_0.gguf')).toStrictEqual({
+			quant: 'Q4_0',
+			shared: false,
+			sidecar: ModelDraftSidecar.MTP,
+			sidecarForm: SidecarForm.INFIX
+		});
+	});
+
+	it('parses the suffix form', () => {
+		expect(extractQuantMeta('gemma-4-E2B-it-BF16-mtp.gguf')).toStrictEqual({
+			quant: 'BF16',
+			shared: false,
+			sidecar: ModelDraftSidecar.MTP,
+			sidecarForm: SidecarForm.SUFFIX
+		});
+	});
+
+	it('parses an uppercase infix token', () => {
+		expect(extractQuantMeta('gemma-4-31B-it-MTP-BF16.gguf')).toStrictEqual({
+			quant: 'BF16',
+			shared: false,
+			sidecar: ModelDraftSidecar.MTP,
+			sidecarForm: SidecarForm.INFIX
+		});
+	});
+
+	it('parses an uppercase trailing token', () => {
+		expect(extractQuantMeta('gemma-4-E2B-it-BF16-MTP.gguf')).toStrictEqual({
+			quant: 'BF16',
+			shared: false,
+			sidecar: ModelDraftSidecar.MTP,
+			sidecarForm: SidecarForm.SUFFIX
+		});
+	});
+
+	it('parses a short-form sidecar with a bare quant', () => {
+		expect(extractQuantMeta('mmproj-F16.gguf')).toStrictEqual({
+			quant: 'F16',
+			shared: false,
+			sidecar: ModelAuxSidecar.MMPROJ,
+			sidecarForm: SidecarForm.PREFIX
+		});
+	});
+
+	it('parses a bare sidecar file', () => {
+		expect(extractQuantMeta('imatrix.gguf')).toStrictEqual({
+			quant: null,
+			shared: false,
+			sidecar: ModelAuxSidecar.IMATRIX,
+			sidecarForm: SidecarForm.PREFIX
+		});
+	});
+
+	it('parses a standalone sidecar with a draft tail', () => {
+		expect(extractQuantMeta('Model-mtp-draft.gguf')).toStrictEqual({
+			quant: null,
+			shared: false,
+			sidecar: ModelDraftSidecar.MTP,
+			sidecarForm: SidecarForm.SUFFIX
+		});
+	});
+
+	it('parses a nested sidecar path by its file name', () => {
+		expect(extractQuantMeta('MTP/mtp-Model-Q4_0.gguf')).toStrictEqual({
+			quant: 'Q4_0',
+			shared: false,
+			sidecar: ModelDraftSidecar.MTP,
+			sidecarForm: SidecarForm.PREFIX
+		});
+	});
+
+	it('returns null for non-weight files', () => {
+		expect(extractQuantMeta('README.md')).toBeNull();
+	});
+});
+
+describe('buildDownloadTag', () => {
+	it('appends the quantization', () => {
+		expect(buildDownloadTag('org/repo', 'Q4_0', null)).toBe('org/repo:Q4_0');
+	});
+
+	it('appends the quantization and sidecar', () => {
+		expect(buildDownloadTag('org/repo', 'Q4_0', ModelDraftSidecar.MTP)).toBe('org/repo:Q4_0-mtp');
+	});
+
+	it('uses the sidecar alone when there is no quant', () => {
+		expect(buildDownloadTag('org/repo', null, ModelAuxSidecar.MMPROJ)).toBe('org/repo:mmproj');
+	});
+
+	it('returns the repo id untouched without a tag', () => {
+		expect(buildDownloadTag('org/repo', null, null)).toBe('org/repo');
+	});
+});
+
+describe('isSidecarEntry', () => {
+	it('detects sidecar entries by their tag', () => {
+		expect(isSidecarEntry('org/repo:Q4_0-mtp')).toBe(true);
+		expect(isSidecarEntry('org/repo:mmproj')).toBe(true);
+	});
+
+	it('leaves plain model entries loadable', () => {
+		expect(isSidecarEntry('org/repo:Q4_0')).toBe(false);
+		expect(isSidecarEntry('org/repo')).toBe(false);
+	});
+});
+
+describe('sidecarFromTag', () => {
+	it('reads the token after the last dash', () => {
+		expect(sidecarFromTag('Q4_0-mtp')).toBe(ModelDraftSidecar.MTP);
+		expect(sidecarFromTag('mmproj')).toBe(ModelAuxSidecar.MMPROJ);
+	});
+
+	it('returns null for quant-only and unrelated tags', () => {
+		expect(sidecarFromTag('Q4_K_XL')).toBeNull();
+		expect(sidecarFromTag('UD-Q4_K_XL')).toBeNull();
+	});
+});