Commit 5c2ea1ab1 for llama.cpp

commit 5c2ea1ab11a17a750dabffe31e8c182ddf4966cc
Author: Aleksander Grygier <aleksander.grygier@gmail.com>
Date:   Mon Nov 2 12:36:00 2026 +0100

    ui: disable model deletion the server would refuse (#31305)

    The router only removes models it took from the Hugging Face cache, but
    the models manager offered "Delete from disk" for every model, so a model
    from --models-preset or --models-dir failed with a "not removable" toast.
    Read the source and can_remove fields of /models and show the delete
    action disabled for those models, with a tooltip (a hint line in the
    mobile drawer) saying how to remove them instead.

    Assisted-by: Claude (Opus 5.5 Medium)

diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelConfiguration/ModelsManagerModelConfiguration.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelConfiguration/ModelsManagerModelConfiguration.svelte
index 0bf3ed491..427ab0189 100644
--- a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelConfiguration/ModelsManagerModelConfiguration.svelte
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelConfiguration/ModelsManagerModelConfiguration.svelte
@@ -7,6 +7,7 @@
 	import { Trash2 } from '@lucide/svelte';
 	import { DialogConfirmDownload } from '$lib/components/app/dialogs';
 	import { Button } from '$lib/components/ui/button';
+	import * as Tooltip from '$lib/components/ui/tooltip';
 	import { ModelDownloadConfirmAction, ServerModelStatus } from '$lib/enums';
 	import { HuggingFaceService } from '$lib/services';
 	import { deviceStore, modelsStore, uiStore } from '$lib/stores';
@@ -26,6 +27,8 @@

 	// the same removal the table row offers, confirmed by the same dialog
 	let deleteOpen = $state(false);
+	// a model the router did not take from its HF cache is not its own to delete
+	let removeBlockedReason = $derived(modelsStore.removeBlockedReason(option.model));

 	// the props cache is a plain Map, so the read has to name its version to stay reactive
 	let serverProps = $derived.by(() => {
@@ -130,15 +133,29 @@
 			<ModelsManagerModelConfigurationInformation hub={hubDetails} {option} {serverProps} />

 			<div class="mt-4 border-t border-border/30 pt-3 pb-2">
-				<Button
-					class="w-full justify-start gap-2 text-destructive hover:bg-destructive/10 hover:text-destructive dark:hover:bg-destructive/20"
-					onclick={() => (deleteOpen = true)}
-					size="sm"
-					variant="ghost"
-				>
-					<Trash2 class="h-4 w-4" />
-					Delete this model from disk
-				</Button>
+				{#if removeBlockedReason}
+					<!-- a disabled button takes no pointer events, so the wrapper holds the hover -->
+					<Tooltip.Root>
+						<Tooltip.Trigger>
+							{#snippet child({ props })}
+								<div {...props}>
+									{@render deleteButton()}
+								</div>
+							{/snippet}
+						</Tooltip.Trigger>
+
+						<Tooltip.Content class="max-w-64">
+							<p>{removeBlockedReason}</p>
+						</Tooltip.Content>
+					</Tooltip.Root>
+
+					<!-- a phone has no hover, so the reason sits under the button -->
+					{#if deviceStore.isMobile}
+						<p class="px-3 pt-1 text-xs text-muted-foreground">{removeBlockedReason}</p>
+					{/if}
+				{:else}
+					{@render deleteButton()}
+				{/if}
 			</div>
 		{/if}
 	</div>
@@ -178,3 +195,16 @@
 		repoWithTag={option.model}
 	/>
 </div>
+
+{#snippet deleteButton()}
+	<Button
+		class="w-full justify-start gap-2 text-destructive hover:bg-destructive/10 hover:text-destructive dark:hover:bg-destructive/20"
+		disabled={removeBlockedReason !== null}
+		onclick={() => (deleteOpen = true)}
+		size="sm"
+		variant="ghost"
+	>
+		<Trash2 class="h-4 w-4" />
+		Delete this model from disk
+	</Button>
+{/snippet}
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/row-actions.ts b/tools/ui/src/lib/components/app/models/ModelsManager/row-actions.ts
index f3c4363c7..42c282e05 100644
--- a/tools/ui/src/lib/components/app/models/ModelsManager/row-actions.ts
+++ b/tools/ui/src/lib/components/app/models/ModelsManager/row-actions.ts
@@ -49,6 +49,9 @@ export function modelRowActions(
 		];
 	}

+	// a model the router did not take from its HF cache is not its own to delete
+	const removeBlockedReason = modelsStore.removeBlockedReason(option.model);
+
 	return [
 		{
 			icon: favorite ? HeartOff : Heart,
@@ -56,10 +59,12 @@ export function modelRowActions(
 			onclick: () => modelsStore.toggleFavorite(option.model)
 		},
 		{
+			disabled: removeBlockedReason !== null,
 			icon: Trash2,
 			label: 'Delete from disk',
 			onclick: () => onDelete(option),
 			separator: true,
+			tooltip: removeBlockedReason ?? undefined,
 			variant: 'destructive' as const
 		},
 		{
diff --git a/tools/ui/src/lib/components/app/navigation/DropdownMenuActions.svelte b/tools/ui/src/lib/components/app/navigation/DropdownMenuActions.svelte
index 161462a04..f31430506 100644
--- a/tools/ui/src/lib/components/app/navigation/DropdownMenuActions.svelte
+++ b/tools/ui/src/lib/components/app/navigation/DropdownMenuActions.svelte
@@ -13,6 +13,8 @@
 		onclick: (event: Event) => void;
 		variant?: 'default' | 'destructive';
 		disabled?: boolean;
+		/** Shown on hover, e.g. why a disabled action is not available. */
+		tooltip?: string;
 		shortcut?: string[];
 		separator?: boolean;
 	}
@@ -88,7 +90,14 @@
 					>
 						{@render iconComponent(action.icon, 'h-4 w-4 shrink-0')}

-						<span class="flex-1">{action.label}</span>
+						<span class="flex flex-1 flex-col">
+							{action.label}
+
+							<!-- a phone has no hover, so the hint sits under the label -->
+							{#if action.tooltip}
+								<span class="text-xs text-muted-foreground">{action.tooltip}</span>
+							{/if}
+						</span>

 						{#if action.shortcut}
 							<KeyboardShortcutInfo keys={action.shortcut} variant={action.variant} />
@@ -131,29 +140,50 @@
 					<DropdownMenu.Separator />
 				{/if}

-				<DropdownMenu.Item
-					class="flex items-center justify-between hover:[&>kbd]:opacity-100"
-					disabled={action.disabled}
-					onclick={action.onclick}
-					variant={action.variant}
-				>
-					<div class="flex items-center gap-2">
-						{@render iconComponent(
-							action.icon,
-							`h-4 w-4 ${action.variant === 'destructive' ? 'text-destructive' : ''}`
-						)}
-						{action.label}
-					</div>
-
-					{#if action.shortcut}
-						<KeyboardShortcutInfo keys={action.shortcut} variant={action.variant} />
-					{/if}
-				</DropdownMenu.Item>
+				{#if action.tooltip}
+					<!-- a disabled item takes no pointer events, so the wrapper holds the hover -->
+					<Tooltip.Root>
+						<Tooltip.Trigger>
+							{#snippet child({ props })}
+								<div {...props}>
+									{@render menuItem(action)}
+								</div>
+							{/snippet}
+						</Tooltip.Trigger>
+
+						<Tooltip.Content class="z-[999999] max-w-64" side="left">
+							<p>{action.tooltip}</p>
+						</Tooltip.Content>
+					</Tooltip.Root>
+				{:else}
+					{@render menuItem(action)}
+				{/if}
 			{/each}
 		</DropdownMenu.Content>
 	</DropdownMenu.Root>
 {/if}

+{#snippet menuItem(action: ActionItem)}
+	<DropdownMenu.Item
+		class="flex items-center justify-between hover:[&>kbd]:opacity-100"
+		disabled={action.disabled}
+		onclick={action.onclick}
+		variant={action.variant}
+	>
+		<div class="flex items-center gap-2">
+			{@render iconComponent(
+				action.icon,
+				`h-4 w-4 ${action.variant === 'destructive' ? 'text-destructive' : ''}`
+			)}
+			{action.label}
+		</div>
+
+		{#if action.shortcut}
+			<KeyboardShortcutInfo keys={action.shortcut} variant={action.variant} />
+		{/if}
+	</DropdownMenu.Item>
+{/snippet}
+
 {#snippet iconComponent(IconComponent: Component, className: string)}
 	<IconComponent class={className} />
 {/snippet}
diff --git a/tools/ui/src/lib/constants/models-manager.constants.ts b/tools/ui/src/lib/constants/models-manager.constants.ts
index bbcf912dd..e6165f842 100644
--- a/tools/ui/src/lib/constants/models-manager.constants.ts
+++ b/tools/ui/src/lib/constants/models-manager.constants.ts
@@ -1,6 +1,6 @@
 /** Labels of the models manager table sections. */

-import { ModelsTableGroupKind } from '$lib/enums';
+import { ModelsTableGroupKind, ServerModelSource } from '$lib/enums';

 export const MODELS_TABLE_GROUP_LABELS: Record<ModelsTableGroupKind, string> = {
 	[ModelsTableGroupKind.DOWNLOADING]: 'Downloading',
@@ -16,3 +16,21 @@ export const MODELS_TABLE_GROUP_LABELS: Record<ModelsTableGroupKind, string> = {
  * so the two stacked sticky rows do not show a gap or overlap by a pixel.
  */
 export const MODELS_TABLE_GROUP_STICKY_OFFSET = 'top: calc(2.25rem - 1px)';
+
+/**
+ * Why a model cannot be deleted from disk, by where the router found it. Only a
+ * model from the HF cache is the router's own to remove.
+ */
+export const MODEL_REMOVE_BLOCKED_REASONS: Record<
+	Exclude<ServerModelSource, ServerModelSource.CACHE>,
+	string
+> = {
+	[ServerModelSource.MODELS_DIR]:
+		'Found in --models-dir: delete the file from that folder to remove it.',
+	[ServerModelSource.PRESET]:
+		'Defined in a --models-preset file: remove its section from the preset to delete it.'
+};
+
+/** Fallback when the router refuses removal without saying where the model came from. */
+export const MODEL_REMOVE_BLOCKED_FALLBACK =
+	'The server does not allow deleting this model: it is not from the Hugging Face cache.';
diff --git a/tools/ui/src/lib/enums/index.ts b/tools/ui/src/lib/enums/index.ts
index 142962826..348864c74 100644
--- a/tools/ui/src/lib/enums/index.ts
+++ b/tools/ui/src/lib/enums/index.ts
@@ -86,7 +86,12 @@ export {
 	ModelsTableSortKey
 } from './model.enums';

-export { ServerRole, ServerModelStatus, ServerModelsSseEventType } from './server.enums';
+export {
+	ServerRole,
+	ServerModelSource,
+	ServerModelStatus,
+	ServerModelsSseEventType
+} from './server.enums';

 export { ParameterSource, SyncableParameterType, SettingsFieldType } from './settings.enums';

diff --git a/tools/ui/src/lib/enums/server.enums.ts b/tools/ui/src/lib/enums/server.enums.ts
index 6f9058942..891fb1720 100644
--- a/tools/ui/src/lib/enums/server.enums.ts
+++ b/tools/ui/src/lib/enums/server.enums.ts
@@ -26,6 +26,16 @@ export enum ServerModelStatus {
 	UNLOADED = 'unloaded'
 }

+/**
+ * Where the router found a model - matches server_model_source in
+ * tools/server/server-models.h. Only a cached model can be removed from disk.
+ */
+export enum ServerModelSource {
+	CACHE = 'cache',
+	MODELS_DIR = 'models_dir',
+	PRESET = 'preset'
+}
+
 /**
  * /models/sse event type enum - discriminates the records broadcast on the
  * model status feed in ROUTER mode. Matches the event names emitted by
diff --git a/tools/ui/src/lib/stores/models/index.svelte.ts b/tools/ui/src/lib/stores/models/index.svelte.ts
index 0a683c24b..990bcf8e8 100644
--- a/tools/ui/src/lib/stores/models/index.svelte.ts
+++ b/tools/ui/src/lib/stores/models/index.svelte.ts
@@ -12,10 +12,12 @@ import {
 	FAVORITE_MODELS_LOCALSTORAGE_KEY,
 	HIDDEN_MODELS_LOCALSTORAGE_KEY,
 	MODEL_GROUP_OPEN_LOCALSTORAGE_KEY,
+	MODEL_REMOVE_BLOCKED_FALLBACK,
+	MODEL_REMOVE_BLOCKED_REASONS,
 	RECENT_MODEL_LIMIT,
 	RECENT_MODELS_LOCALSTORAGE_KEY
 } from '$lib/constants';
-import { ReasoningEffort, ServerModelStatus } from '$lib/enums';
+import { ReasoningEffort, ServerModelSource, ServerModelStatus } from '$lib/enums';
 import { HuggingFaceService } from '$lib/services/huggingface.service';
 import { ModelsService } from '$lib/services/models.service';
 // direct imports between stores, not via the barrel, to avoid circular deps
@@ -23,6 +25,7 @@ import { conversationsStore } from '$lib/stores/conversations/index.svelte';
 import { type ModelPropsHost, ModelPropsManager } from '$lib/stores/models/props.svelte';
 import { type ModelStatusHost, ModelStatusManager } from '$lib/stores/models/status.svelte';
 import { serverStore } from '$lib/stores/server.svelte';
+import type { ApiModelDataEntry } from '$lib/types';
 import type { ParsedModelId } from '$lib/types/models';
 import { getConversationModel } from '$lib/utils/conversation-utils';
 import { repoOf } from '$lib/utils/model-names';
@@ -424,6 +427,23 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
 		return this.isModelLoaded(modelId) && !this._status.isOperationInProgress(modelId);
 	}

+	/**
+	 * Why the router would refuse to delete a model from disk, or null when it can.
+	 * Only a model from the HF cache is removable; a server that does not report
+	 * `can_remove` is let through and answers for itself.
+	 */
+	removeBlockedReason(modelId: string): string | null {
+		const model = this.routerModels.find((m) => m.id === modelId);
+
+		if (!model || model.can_remove !== false) return null;
+
+		if (model.source && model.source !== ServerModelSource.CACHE) {
+			return MODEL_REMOVE_BLOCKED_REASONS[model.source];
+		}
+
+		return MODEL_REMOVE_BLOCKED_FALLBACK;
+	}
+
 	/**
 	 * Select a model. `recordRecent` keeps automatic picks out of the recency list.
 	 */
diff --git a/tools/ui/src/lib/types/api.d.ts b/tools/ui/src/lib/types/api.d.ts
index 72d42a6c5..d3bfbb184 100644
--- a/tools/ui/src/lib/types/api.d.ts
+++ b/tools/ui/src/lib/types/api.d.ts
@@ -3,6 +3,7 @@ import type { ModelSidecar } from '$lib/constants';
 import type {
 	ContentPartType,
 	FileTypeAudio,
+	ServerModelSource,
 	ServerModelsSseEventType,
 	ServerModelStatus,
 	ServerRole
@@ -95,6 +96,10 @@ export interface ApiModelDataEntry {
 	path: string;
 	/** Current status of the model */
 	status: ApiModelStatus;
+	/** Where the router found the model: the HF cache, --models-dir or a preset */
+	source?: ServerModelSource;
+	/** Whether the router can delete the model's files (only cached models) */
+	can_remove?: boolean;
 	/** Alternative names that resolve to this model */
 	aliases?: string[];
 	/** Informational tags for this model */
diff --git a/tools/ui/tests/stories/ModelsManager.stories.svelte b/tools/ui/tests/stories/ModelsManager.stories.svelte
index dd474cee5..77e53f8e3 100644
--- a/tools/ui/tests/stories/ModelsManager.stories.svelte
+++ b/tools/ui/tests/stories/ModelsManager.stories.svelte
@@ -6,7 +6,7 @@
 		ModelsTableGroup
 	} from '$lib/components/app/models/ModelsManager/utils';
 	import { MODELS_TABLE_GROUP_LABELS } from '$lib/constants';
-	import { ModelGroupKind, ModelsTableGroupKind } from '$lib/enums';
+	import { ModelGroupKind, ModelsTableGroupKind, ServerModelStatus } from '$lib/enums';
 	import { modelsStore } from '$lib/stores/models/index.svelte';
 	import type { ModelOption } from '$lib/types/models';

@@ -60,7 +60,7 @@
 			object: 'model',
 			owned_by: 'llamacpp',
 			path: `/models/${qwen.model}`,
-			status: { value: 'loaded' }
+			status: { value: ServerModelStatus.LOADED }
 		}
 	];
 </script>