mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-11 04:56:56 +02:00
ui : Hugging Face Hub data layer
Add HuggingFaceService and its constants/enums/types: GGUF repo search, file tree and model detail fetching, quant/sidecar filename analysis, shard-set collapsing and the llama.app catalog feed, plus an orgOf() helper on the model name utils. Assisted-by: pi:GLM-5.3-Flash
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* HuggingFace Hub constants.
|
||||
*
|
||||
* URLs, parsing regexes and formatting units for the HuggingFaceService.
|
||||
* Reference: https://huggingface.co/docs/huggingface_hub/package_reference/hf_api
|
||||
*/
|
||||
|
||||
// API endpoints
|
||||
|
||||
export const HF_BASE_URL = 'https://huggingface.co';
|
||||
export const HF_API_MODELS_URL = `${HF_BASE_URL}/api/models`;
|
||||
export const HF_AVATARS_URL = `${HF_BASE_URL}/api/avatars`;
|
||||
|
||||
// Query params
|
||||
|
||||
export const HF_FULL_DETAIL_PARAM = 'full=true';
|
||||
export const HF_RECURSIVE_TREE_PARAM = 'recursive=true';
|
||||
/** Search filter that restricts results to repos containing GGUF files. */
|
||||
export const HF_GGUF_FILTER = 'gguf';
|
||||
/** Repeatable `expand` query param selecting fields on the list endpoint. */
|
||||
export const HF_EXPAND_PARAM = 'expand';
|
||||
/**
|
||||
* Fields the model list endpoint omits by default but the discover list rows
|
||||
* render: `gguf` (chat template, context length, param count) drives the
|
||||
* reasoning / tool-use icons and the context badge, `siblings` the vision and
|
||||
* draft-sidecar badges. Without them those parts of a row stay empty.
|
||||
*/
|
||||
export const HF_MODEL_LIST_EXPAND: readonly string[] = [
|
||||
'author',
|
||||
'downloads',
|
||||
'gguf',
|
||||
'lastModified',
|
||||
'likes',
|
||||
'pipeline_tag',
|
||||
'siblings',
|
||||
// `base_model:` tags, so search rows can show the base org's avatar as the
|
||||
// main avatar with the quant org as the corner badge, like catalog rows.
|
||||
'tags'
|
||||
];
|
||||
|
||||
// Repo file conventions
|
||||
|
||||
export const HF_MAIN_BRANCH = 'main';
|
||||
export const HF_README_FILENAME = 'README.md';
|
||||
export const HF_RAW_PATH = 'raw';
|
||||
export const HF_TREE_PATH = 'tree';
|
||||
|
||||
// Pagination
|
||||
|
||||
export const HF_LINK_NEXT_REGEX = /<([^>]+)>;\s*rel="next"/;
|
||||
/** `Link` response header carrying the next page URL for cursor pagination. */
|
||||
export const HF_LINK_HEADER = 'Link';
|
||||
|
||||
// Fetch retry
|
||||
|
||||
export const HF_RETRY_ATTEMPTS = 3;
|
||||
export const HF_RETRY_DELAY_MS = 1000;
|
||||
export const HF_HTTP_NOT_FOUND = 404;
|
||||
export const HF_HTTP_SERVER_ERROR_MIN = 500;
|
||||
|
||||
// Search limits
|
||||
|
||||
export const HF_DEFAULT_LIMIT = 50;
|
||||
/** Safety cap on `/tree` pagination: more pages means a misbehaving endpoint. */
|
||||
export const HF_TREE_MAX_PAGES = 10;
|
||||
export const HF_MAX_LIMIT = 100;
|
||||
|
||||
// GGUF shard files
|
||||
|
||||
/** Matches a split-shard GGUF file name, e.g. `Model-00001-of-00015.gguf`. */
|
||||
export const HF_SHARD_REGEX = /-(\d{5})-of-(\d{5})\.gguf$/i;
|
||||
/** Index (1-based) of the first shard in a split-shard set. */
|
||||
export const HF_FIRST_SHARD = 1;
|
||||
/** Zero-padded width of the shard index in a split-shard file name. */
|
||||
export const HF_SHARD_PAD_WIDTH = 5;
|
||||
|
||||
// Quantization tokens
|
||||
|
||||
/** `UD-` (Unsloth Dynamic) custom quantization prefix, e.g. `UD-Q4_K_XL`. */
|
||||
export const HF_UD_QUANT_PREFIX = 'UD';
|
||||
export const HF_UD_QUANT_PREFIX_REGEX = /^UD-/i;
|
||||
/**
|
||||
* Segment marking an Unsloth `shared-` draft head that borrows the target
|
||||
* model's embedding/output weights, e.g. `...-shared-Q4_K_M.gguf`.
|
||||
*/
|
||||
export const HF_SHARED_DRAFT_TOKEN = 'shared';
|
||||
/**
|
||||
* Extracts the leading precision digits from a quant token, e.g.
|
||||
* `Q4_K_XL` -> 4, `IQ2_XXS` -> 2, `TQ1_0` -> 1, `BF16` -> 16.
|
||||
*/
|
||||
export const HF_QUANT_PRECISION_REGEX = /^(?:I?Q|TQ|BF|F|MXFP)?(\d+)/i;
|
||||
|
||||
// Model card tags
|
||||
|
||||
/** Matches the `base_model:` tag (plain or `quantized:`), capturing the repo id. */
|
||||
export const HF_BASE_MODEL_TAG_REGEX = /^base_model:(?:quantized:)?(.+)$/;
|
||||
export const HF_LICENSE_TAG_PREFIX = 'license:';
|
||||
export const HF_GATED_TAG = 'gated';
|
||||
export const HF_GGUF_TAG = 'gguf';
|
||||
export const HF_SAFETENSORS_TAG = 'safetensors';
|
||||
|
||||
// Pipeline tasks (logic use only - matching `pipeline_tag` values against tags)
|
||||
|
||||
/**
|
||||
* `pipeline_tag` values grouped by the input/output modality they imply, used
|
||||
* to derive a discover row's modality icons. A tag in more than one group (e.g.
|
||||
* `image-to-video`) lights up each modality it belongs to.
|
||||
*/
|
||||
export const HF_MODALITY_PIPELINE_TAGS: Readonly<
|
||||
Record<'audio' | 'video' | 'vision', readonly string[]>
|
||||
> = {
|
||||
audio: [
|
||||
'audio-classification',
|
||||
'audio-to-audio',
|
||||
'automatic-speech-recognition',
|
||||
'text-to-speech',
|
||||
'voice-activity-detection'
|
||||
],
|
||||
video: ['text-to-video', 'image-to-video', 'video-to-video'],
|
||||
vision: ['image-text-to-text', 'image-to-text', 'text-to-image', 'image-to-video']
|
||||
};
|
||||
|
||||
/** Filename token marking an mmproj sidecar sibling (unlocks vision / audio). */
|
||||
export const HF_MMPROJ_FILENAME_TOKEN = 'mmproj';
|
||||
|
||||
export const HF_TASK_TAGS: readonly string[] = [
|
||||
'audio-classification',
|
||||
'audio-to-audio',
|
||||
'automatic-speech-recognition',
|
||||
'conversational',
|
||||
'depth-estimation',
|
||||
'feature-extraction',
|
||||
'fill-mask',
|
||||
'image-classification',
|
||||
'image-feature-extraction',
|
||||
'image-segmentation',
|
||||
'image-text-to-text',
|
||||
'image-to-text',
|
||||
'image-to-video',
|
||||
'object-detection',
|
||||
'question-answering',
|
||||
'reinforcement-learning',
|
||||
'robotics',
|
||||
'sentence-similarity',
|
||||
'summarization',
|
||||
'text2text-generation',
|
||||
'text-classification',
|
||||
'text-generation',
|
||||
'text-to-image',
|
||||
'text-to-speech',
|
||||
'text-to-video',
|
||||
'token-classification',
|
||||
'translation',
|
||||
'video-to-video',
|
||||
'voice-activity-detection',
|
||||
'zero-shot-classification'
|
||||
];
|
||||
|
||||
// Formatting
|
||||
|
||||
export const BYTE = 1;
|
||||
export const KILOBYTE = 1_000;
|
||||
export const MEGABYTE = 1_000_000;
|
||||
export const GIGABYTE = 1_000_000_000;
|
||||
export const TERABYTE = 1_000_000_000_000;
|
||||
|
||||
/**
|
||||
* Matches a human size string (`177GB`, `1.2 TB`, `500MB`), capturing the
|
||||
* numeric value and its unit suffix. Used by `parseSizeBytes`.
|
||||
*/
|
||||
export const HF_SIZE_STRING_REGEX = /^\s*([\d.]+)\s*([a-z]+)\s*$/i;
|
||||
|
||||
/**
|
||||
* Byte multiplier for a size suffix (`k` kilobyte, `m` megabyte, ...) as used by
|
||||
* the llama.app catalog `size` strings, whose suffix is lowercase.
|
||||
*/
|
||||
export const HF_SIZE_SUFFIX_BYTES: Readonly<Record<string, number>> = {
|
||||
b: BYTE,
|
||||
g: GIGABYTE,
|
||||
k: KILOBYTE,
|
||||
m: MEGABYTE,
|
||||
t: TERABYTE
|
||||
};
|
||||
|
||||
export const BYTE_LABEL = 'B';
|
||||
export const KILOBYTE_LABEL = 'KB';
|
||||
export const MEGABYTE_LABEL = 'MB';
|
||||
export const GIGABYTE_LABEL = 'GB';
|
||||
|
||||
/** Count suffixes for compact number formatting, e.g. `1.5K`, `2.0M`. */
|
||||
export const KILO_LABEL = 'K';
|
||||
export const MEGA_LABEL = 'M';
|
||||
export const GIGA_LABEL = 'B';
|
||||
|
||||
// Relative time
|
||||
|
||||
export const MS_PER_DAY = 1000 * 60 * 60 * 24;
|
||||
export const DAYS_PER_WEEK = 7;
|
||||
/** Rough month length in days, used to bucket relative timestamps. */
|
||||
export const DAYS_PER_MONTH = 30;
|
||||
export const DAYS_PER_YEAR = 365;
|
||||
|
||||
export const TODAY_LABEL = 'Today';
|
||||
export const YESTERDAY_LABEL = 'Yesterday';
|
||||
export const DAYS_AGO_LABEL = 'days ago';
|
||||
export const WEEKS_AGO_LABEL = 'weeks ago';
|
||||
export const MONTHS_AGO_LABEL = 'months ago';
|
||||
export const YEARS_AGO_LABEL = 'years ago';
|
||||
|
||||
// Cache paths
|
||||
|
||||
/**
|
||||
* Matches a local HF cache file path
|
||||
* (`.../models--<org>--<name>/snapshots/<sha>/<file>`), capturing the repo
|
||||
* directory name and the repo-relative file path.
|
||||
*/
|
||||
export const HF_CACHE_PATH_REGEX = /models--(.+?)\/snapshots\/[^/]+\/(.+)$/;
|
||||
/** Separator between org and name segments in an HF cache directory name. */
|
||||
export const HF_CACHE_DIR_SEPARATOR = '--';
|
||||
|
||||
// README
|
||||
|
||||
/** Matches a leading YAML frontmatter block (--- ... ---) in a markdown document. */
|
||||
export const HF_FRONTMATTER_REGEX = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/;
|
||||
|
||||
// Param counts
|
||||
|
||||
/**
|
||||
* Best-effort parameter count token in a model id/name, e.g. `27B` from
|
||||
* `Qwen3.8-27B-GGUF` or `300M` from `embeddinggemma-300M-GGUF`.
|
||||
*/
|
||||
export const HF_PARAM_COUNT_REGEX = /(?:^|[^a-z0-9])(\d+(?:[._]\d+)?)\s*([bm])(?![a-z0-9])/i;
|
||||
@@ -45,6 +45,8 @@ export * from './message-export.constants';
|
||||
export * from './path-display.constants';
|
||||
export * from './model-id.constants';
|
||||
export * from './model-loading.constants';
|
||||
export * from './models-discover.constants';
|
||||
export * from './huggingface.constants';
|
||||
export * from './precision.constants';
|
||||
export * from './pwa.constants';
|
||||
export * from './routes.constants';
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Models discover constants.
|
||||
*
|
||||
* Endpoints and settings for the Models Discover dialog.
|
||||
*/
|
||||
|
||||
/** llama.app model catalog used as the default model list. Online-only source; the discover feature requires an internet connection anyway. */
|
||||
export const MODELS_DISCOVER_CATALOG_URL = 'https://llama.app/v1/catalog.json';
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* HuggingFace Hub enums.
|
||||
*
|
||||
* Values mirror the strings used by the HF REST API
|
||||
* (https://huggingface.co/docs/huggingface_hub/package_reference/hf_api)
|
||||
* so they can be sent and compared directly.
|
||||
*/
|
||||
|
||||
/** Sort field for /api/models search queries. */
|
||||
export enum HfModelSort {
|
||||
CREATED_AT = 'createdAt',
|
||||
DOWNLOADS = 'downloads',
|
||||
LAST_MODIFIED = 'lastModified',
|
||||
LIKES = 'likes',
|
||||
TRENDING_SCORE = 'trendingScore'
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the sidecar token (`mtp` / `dflash` / `mmproj` / ...) sits in the
|
||||
* filename.
|
||||
* - `prefix` sidecar file that lives next to the main weights, e.g. `mtp-Q4_0.gguf`
|
||||
* - `suffix` embedded draft baked into the main weights, e.g. `Hy3-IQ1_M-mtp.gguf`
|
||||
* - `infix` standalone sidecar named between head and quant, e.g. `model-mtp-Q8_0.gguf`
|
||||
*/
|
||||
export enum SidecarForm {
|
||||
INFIX = 'infix',
|
||||
PREFIX = 'prefix',
|
||||
SUFFIX = 'suffix'
|
||||
}
|
||||
|
||||
/** Entry type in a model repository file tree (`/tree` responses). */
|
||||
export enum HfEntryType {
|
||||
DIRECTORY = 'directory',
|
||||
FILE = 'file'
|
||||
}
|
||||
@@ -57,6 +57,8 @@ export {
|
||||
SpecialFileType
|
||||
} from './files.enums';
|
||||
|
||||
export { HfEntryType, HfModelSort, SidecarForm } from './huggingface.enums';
|
||||
|
||||
export {
|
||||
MCPConnectionPhase,
|
||||
MCPLogLevel,
|
||||
|
||||
@@ -0,0 +1,794 @@
|
||||
import { PATH_SEPARATOR } from '$lib/constants';
|
||||
import {
|
||||
BYTE,
|
||||
BYTE_LABEL,
|
||||
DAYS_AGO_LABEL,
|
||||
DAYS_PER_MONTH,
|
||||
DAYS_PER_WEEK,
|
||||
DAYS_PER_YEAR,
|
||||
GIGA_LABEL,
|
||||
GIGABYTE,
|
||||
GIGABYTE_LABEL,
|
||||
HF_API_MODELS_URL,
|
||||
HF_AVATARS_URL,
|
||||
HF_BASE_MODEL_TAG_REGEX,
|
||||
HF_BASE_URL,
|
||||
HF_CACHE_DIR_SEPARATOR,
|
||||
HF_CACHE_PATH_REGEX,
|
||||
HF_DEFAULT_LIMIT,
|
||||
HF_FIRST_SHARD,
|
||||
HF_FRONTMATTER_REGEX,
|
||||
HF_FULL_DETAIL_PARAM,
|
||||
HF_GATED_TAG,
|
||||
HF_GGUF_FILTER,
|
||||
HF_GGUF_TAG,
|
||||
HF_HTTP_NOT_FOUND,
|
||||
HF_HTTP_SERVER_ERROR_MIN,
|
||||
HF_LICENSE_TAG_PREFIX,
|
||||
HF_LINK_HEADER,
|
||||
HF_LINK_NEXT_REGEX,
|
||||
HF_MAIN_BRANCH,
|
||||
HF_MAX_LIMIT,
|
||||
HF_MODEL_LIST_EXPAND,
|
||||
HF_PARAM_COUNT_REGEX,
|
||||
HF_QUANT_PRECISION_REGEX,
|
||||
HF_RAW_PATH,
|
||||
HF_README_FILENAME,
|
||||
HF_RECURSIVE_TREE_PARAM,
|
||||
HF_RETRY_ATTEMPTS,
|
||||
HF_RETRY_DELAY_MS,
|
||||
HF_SAFETENSORS_TAG,
|
||||
HF_SHARD_PAD_WIDTH,
|
||||
HF_SHARD_REGEX,
|
||||
HF_SHARED_DRAFT_TOKEN,
|
||||
HF_SIZE_STRING_REGEX,
|
||||
HF_SIZE_SUFFIX_BYTES,
|
||||
HF_TASK_TAGS,
|
||||
HF_TREE_MAX_PAGES,
|
||||
HF_TREE_PATH,
|
||||
HF_UD_QUANT_PREFIX,
|
||||
HF_UD_QUANT_PREFIX_REGEX,
|
||||
KILO_LABEL,
|
||||
KILOBYTE,
|
||||
KILOBYTE_LABEL,
|
||||
MEGA_LABEL,
|
||||
MEGABYTE,
|
||||
MEGABYTE_LABEL,
|
||||
MODELS_DISCOVER_CATALOG_URL,
|
||||
MONTHS_AGO_LABEL,
|
||||
MS_PER_DAY,
|
||||
TODAY_LABEL,
|
||||
WEEKS_AGO_LABEL,
|
||||
YEARS_AGO_LABEL,
|
||||
YESTERDAY_LABEL
|
||||
} from '$lib/constants';
|
||||
import { MODEL_ID, type ModelSidecar } from '$lib/constants';
|
||||
import { HfEntryType, HfModelSort, SidecarForm } from '$lib/enums';
|
||||
import type {
|
||||
HfCatalogEntry,
|
||||
HfModelDetailInfo,
|
||||
HfModelInfo,
|
||||
HfModelSearchParams,
|
||||
HfModelSibling
|
||||
} from '$lib/types/huggingface';
|
||||
import { sidecarFromFileToken } from '$lib/utils';
|
||||
|
||||
/**
|
||||
* HuggingFaceService - Service for browsing and searching GGUF models on Hugging Face Hub
|
||||
*/
|
||||
export class HuggingFaceService {
|
||||
private static readonly BASE_URL = HF_API_MODELS_URL;
|
||||
|
||||
// Cached base model lookups keyed by repo id, so repeated selector opens
|
||||
// never re-hit the HF API for the same repo.
|
||||
private static baseModelCache = new Map<string, { org: string; name: string } | null>();
|
||||
|
||||
private static baseModelPending = new Map<
|
||||
string,
|
||||
Promise<{ org: string; name: string } | null>
|
||||
>();
|
||||
|
||||
/**
|
||||
* Map of quant token to its average bit-depth in bits-per-weight (bpw).
|
||||
*/
|
||||
private static readonly QUANT_BIT_DEPTH: Record<string, number> = {
|
||||
BF16: 16,
|
||||
F16: 16,
|
||||
IQ1_M: 1,
|
||||
IQ1_S: 1,
|
||||
IQ1_XS: 1,
|
||||
IQ1_XXS: 1,
|
||||
IQ2_M: 2,
|
||||
IQ2_S: 2,
|
||||
IQ2_XS: 2,
|
||||
IQ2_XXS: 2,
|
||||
IQ3_M: 3,
|
||||
IQ3_S: 3,
|
||||
IQ3_XS: 3,
|
||||
IQ3_XXS: 3,
|
||||
Q2_K: 2,
|
||||
Q2_K_M: 2,
|
||||
Q2_K_S: 2,
|
||||
Q3_K: 3,
|
||||
Q3_K_L: 3,
|
||||
Q3_K_M: 3,
|
||||
Q3_K_S: 3,
|
||||
Q4_0: 4,
|
||||
Q4_1: 4,
|
||||
Q4_K: 4,
|
||||
Q4_K_M: 4,
|
||||
Q4_K_S: 4,
|
||||
Q5_0: 5,
|
||||
Q5_1: 5,
|
||||
Q5_K: 5,
|
||||
Q5_K_M: 5,
|
||||
Q5_K_S: 5,
|
||||
Q6_K: 6,
|
||||
Q8_0: 8
|
||||
};
|
||||
|
||||
/**
|
||||
* Collapse split GGUF shard sets (`-00001-of-00015.gguf`, ...) to their first
|
||||
* shard, summing every shard's size so the kept entry reflects the whole
|
||||
* quant. Non-sharded files pass through unchanged. Downloads are tag-based
|
||||
* (`repo:quant`), so the first shard is enough to represent the set.
|
||||
*/
|
||||
static collapseGgufShards(siblings: HfModelSibling[]): HfModelSibling[] {
|
||||
const sizeByPath = new Map(siblings.map((f) => [f.path, f.size ?? 0]));
|
||||
const result: HfModelSibling[] = [];
|
||||
|
||||
for (const file of siblings) {
|
||||
const match = HF_SHARD_REGEX.exec(file.path);
|
||||
|
||||
if (!match) {
|
||||
result.push(file);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Keep only the first shard; its size becomes the whole shard set's.
|
||||
if (Number(match[1]) !== HF_FIRST_SHARD) continue;
|
||||
|
||||
const total = Number(match[2]);
|
||||
const stem = file.path.slice(0, file.path.length - match[0].length);
|
||||
|
||||
let size = 0;
|
||||
|
||||
for (let i = HF_FIRST_SHARD; i <= total; i++) {
|
||||
const shard = HuggingFaceService.shardPath(stem, i, total);
|
||||
|
||||
size += sizeByPath.get(shard) ?? 0;
|
||||
}
|
||||
|
||||
result.push({ ...file, size });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// GGUF Model Browsing
|
||||
|
||||
/**
|
||||
* Extract the GGUF quantization token (e.g. `Q4_K_M`) and any sidecar type
|
||||
* (`mtp`, `dflash`, `mmproj`, ...) from a `.gguf` filename. The sidecar token
|
||||
* shows up either as a sidecar prefix (`mtp-<name>.gguf`, `dflash-<name>.gguf`,
|
||||
* `mmproj-<name>.gguf`), as a `-mtp` suffix, or as the whole filename
|
||||
* (`imatrix.gguf`); a `-draft` tail marks a standalone sidecar file
|
||||
* (`Model-MTP-draft.gguf`).
|
||||
*
|
||||
* `sidecarForm` records which side of the filename the sidecar token sat
|
||||
* on so callers can render badges differently (e.g. prefix on the left of
|
||||
* the quant label, suffix appended to it).
|
||||
* `quant` is `null` for files that don't carry a bit-depth token
|
||||
* (e.g. `*-BF16.gguf`); `sidecar` is `null` if no sidecar flag is present.
|
||||
* Returns `null` only when the filename doesn't end in `.gguf`.
|
||||
*/
|
||||
static extractQuantMeta(filename: string): {
|
||||
quant: string | null;
|
||||
/** Draft-head-only variant borrowing embed/output weights from the target model. */
|
||||
shared: boolean;
|
||||
sidecar: ModelSidecar | null;
|
||||
sidecarForm: SidecarForm | null;
|
||||
} | null {
|
||||
if (!MODEL_ID.WEIGHT_EXTENSION_REGEX.test(filename)) return null;
|
||||
|
||||
// HF repos may nest sidecars in a folder (e.g. `MTP/mtp-Model-Q4_0.gguf`);
|
||||
// parse the file name only, the folder adds no quant information.
|
||||
let source = (filename.split(PATH_SEPARATOR).pop() ?? filename).replace(
|
||||
MODEL_ID.WEIGHT_EXTENSION_REGEX,
|
||||
''
|
||||
);
|
||||
let sidecar: ModelSidecar | null = null;
|
||||
let sidecarForm: SidecarForm | null = null;
|
||||
|
||||
// A file named just the sidecar token (`imatrix.gguf`) is the sidecar
|
||||
// itself: no name or quant segments to parse.
|
||||
const bareSidecar = sidecarFromFileToken(source.toLowerCase());
|
||||
|
||||
if (bareSidecar) {
|
||||
return { quant: null, shared: false, sidecar: bareSidecar, sidecarForm: SidecarForm.PREFIX };
|
||||
}
|
||||
|
||||
const prefixMatch = source.match(MODEL_ID.SIDECAR_PREFIX_REGEX);
|
||||
|
||||
if (prefixMatch) {
|
||||
sidecar = sidecarFromFileToken(prefixMatch[1].toLowerCase());
|
||||
sidecarForm = SidecarForm.PREFIX;
|
||||
source = prefixMatch[2];
|
||||
} else {
|
||||
const suffixMatch = source.match(MODEL_ID.SIDECAR_SUFFIX_REGEX);
|
||||
|
||||
if (suffixMatch) {
|
||||
// Take the suffix sidecar even when the head carries no quant:
|
||||
// embedded drafts end in one (`Hy3-IQ1_M-mtp`), standalone sidecar
|
||||
// files do not (`Model-mtp-draft`, `Model-imatrix`).
|
||||
sidecar = sidecarFromFileToken(suffixMatch[2].toLowerCase());
|
||||
sidecarForm = SidecarForm.SUFFIX;
|
||||
source = suffixMatch[1];
|
||||
} else {
|
||||
const infixMatch = source.match(MODEL_ID.SIDECAR_INFIX_REGEX);
|
||||
|
||||
if (infixMatch) {
|
||||
sidecar = sidecarFromFileToken(infixMatch[2].toLowerCase());
|
||||
sidecarForm = SidecarForm.INFIX;
|
||||
source = `${infixMatch[1]}-${infixMatch[3]}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scan dash-separated segments left-to-right for the first quant match.
|
||||
// - For sidecars like `mtp-Q4_0-180MB.gguf` the quant is `Q4_0`.
|
||||
// - For embedded MTP like `Hy3-IQ1_M-mtp.gguf` we have `Hy3-IQ1_M` and `IQ1_M` matches.
|
||||
// - For main files like `Llama-3-8B-Q4_K_M.gguf` we land on the trailing quant.
|
||||
const segments = source.split(MODEL_ID.SEGMENT_SEPARATOR);
|
||||
const quantIdx = segments.findIndex((seg) => MODEL_ID.QUANTIZATION_SEGMENT_REGEX.test(seg));
|
||||
// Unsloth ships draft heads in two layouts: `shared-` files borrow the
|
||||
// embedding/output weights from the target model, others are self-contained.
|
||||
const shared = segments.some((seg) => seg.toLowerCase() === HF_SHARED_DRAFT_TOKEN);
|
||||
|
||||
let quant = quantIdx >= 0 ? segments[quantIdx].toUpperCase() : null;
|
||||
|
||||
// Recombine a `UD-` (Unsloth Dynamic) prefix, e.g. `...-UD-Q4_K_XL.gguf`.
|
||||
// The prefix must be the whole previous segment, matching the server's
|
||||
// `UD-<quant>` custom-quant convention (e.g. not `-mtp-Q4_K_M`).
|
||||
const udPrefixIdx = quantIdx - 1;
|
||||
|
||||
if (quant && quantIdx > 0 && segments[udPrefixIdx].toUpperCase() === HF_UD_QUANT_PREFIX) {
|
||||
quant = `${HF_UD_QUANT_PREFIX}-${quant}`;
|
||||
}
|
||||
|
||||
return { quant, shared, sidecar, sidecarForm };
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter raw siblings by file extension and sort by size descending.
|
||||
*/
|
||||
static filterByExtension(siblings: HfModelSibling[], ext: string): HfModelSibling[] {
|
||||
return siblings
|
||||
.filter((f) => f.path.toLowerCase().endsWith(ext.toLowerCase()) && (f.size ?? 0) > 0)
|
||||
.sort((a, b) => (b.size ?? 0) - (a.size ?? 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format model downloads count with K/M/B suffix
|
||||
*/
|
||||
static formatDownloads(downloads: number): string {
|
||||
if (downloads >= GIGABYTE) {
|
||||
return `${(downloads / GIGABYTE).toFixed(1)}${GIGA_LABEL}`;
|
||||
}
|
||||
|
||||
if (downloads >= MEGABYTE) {
|
||||
return `${(downloads / MEGABYTE).toFixed(1)}${MEGA_LABEL}`;
|
||||
}
|
||||
|
||||
if (downloads >= KILOBYTE) {
|
||||
return `${(downloads / KILOBYTE).toFixed(1)}${KILO_LABEL}`;
|
||||
}
|
||||
|
||||
return downloads.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size in bytes to human-readable string
|
||||
*/
|
||||
static formatFileSize(bytes: number): string {
|
||||
if (bytes >= GIGABYTE) {
|
||||
return `${(bytes / GIGABYTE).toFixed(1)} ${GIGABYTE_LABEL}`;
|
||||
}
|
||||
|
||||
if (bytes >= MEGABYTE) {
|
||||
return `${(bytes / MEGABYTE).toFixed(1)} ${MEGABYTE_LABEL}`;
|
||||
}
|
||||
|
||||
if (bytes >= KILOBYTE) {
|
||||
return `${(bytes / KILOBYTE).toFixed(1)} ${KILOBYTE_LABEL}`;
|
||||
}
|
||||
|
||||
return `${bytes} ${BYTE_LABEL}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format likes count with K suffix if applicable
|
||||
*/
|
||||
static formatLikes(likes: number): string {
|
||||
if (likes >= KILOBYTE) {
|
||||
return `${(likes / KILOBYTE).toFixed(1)}${KILO_LABEL}`;
|
||||
}
|
||||
|
||||
return likes.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp to relative time
|
||||
*/
|
||||
static formatRelativeTime(timestamp: string): string {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
// timestamps can lie in the future (clock skew); clamp so they read as today
|
||||
const diffDays = Math.max(0, Math.floor(diffMs / MS_PER_DAY));
|
||||
|
||||
if (diffDays === 0) return TODAY_LABEL;
|
||||
|
||||
if (diffDays === 1) return YESTERDAY_LABEL;
|
||||
|
||||
if (diffDays < DAYS_PER_WEEK) return `${diffDays} ${DAYS_AGO_LABEL}`;
|
||||
|
||||
if (diffDays < DAYS_PER_MONTH) {
|
||||
return `${Math.floor(diffDays / DAYS_PER_WEEK)} ${WEEKS_AGO_LABEL}`;
|
||||
}
|
||||
|
||||
if (diffDays < DAYS_PER_YEAR) {
|
||||
return `${Math.floor(diffDays / DAYS_PER_MONTH)} ${MONTHS_AGO_LABEL}`;
|
||||
}
|
||||
|
||||
return `${Math.floor(diffDays / DAYS_PER_YEAR)} ${YEARS_AGO_LABEL}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a min-max size range with a single shared unit and no spaces
|
||||
* around the dash, e.g. `19.0-28.6 GB`.
|
||||
*/
|
||||
static formatSizeRange(min: number, max: number): string {
|
||||
const unit =
|
||||
max >= GIGABYTE
|
||||
? GIGABYTE_LABEL
|
||||
: max >= MEGABYTE
|
||||
? MEGABYTE_LABEL
|
||||
: max >= KILOBYTE
|
||||
? KILOBYTE_LABEL
|
||||
: BYTE_LABEL;
|
||||
const div =
|
||||
unit === GIGABYTE_LABEL
|
||||
? GIGABYTE
|
||||
: unit === MEGABYTE_LABEL
|
||||
? MEGABYTE
|
||||
: unit === KILOBYTE_LABEL
|
||||
? KILOBYTE
|
||||
: BYTE;
|
||||
const fmt = (n: number) => (div === BYTE ? `${n}` : `${(n / div).toFixed(1)}`);
|
||||
|
||||
return `${fmt(min)}-${fmt(max)} ${unit}`;
|
||||
}
|
||||
|
||||
// Model Details & Files
|
||||
|
||||
/**
|
||||
* Avatar URL for an author (org or user). 404s when the author does not
|
||||
* exist, so callers should provide a fallback.
|
||||
*/
|
||||
static getAvatarUrl(author: string): string {
|
||||
return `${HF_AVATARS_URL}${PATH_SEPARATOR}${author}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the original (non-GGUF) base model `{ org, name }` for a GGUF repo
|
||||
* from its HF card (`cardData.base_model`). Returns null when the card has no
|
||||
* base model. Results are cached per repo.
|
||||
*/
|
||||
static getBaseModel(repoId: string): Promise<{ org: string; name: string } | null> {
|
||||
const cached = this.baseModelCache.get(repoId);
|
||||
|
||||
if (cached !== undefined) return Promise.resolve(cached);
|
||||
|
||||
const pending = this.baseModelPending.get(repoId);
|
||||
|
||||
if (pending) return pending;
|
||||
|
||||
const promise = (async () => {
|
||||
const details = await this.getDetails(repoId);
|
||||
const base = this.getBaseModels(details)[0];
|
||||
|
||||
if (!base) return null;
|
||||
|
||||
const [org, ...rest] = base.split(PATH_SEPARATOR);
|
||||
|
||||
return { name: rest.join(PATH_SEPARATOR), org };
|
||||
})();
|
||||
|
||||
this.baseModelPending.set(repoId, promise);
|
||||
|
||||
promise
|
||||
.then((result) => this.baseModelCache.set(repoId, result))
|
||||
.finally(() => this.baseModelPending.delete(repoId));
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the original (non-GGUF) base model ids for a repo, from
|
||||
* `cardData.base_model` (string or list) and the `base_model:` tags.
|
||||
*/
|
||||
static getBaseModels(model: HfModelDetailInfo | null): string[] {
|
||||
if (!model) return [];
|
||||
|
||||
const cardBase = model.cardData?.base_model;
|
||||
const fromCard: string[] = Array.isArray(cardBase) ? cardBase : cardBase ? [cardBase] : [];
|
||||
const fromTags = (model.tags ?? [])
|
||||
.map((t) => HF_BASE_MODEL_TAG_REGEX.exec(t)?.[1])
|
||||
.filter((v): v is string => Boolean(v));
|
||||
|
||||
return Array.from(new Set([...fromCard, ...fromTags]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the average bit-depth for a known GGUF quantization.
|
||||
* Returns `null` for unrecognized tokens.
|
||||
*/
|
||||
static getBitDepth(quant: string): number | null {
|
||||
// Strip a leading `UD-` (Unsloth Dynamic) prefix before lookup.
|
||||
const base = quant.replace(HF_UD_QUANT_PREFIX_REGEX, '');
|
||||
const direct = HuggingFaceService.QUANT_BIT_DEPTH[base];
|
||||
|
||||
if (direct !== undefined) return direct;
|
||||
|
||||
// Fall back to the leading precision digits for variants missing from the
|
||||
// map, e.g. `Q4_K_XL` -> 4, `IQ2_XXS` -> 2, `TQ1_0` -> 1, `BF16` -> 16.
|
||||
const match = HF_QUANT_PRECISION_REGEX.exec(base);
|
||||
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get GGUF models by pipeline task
|
||||
*/
|
||||
static async getByTask(
|
||||
pipelineTag: string,
|
||||
params: Omit<HfModelSearchParams, 'pipeline_tag'> = {}
|
||||
): Promise<HfModelInfo[]> {
|
||||
return this.search({
|
||||
...params,
|
||||
pipeline_tag: pipelineTag
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the llama.app model catalog. Returns an empty array on failure so
|
||||
* callers can fall back gracefully.
|
||||
*/
|
||||
static async getCatalog(): Promise<HfCatalogEntry[]> {
|
||||
const response = await fetch(MODELS_DISCOVER_CATALOG_URL);
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to fetch catalog: ${response.status}`);
|
||||
|
||||
return (await response.json()) as HfCatalogEntry[];
|
||||
}
|
||||
|
||||
static async getDetails(modelId: string): Promise<HfModelDetailInfo | null> {
|
||||
// Do not encode the modelId, it contains slashes for author/name.
|
||||
// `full=true` includes cardData (description, base_model) and safetensors.
|
||||
const url = `${HF_API_MODELS_URL}${PATH_SEPARATOR}${modelId}?${HF_FULL_DETAIL_PARAM}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.status === HF_HTTP_NOT_FOUND) return null;
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to fetch model details: ${response.status}`);
|
||||
|
||||
const data = (await response.json()) as HfModelDetailInfo;
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching details for ${modelId}:`, error);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model URL on Hugging Face Hub
|
||||
*/
|
||||
static getModelUrl(modelId: string): string {
|
||||
return `${HF_BASE_URL}${PATH_SEPARATOR}${modelId}`;
|
||||
}
|
||||
|
||||
// Utility Methods
|
||||
|
||||
/**
|
||||
* Get most liked GGUF models
|
||||
*/
|
||||
static async getMostLiked(limit: number = HF_DEFAULT_LIMIT): Promise<HfModelInfo[]> {
|
||||
return this.search({ limit, sort: HfModelSort.LIKES });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get newly released GGUF models
|
||||
*/
|
||||
static async getNew(limit: number = HF_DEFAULT_LIMIT): Promise<HfModelInfo[]> {
|
||||
return this.search({ limit, sort: HfModelSort.CREATED_AT });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get most popular GGUF models by downloads
|
||||
*/
|
||||
static async getPopular(limit: number = HF_DEFAULT_LIMIT): Promise<HfModelInfo[]> {
|
||||
return this.search({ limit, sort: HfModelSort.DOWNLOADS });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the raw README.md for a repo, with the YAML frontmatter stripped.
|
||||
*/
|
||||
static async getReadme(modelId: string): Promise<string | null> {
|
||||
// Do not encode the modelId, it contains slashes for author/name
|
||||
const url = `${HF_BASE_URL}${PATH_SEPARATOR}${modelId}${PATH_SEPARATOR}${HF_RAW_PATH}${PATH_SEPARATOR}${HF_MAIN_BRANCH}${PATH_SEPARATOR}${HF_README_FILENAME}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.status === HF_HTTP_NOT_FOUND) return null;
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to fetch README: ${response.status}`);
|
||||
|
||||
return HuggingFaceService.stripFrontmatter(await response.text());
|
||||
} catch (error) {
|
||||
console.error(`Error fetching README for ${modelId}:`, error);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get repository file tree to list available GGUF variants. Recursive so
|
||||
* repos that keep quants in per-quant subdirectories (e.g. `UD-Q4_K_XL/`)
|
||||
* are included; follows cursor pagination for repos over one page.
|
||||
*/
|
||||
static async getTree(modelId: string): Promise<HfModelSibling[]> {
|
||||
const files: HfModelSibling[] = [];
|
||||
const firstUrl =
|
||||
`${HF_API_MODELS_URL}${PATH_SEPARATOR}${modelId}${PATH_SEPARATOR}${HF_TREE_PATH}` +
|
||||
`${PATH_SEPARATOR}${HF_MAIN_BRANCH}?${HF_RECURSIVE_TREE_PARAM}`;
|
||||
|
||||
let url: string | null = firstUrl;
|
||||
|
||||
try {
|
||||
for (let page = 0; url && page < HF_TREE_MAX_PAGES; page++) {
|
||||
const response: Response = await fetch(url);
|
||||
|
||||
if (!response.ok) return files;
|
||||
|
||||
const data = (await response.json()) as HfModelSibling[];
|
||||
|
||||
files.push(...data.filter((f) => f.type !== HfEntryType.DIRECTORY));
|
||||
|
||||
url = HuggingFaceService.parseNextPageUrl(response.headers.get(HF_LINK_HEADER));
|
||||
}
|
||||
} catch {
|
||||
// Return whatever was fetched before the failure.
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trending GGUF models
|
||||
*/
|
||||
static async getTrending(limit: number = HF_DEFAULT_LIMIT): Promise<HfModelInfo[]> {
|
||||
return this.search({ limit, sort: HfModelSort.TRENDING_SCORE });
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a local HF cache file path
|
||||
* (`.../models--<org>--<name>/snapshots/<sha>/<file>`) into its repo id and
|
||||
* repo-relative file path. Returns null when the path is not an HF cache path.
|
||||
*/
|
||||
static parseCachePath(path: string): { repo: string; file: string } | null {
|
||||
// the paths come from the server's CLI args, which use native separators
|
||||
const match = HF_CACHE_PATH_REGEX.exec(path.replace(/\\/g, PATH_SEPARATOR));
|
||||
|
||||
if (!match) return null;
|
||||
|
||||
const parts = match[1].split(HF_CACHE_DIR_SEPARATOR);
|
||||
|
||||
if (parts.length < 2) return null;
|
||||
|
||||
return {
|
||||
file: match[2],
|
||||
repo: `${parts[0]}${PATH_SEPARATOR}${parts.slice(1).join(HF_CACHE_DIR_SEPARATOR)}`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort parameter count parsed from a model id/name, e.g. `27B` from
|
||||
* `Qwen3.8-27B-GGUF` or `300M` from `embeddinggemma-300M-GGUF`. Returns null
|
||||
* when no size token is present.
|
||||
*/
|
||||
static parseParamCount(name: string): string | null {
|
||||
const match = HF_PARAM_COUNT_REGEX.exec(name);
|
||||
|
||||
if (!match) return null;
|
||||
|
||||
return `${match[1]}${match[2].toUpperCase()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a human size string (`177GB`, `1.2 TB`, `500MB`) to bytes. Returns
|
||||
* null when it carries no number or no known suffix, so callers can fall
|
||||
* back to another source instead of showing a wrong size.
|
||||
*/
|
||||
static parseSizeBytes(size: string): number | null {
|
||||
const match = HF_SIZE_STRING_REGEX.exec(size);
|
||||
|
||||
if (!match) return null;
|
||||
|
||||
const value = parseFloat(match[1]);
|
||||
const multiplier = HF_SIZE_SUFFIX_BYTES[match[2].toLowerCase()];
|
||||
|
||||
if (!Number.isFinite(value) || multiplier === undefined) return null;
|
||||
|
||||
return value * multiplier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse model tags to extract useful information
|
||||
*/
|
||||
static parseTags(tags: string[]): {
|
||||
license: string | null;
|
||||
isGated: boolean;
|
||||
isGguf: boolean;
|
||||
isSafetensors: boolean;
|
||||
tasks: string[];
|
||||
} {
|
||||
const license =
|
||||
tags
|
||||
.find((tag) => tag.startsWith(HF_LICENSE_TAG_PREFIX))
|
||||
?.replace(HF_LICENSE_TAG_PREFIX, '') || null;
|
||||
const isGated = tags.includes(HF_GATED_TAG);
|
||||
const isGguf = tags.includes(HF_GGUF_TAG);
|
||||
const isSafetensors = tags.includes(HF_SAFETENSORS_TAG);
|
||||
const tasks = tags.filter((tag) => HF_TASK_TAGS.includes(tag));
|
||||
|
||||
return { isGated, isGguf, isSafetensors, license, tasks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Search GGUF models with various filters and options.
|
||||
*
|
||||
* Always expands the fields the discover rows render (chat template, context
|
||||
* length, siblings, ...) so a search result carries the same badges as a
|
||||
* catalog entry; caller-provided `expand` entries are merged in.
|
||||
*/
|
||||
static async search(params: HfModelSearchParams = {}): Promise<HfModelInfo[]> {
|
||||
const { expand, limit = HF_DEFAULT_LIMIT, ...restParams } = params;
|
||||
const url = this.buildUrl({
|
||||
...restParams,
|
||||
expand: [...new Set([...HF_MODEL_LIST_EXPAND, ...(expand ?? [])])],
|
||||
filter: HF_GGUF_FILTER,
|
||||
limit: Math.min(limit, HF_MAX_LIMIT)
|
||||
});
|
||||
|
||||
return this.fetchWithRetry(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search models by query string
|
||||
*/
|
||||
static async searchByQuery(
|
||||
query: string,
|
||||
params: Omit<HfModelSearchParams, 'search'> = {}
|
||||
): Promise<HfModelInfo[]> {
|
||||
return this.search({
|
||||
...params,
|
||||
search: query
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build API URL from search parameters
|
||||
*/
|
||||
private static buildUrl(params: HfModelSearchParams): string {
|
||||
const url = new URL(this.BASE_URL);
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => url.searchParams.append(key, v));
|
||||
} else {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delay helper for retry logic
|
||||
*/
|
||||
private static delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch data with retry logic for resilience
|
||||
*/
|
||||
private static async fetchWithRetry(url: string, attempt: number = 1): Promise<HfModelInfo[]> {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === HF_HTTP_NOT_FOUND) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (response.status >= HF_HTTP_SERVER_ERROR_MIN && attempt < HF_RETRY_ATTEMPTS) {
|
||||
await this.delay(HF_RETRY_DELAY_MS * attempt);
|
||||
|
||||
return this.fetchWithRetry(url, attempt + 1);
|
||||
}
|
||||
|
||||
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
return data as HfModelInfo[];
|
||||
}
|
||||
|
||||
if (data && Array.isArray(data.data)) {
|
||||
return data.data as HfModelInfo[];
|
||||
}
|
||||
|
||||
throw new Error('Unexpected API response format');
|
||||
} catch (error) {
|
||||
// only transient failures are retried; anything else fails the search
|
||||
const transient =
|
||||
error instanceof TypeError ||
|
||||
(error instanceof Error && error.message.startsWith('API request failed: 5'));
|
||||
|
||||
if (transient && attempt < HF_RETRY_ATTEMPTS) {
|
||||
await this.delay(HF_RETRY_DELAY_MS * attempt);
|
||||
|
||||
return this.fetchWithRetry(url, attempt + 1);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Internal Methods
|
||||
|
||||
/** Extract the `rel="next"` URL from an RFC 5988 `Link` header, if present. */
|
||||
private static parseNextPageUrl(linkHeader: string | null): string | null {
|
||||
if (!linkHeader) return null;
|
||||
|
||||
const match = HF_LINK_NEXT_REGEX.exec(linkHeader);
|
||||
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/** Full path of one shard in a split-shard GGUF set. */
|
||||
private static shardPath(stem: string, index: number, total: number): string {
|
||||
const pad = (n: number) => String(n).padStart(HF_SHARD_PAD_WIDTH, '0');
|
||||
|
||||
return `${stem}-${pad(index)}-of-${pad(total)}.gguf`;
|
||||
}
|
||||
|
||||
/** Strip a leading YAML frontmatter block (--- ... ---) from a markdown document. */
|
||||
private static stripFrontmatter(text: string): string {
|
||||
const match = text.match(HF_FRONTMATTER_REGEX);
|
||||
|
||||
return match ? text.slice(match[0].length) : text;
|
||||
}
|
||||
}
|
||||
@@ -147,6 +147,16 @@ export { ConversationTransferService } from './conversation-transfer.service';
|
||||
*/
|
||||
export { ModelsService } from './models.service';
|
||||
|
||||
/**
|
||||
* **HuggingFaceService** - Hugging Face Hub browsing and searching
|
||||
*
|
||||
* Stateless HTTP client for the HF REST API (`/api/models`, `/tree`, raw
|
||||
* README) and the llama.app model catalog. Provides GGUF file analysis
|
||||
* (quant metadata, shard collapsing, size formatting) used by the models
|
||||
* discover UI.
|
||||
*/
|
||||
export { HuggingFaceService } from './huggingface.service';
|
||||
|
||||
/**
|
||||
* **PropsService** - Server properties and capabilities retrieval
|
||||
*
|
||||
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* HuggingFace Hub Model Browsing Types
|
||||
*
|
||||
* Types for the HuggingFace REST API (/api/models)
|
||||
* Reference: https://huggingface.co/docs/huggingface_hub/package_reference/hf_api
|
||||
*/
|
||||
|
||||
// Search Options
|
||||
|
||||
export interface HfModelSearchParams {
|
||||
/** Full-text search query */
|
||||
search?: string;
|
||||
/** Filter by pipeline task (e.g., "text-generation", "image-generation") */
|
||||
pipeline_tag?: string;
|
||||
/** Filter by library (e.g., "transformers", "diffusers", "gguf") */
|
||||
library_name?: string;
|
||||
/** Filter by tag (e.g., "gguf") */
|
||||
filter?: string;
|
||||
/** Filter by author or organization */
|
||||
author?: string;
|
||||
/** Sort field */
|
||||
sort?: HfModelSort;
|
||||
/** Results per page (1-100) */
|
||||
limit?: number;
|
||||
/** Pagination offset */
|
||||
offset?: number;
|
||||
/** Filter by model config */
|
||||
config?: string;
|
||||
/** Return full model info */
|
||||
full?: boolean;
|
||||
/**
|
||||
* Fields to include beyond the default set (repeated as `expand=<field>`).
|
||||
* The list endpoint returns only `_id`, `id`, `modelId` and the sort field
|
||||
* unless this is given, so callers rendering badges must ask for them.
|
||||
*/
|
||||
expand?: string[];
|
||||
/** Filter by visibility */
|
||||
private?: boolean;
|
||||
/** Filter by gated status */
|
||||
gated?: boolean;
|
||||
}
|
||||
|
||||
import type { HfEntryType, HfModelSort } from '$lib/enums';
|
||||
|
||||
// Model Info (from /api/models)
|
||||
|
||||
export interface HfModelInfo {
|
||||
/** Unique document ID */
|
||||
_id: string;
|
||||
/** Model ID (e.g., "meta-llama/Llama-3.1-8B-Instruct") */
|
||||
id: string;
|
||||
/** Number of likes */
|
||||
likes: number;
|
||||
/** Trending score */
|
||||
trendingScore: number;
|
||||
/** Whether the model is private */
|
||||
private: boolean;
|
||||
/** Number of downloads */
|
||||
downloads: number;
|
||||
/** Model tags */
|
||||
tags: string[];
|
||||
/** Pipeline task (e.g., "text-generation") */
|
||||
pipeline_tag: string | null;
|
||||
/** Library name (e.g., "transformers", "diffusers") */
|
||||
library_name: string | null;
|
||||
/** Creation timestamp */
|
||||
createdAt: string;
|
||||
/** Model ID (alias for id) */
|
||||
modelId: string;
|
||||
/** Author / organization (present when full=true) */
|
||||
author?: string;
|
||||
/** Last modified timestamp (present when full=true) */
|
||||
lastModified?: string;
|
||||
/** Repository file listing (present when full=true) */
|
||||
siblings?: HfModelSiblingRef[];
|
||||
/** GGUF metadata (context length, architecture, etc.) */
|
||||
gguf?: HfModelGguf;
|
||||
}
|
||||
|
||||
// Model Details (with full=true)
|
||||
|
||||
export interface HfModelCardData {
|
||||
/** License identifier */
|
||||
license?: string;
|
||||
/** License URL */
|
||||
license_link?: string;
|
||||
/** Model description */
|
||||
description?: string;
|
||||
/** Model library */
|
||||
language?: string[];
|
||||
/** Tags */
|
||||
tags?: string[];
|
||||
/** Original (non-GGUF) model(s) this repo was converted from, e.g. `Qwen/Qwen3.8-27B`. The API returns a single string or a list. */
|
||||
base_model?: string | string[];
|
||||
/** Org that produced the quant, e.g. `bartowski` */
|
||||
quantized_by?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** GGUF metadata returned by /api/models/{id}?full=true for GGUF repos. */
|
||||
export interface HfModelGguf {
|
||||
/** Total parameter count */
|
||||
total?: number;
|
||||
/** Architecture, e.g. `gemma3`, `qwen3` */
|
||||
architecture?: string;
|
||||
/** Context length */
|
||||
context_length?: number;
|
||||
/** Chat template (Jinja) */
|
||||
chat_template?: string;
|
||||
bos_token?: string;
|
||||
eos_token?: string;
|
||||
/** Total size of all GGUF files in the repo, in bytes */
|
||||
totalFileSize?: number;
|
||||
}
|
||||
|
||||
export interface HfModelDetails {
|
||||
/** Model ID */
|
||||
id?: string;
|
||||
/** SHA256 digest */
|
||||
sha?: string;
|
||||
/** Last modified timestamp */
|
||||
lastModified?: string;
|
||||
/** Downloads count */
|
||||
downloads?: number;
|
||||
/** Number of likes */
|
||||
likes?: number;
|
||||
/** Whether the model is gated */
|
||||
gated?: boolean;
|
||||
/** Model card data */
|
||||
cardData?: HfModelCardData;
|
||||
/** Tags */
|
||||
tags?: string[];
|
||||
/** Pipeline tag */
|
||||
pipeline_tag?: string | null;
|
||||
/** Library name */
|
||||
library_name?: string | null;
|
||||
/** Safe tensors info */
|
||||
safetensors?: Record<string, unknown>;
|
||||
/** Model size in bytes */
|
||||
size?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface HfModelDetailInfo extends HfModelInfo {
|
||||
/** Whether the model is gated (true/false/'auto') */
|
||||
gated?: boolean | string;
|
||||
/** Repository file listing mirrors of /api/models/{id}/tree/main */
|
||||
siblings?: HfModelSiblingRef[];
|
||||
/** Author / organization */
|
||||
author?: string;
|
||||
/** Last modified timestamp */
|
||||
lastModified?: string;
|
||||
/** Model card YAML data (only present when full=true) */
|
||||
cardData?: HfModelCardData;
|
||||
/** GGUF metadata (only present when full=true for GGUF repos) */
|
||||
gguf?: HfModelGguf;
|
||||
/** Model config (only present when full=true) */
|
||||
config?: Record<string, unknown>;
|
||||
/** Total repo storage in bytes (only present when full=true) */
|
||||
usedStorage?: number;
|
||||
/** Sample widget prompts */
|
||||
widgetData?: Array<{ text?: string }>;
|
||||
/** Related spaces */
|
||||
spaces?: string[];
|
||||
}
|
||||
|
||||
/** A single entry in a model repository's file tree (`/tree` responses) */
|
||||
export interface HfModelSibling {
|
||||
/** Relative path of the file or directory within the repo */
|
||||
path: string;
|
||||
/** Size in bytes (omitted for directories) */
|
||||
size?: number;
|
||||
/** Whether this entry is a directory */
|
||||
type?: HfEntryType;
|
||||
/** OID/hash for the blob */
|
||||
oid?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single file entry in a model's `siblings` list. List (`/api/models`) and
|
||||
* detail (`/api/models/{id}`) responses use `rfilename`, unlike `/tree`.
|
||||
*/
|
||||
export interface HfModelSiblingRef {
|
||||
/** Relative file name within the repo */
|
||||
rfilename: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// API Response
|
||||
|
||||
export interface HfModelApiResponse {
|
||||
/** List of models */
|
||||
data: HfModelInfo[];
|
||||
/** Total count (if available) */
|
||||
total?: number;
|
||||
}
|
||||
|
||||
// llama.app model catalog (https://llama.app/v1/catalog.json)
|
||||
|
||||
/** A single GGUF build/repo within a catalog size. */
|
||||
export interface HfCatalogBuild {
|
||||
quant: string;
|
||||
size: string;
|
||||
sizeBytes: number;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
/** A size variant (e.g. `GPT-OSS 20B`) within a catalog entry. */
|
||||
export interface HfCatalogSize {
|
||||
name: string;
|
||||
params: string;
|
||||
builds: HfCatalogBuild[];
|
||||
}
|
||||
|
||||
/** A single model family in the catalog. `featured` marks the staff picks. */
|
||||
export interface HfCatalogEntry {
|
||||
name: string;
|
||||
brand: string;
|
||||
description: string;
|
||||
details: string;
|
||||
released: string;
|
||||
license: string;
|
||||
featured?: boolean;
|
||||
maxMemGb?: number;
|
||||
sizes: HfCatalogSize[];
|
||||
}
|
||||
@@ -32,6 +32,22 @@ export type {
|
||||
ApiStreamSession
|
||||
} from './api';
|
||||
|
||||
// HuggingFace types
|
||||
export type {
|
||||
HfCatalogBuild,
|
||||
HfCatalogEntry,
|
||||
HfCatalogSize,
|
||||
HfModelApiResponse,
|
||||
HfModelCardData,
|
||||
HfModelDetails,
|
||||
HfModelDetailInfo,
|
||||
HfModelGguf,
|
||||
HfModelInfo,
|
||||
HfModelSearchParams,
|
||||
HfModelSibling,
|
||||
HfModelSiblingRef
|
||||
} from './huggingface';
|
||||
|
||||
// Chat types
|
||||
export type {
|
||||
AttachmentMenuItem,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FILE_PATH_SEPARATOR_REGEX } from '$lib/constants';
|
||||
import { FILE_PATH_SEPARATOR_REGEX, MODEL_ID } from '$lib/constants';
|
||||
|
||||
/**
|
||||
* Normalizes a model name by extracting the filename from a path, but preserves Hugging Face repository format.
|
||||
@@ -56,3 +56,14 @@ export function normalizeModelName(modelName: string): string {
|
||||
export function isValidModelName(modelName: string): boolean {
|
||||
return normalizeModelName(modelName).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Org segment of a HuggingFace repo id (`ggml-org/Qwen3-8B` -> `ggml-org`).
|
||||
* Returns the input itself when it carries no org separator, and an empty string
|
||||
* for a missing id, so callers can use `||` against their own fallback org.
|
||||
*/
|
||||
export function orgOf(repoId: string | null | undefined): string {
|
||||
if (!repoId) return '';
|
||||
|
||||
return repoId.split(MODEL_ID.ORG_SEPARATOR)[0] || repoId;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user