studio: interruptible GGUF downloads, cached models endpoint, Downloaded section

1. Interruptible downloads: load_model now checks a cancel event
   between shard downloads. unload_model sets the event so cancel
   stops the download at the next shard boundary.

2. /api/models/cached-gguf endpoint: scans the HF cache for
   already-downloaded GGUF repos with their total size and cache path.

3. "Downloaded" section in Hub model picker: shows cached GGUF repos
   at the top (before Recommended) so users can quickly re-load
   previously downloaded models without re-downloading.
This commit is contained in:
Daniel Han 2026-03-15 07:17:16 +00:00
commit 897d8b426a
4 changed files with 97 additions and 2 deletions

View file

@ -48,6 +48,7 @@ class LlamaCppBackend:
self._lock = threading.Lock()
self._stdout_lines: list[str] = []
self._stdout_thread: Optional[threading.Thread] = None
self._cancel_event = threading.Event()
self._kill_orphaned_servers()
atexit.register(self._cleanup)
@ -399,6 +400,7 @@ class LlamaCppBackend:
Returns True if server started and health check passed.
"""
self._cancel_event.clear()
with self._lock:
self._kill_process()
@ -539,6 +541,8 @@ class LlamaCppBackend:
)
)
try:
if self._cancel_event.is_set():
raise RuntimeError("Cancelled")
local_path = hf_hub_download(
repo_id = hf_repo,
filename = gguf_filename,
@ -547,12 +551,20 @@ class LlamaCppBackend:
# Download remaining shards for split GGUFs — llama-server
# auto-discovers them when they are in the same directory.
for shard in gguf_extra_shards:
if self._cancel_event.is_set():
raise RuntimeError("Cancelled")
logger.info(f"Downloading GGUF shard: {shard}")
hf_hub_download(
repo_id = hf_repo,
filename = shard,
token = hf_token,
)
except RuntimeError as e:
if "Cancelled" in str(e):
raise
raise RuntimeError(
f"Failed to download GGUF file '{gguf_filename}' from {hf_repo}: {e}"
)
except Exception as e:
raise RuntimeError(
f"Failed to download GGUF file '{gguf_filename}' from {hf_repo}: {e}"
@ -680,7 +692,8 @@ class LlamaCppBackend:
return True
def unload_model(self) -> bool:
"""Terminate the llama-server subprocess and clean up state."""
"""Terminate the llama-server subprocess and cancel any in-flight download."""
self._cancel_event.set()
with self._lock:
self._kill_process()
logger.info(f"Unloaded GGUF model: {self._model_identifier}")

View file

@ -577,6 +577,49 @@ async def get_gguf_variants(
)
@router.get("/cached-gguf")
async def list_cached_gguf(
current_subject: str = Depends(get_current_subject),
):
"""List GGUF repos that have already been downloaded to the HF cache."""
try:
from huggingface_hub import constants as hf_constants
cache_dir = Path(hf_constants.HF_HUB_CACHE)
cached = []
if cache_dir.is_dir():
for entry in sorted(cache_dir.iterdir()):
if not entry.name.startswith("models--"):
continue
# models--unsloth--Qwen3-8B-GGUF -> unsloth/Qwen3-8B-GGUF
parts = entry.name.split("--", 1)
if len(parts) < 2:
continue
repo_id = parts[1].replace("--", "/")
if not repo_id.lower().endswith("-gguf"):
continue
# Check if there are actual .gguf files in snapshots
snapshots = entry / "snapshots"
if not snapshots.is_dir():
continue
total_size = 0
has_gguf = False
for snap in snapshots.iterdir():
for f in snap.rglob("*.gguf"):
has_gguf = True
total_size += f.stat().st_size
if has_gguf:
cached.append({
"repo_id": repo_id,
"size_bytes": total_size,
"cache_path": str(entry),
})
return {"cached": cached}
except Exception as e:
logger.error(f"Error listing cached GGUF repos: {e}", exc_info = True)
return {"cached": []}
@router.get("/checkpoints", response_model = CheckpointListResponse)
async def list_checkpoints(
outputs_dir: str = Query(

View file

@ -8,7 +8,8 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { listGgufVariants } from "@/features/chat/api/chat-api";
import { listCachedGguf, listGgufVariants } from "@/features/chat/api/chat-api";
import type { CachedGgufRepo } from "@/features/chat/api/chat-api";
import type { GgufVariantDetail } from "@/features/chat/types/api";
import {
useDebouncedValue,
@ -348,6 +349,12 @@ export function HubModelPicker({
// Track which GGUF repo is expanded for variant selection
const [expandedGguf, setExpandedGguf] = useState<string | null>(null);
// Cached (already downloaded) GGUF repos
const [cachedGguf, setCachedGguf] = useState<CachedGgufRepo[]>([]);
useEffect(() => {
listCachedGguf().then(setCachedGguf).catch(() => {});
}, []);
const recommendedIds = useMemo(
() => dedupe([...models.map((model) => model.id), value ?? ""]),
[models, value],
@ -450,6 +457,26 @@ export function HubModelPicker({
<div ref={scrollRef} className="max-h-64 overflow-y-auto">
<div className="p-1">
{!showHfSection && cachedGguf.length > 0 ? (
<>
<ListLabel>Downloaded</ListLabel>
{cachedGguf.map((c) => (
<div key={c.repo_id}>
<ModelRow
label={c.repo_id}
meta={`GGUF · ${formatBytes(c.size_bytes)}`}
selected={value === c.repo_id}
onClick={() => handleModelClick(c.repo_id)}
vramStatus={null}
/>
{expandedGguf === c.repo_id && (
<GgufVariantExpander repoId={c.repo_id} onSelect={onSelect} gpuGb={gpu.available ? gpu.memoryTotalGb : undefined} systemRamGb={gpu.available ? gpu.systemRamAvailableGb : undefined} />
)}
</div>
))}
</>
) : null}
{!showHfSection ? (
<>
<ListLabel>Recommended</ListLabel>

View file

@ -97,6 +97,18 @@ export async function unloadModel(payload: UnloadModelRequest): Promise<void> {
await parseJsonOrThrow<unknown>(response);
}
export interface CachedGgufRepo {
repo_id: string;
size_bytes: number;
cache_path: string;
}
export async function listCachedGguf(): Promise<CachedGgufRepo[]> {
const response = await authFetch("/api/models/cached-gguf");
const data = await parseJsonOrThrow<{ cached: CachedGgufRepo[] }>(response);
return data.cached;
}
export async function listGgufVariants(
repoId: string,
hfToken?: string,