mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-30 18:07:38 -05:00
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
This commit is contained in:
Vendored
+6
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`;
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
const entries: {
|
||||
details?: ApiModelsListResponse['models'][number];
|
||||
item: ApiModelDataEntry;
|
||||
}[] = response.data.map((item: ApiModelDataEntry, index: number) => ({
|
||||
details: response.models?.[index],
|
||||
item
|
||||
}));
|
||||
|
||||
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 ?? []
|
||||
};
|
||||
});
|
||||
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). */
|
||||
|
||||
@@ -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>();
|
||||
|
||||
constructor(private host: ModelStatusHost) {}
|
||||
/**
|
||||
* 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();
|
||||
|
||||
// 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 });
|
||||
|
||||
Vendored
+28
-1
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
Vendored
+16
-8
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user