Compare commits

...
Author SHA1 Message Date
Aleksander Grygier 2498e9ae76 ui : optional sanitized raw HTML in markdown
Add an allowHtml prop to MarkdownContent: raw HTML found in the markdown is
rendered after DOMPurify sanitization instead of being escaped as literal
text. Default stays escaped.

Assisted-by: pi:GLM-5.3-Flash
2026-09-07 16:08:16 +02:00
Aleksander Grygier b774d2c807 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
2026-09-07 16:08:16 +02:00
Aleksander Grygier affdf585c1 ui : model memory-fit estimation
Replace the raw runtime-memory estimate with the app's compatibility check:
the smallest real Mac memory tier that fits a model file, budgeted as
RAM x 0.75 minus fixed overhead with headroom on the file size. The constants
move to lib; the unused runtime-memory estimate is dropped. browser-info's
OS detection is exported for reuse.

Assisted-by: pi:GLM-5.3-Flash
2026-09-07 16:08:16 +02:00
Aleksander Grygier b9bf09af84 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
2026-09-07 16:08:16 +02:00
Aleksander Grygier a693dd45c2 ui : model id grammar for sidecars, quants and capability parsing
Extend the shared model id parser with sidecar tokens (draft variants and
auxiliary imatrix/mmproj files), weight-file and custom-quant regexes, and add
the tools capability to ModelCapabilities; the selector option row picks it up
from the model's declared capabilities.

Assisted-by: pi:GLM-5.3-Flash
2026-09-07 16:08:16 +02:00
Aleksander Grygier 8635b58aae ui : type-safe API types, fetch helpers and download-ready models store plumbing
Assisted-by: pi:GLM-5.3-Flash
2026-09-07 16:08:15 +02:00
Aleksander Grygier 296b0f8881 server : fix deadlock when removing a finished download
The download monitor thread acquires the mutex on its way out, so joining
it while holding the lock in server_models::remove deadlocks once the
status has flipped to DOWNLOADED. Join outside the lock, same pattern as
load_models().

Assisted-by: pi:zai-org/GLM-5.3
2026-09-07 16:08:15 +02:00
Aleksander Grygier b14462c0c9 common : resolve <quant>-<sidecar> download tags and list cached sidecars
A Q4_0-mtp style tag now resolves the sidecar file when no model file matches it, so a solo draft or mmproj download actually pulls the file. Cached sidecar files list as their own entries so the state survives a restart, and removing such a tag deletes only the sidecar.

Assisted-by: pi:zai-org/GLM-5.3
2026-09-07 16:08:15 +02:00
46 changed files with 3520 additions and 276 deletions
+221 -58
View File
@@ -8,6 +8,7 @@
#include "json.h"
#include <algorithm>
#include <cctype>
#include <filesystem>
#include <fstream>
#include <future>
@@ -534,15 +535,18 @@ static gguf_split_info get_gguf_split_info(const std::string & path) {
}
// Q4_0 -> 4, F16 -> 16, NVFP4 -> 4, Q8_K_M -> 8, etc
static int extract_quant_bits(const std::string & filename) {
auto split = get_gguf_split_info(filename);
static int quant_bits_from_tag(const std::string & tag) {
auto pos = tag.find_first_of("0123456789");
auto pos = split.tag.find_first_of("0123456789");
if (pos == std::string::npos) {
return 0;
}
return std::stoi(split.tag.substr(pos));
return std::stoi(tag.substr(pos));
}
static int extract_quant_bits(const std::string & filename) {
return quant_bits_from_tag(get_gguf_split_info(filename).tag);
}
static hf_cache::hf_files get_split_files(const hf_cache::hf_files & files,
@@ -563,12 +567,127 @@ static hf_cache::hf_files get_split_files(const hf_cache::hf_files & files,
return result;
}
// pick the best sibling GGUF whose filename contains `keyword` (e.g. "mmproj" / "mtp"),
// sidecar filename tokens, as used in `<quant>-<sidecar>` download tags,
// e.g. `Q4_0-mtp` for `mtp-Model-Q4_0.gguf`, `BF16-mmproj` for `mmproj-BF16.gguf`
static const std::vector<std::string> sidecar_tokens = {
"mtp", "eagle3", "dflash", "dspark", "mmproj", "imatrix",
};
static bool iequals(const std::string & a, const std::string & b) {
return a.size() == b.size() &&
std::equal(a.begin(), a.end(), b.begin(), [](char x, char y) {
return std::tolower((unsigned char) x) == std::tolower((unsigned char) y);
});
}
// split a `<quant>-<sidecar>` tag into its parts, e.g. `Q4_0-mtp` -> {`Q4_0`, `mtp`};
// a bare sidecar tag (`mtp`) yields an empty quant; no sidecar yields an empty token
static std::pair<std::string, std::string> split_sidecar_tag(const std::string & tag) {
for (const auto & t : sidecar_tokens) {
if (tag.size() > t.size() + 1 && iequals(tag.substr(tag.size() - t.size() - 1), "-" + t)) {
return { tag.substr(0, tag.size() - t.size() - 1), t };
}
if (iequals(tag, t)) {
return { "", t };
}
}
return { tag, "" };
}
// filename with directory and extension removed, e.g. `sub/mtp-Model-Q4_0.gguf` -> `mtp-Model-Q4_0`
static std::string stem_of(const std::string & path) {
std::string base = path;
if (auto pos = base.rfind('/'); pos != std::string::npos) {
base = base.substr(pos + 1);
}
string_remove_suffix(base, ".gguf");
return base;
}
// a sidecar file carries its token as a name segment in any position and case,
// e.g. `mmproj-Model-F16.gguf`, `Model-mtp-Q4_0.gguf`, `Model-Q4_0-mtp.gguf`
// or the short `mmproj-F16.gguf`; the token must be a whole segment, so an
// unrelated name that merely contains it (`smtp-Model.gguf`) is a plain model
static std::string sidecar_token_of(const std::string & path) {
std::string base = stem_of(path);
for (char & c : base) {
c = (char) std::tolower((unsigned char) c);
}
if (base.empty()) {
return {};
}
for (const auto & t : sidecar_tokens) {
if (base == t) {
return t; // the sidecar file itself, e.g. `imatrix.gguf`
}
if (base.rfind(t + "-", 0) == 0) {
return t; // `mtp-Model-Q4_0.gguf`
}
if (base.find("-" + t + "-") != std::string::npos) {
return t; // `Model-mtp-Q4_0.gguf`
}
// `Model-Q4_0-mtp.gguf`, optionally with a `-draft` tail
if (string_ends_with(base, "-" + t) || string_ends_with(base, "-" + t + "-draft")) {
return t;
}
}
return {};
}
// name with the sidecar token segment removed, lowercased for tag parsing,
// e.g. `Model-MTP-Q4_0` -> `model-q4_0`
static std::string strip_sidecar_token(const std::string & base, const std::string & token) {
std::string lower = base;
for (char & c : lower) {
c = (char) std::tolower((unsigned char) c);
}
if (lower == token) {
return {};
}
if (lower.rfind(token + "-", 0) == 0) {
return lower.substr(token.size() + 1);
}
const std::string seg = "-" + token + "-";
if (auto pos = lower.find(seg); pos != std::string::npos) {
return lower.substr(0, pos) + "-" + lower.substr(pos + seg.size());
}
if (string_ends_with(lower, "-" + token + "-draft")) {
return lower.substr(0, lower.size() - token.size() - 7);
}
if (string_ends_with(lower, "-" + token)) {
return lower.substr(0, lower.size() - token.size() - 1);
}
return lower;
}
// the quant a sidecar file belongs to, from its name with the token stripped;
// a short-form name (`mmproj-F16.gguf`) leaves the bare quant, which has no
// `-` separator for the tag regex, so it becomes the tag directly
static std::string sidecar_quant(const std::string & path, const std::string & token) {
std::string name = strip_sidecar_token(stem_of(path), token);
std::string tag = get_gguf_split_info(name + ".gguf").tag;
if (tag.empty() && name.find('-') == std::string::npos) {
for (char & c : name) {
c = (char) std::toupper((unsigned char) c);
}
tag = name;
}
return tag;
}
// pick the best sibling GGUF carrying the sidecar `token` (e.g. "mmproj" / "mtp"),
// preferring deeper shared directory prefix with the model, then exact `tag` match,
// then closest quantization to the tag when given, or to the model otherwise
// an empty `model` skips the directory constraint: the sidecar is matched by tag alone
static hf_cache::hf_file find_best_sibling(const hf_cache::hf_files & files,
const std::string & model,
const std::string & keyword,
const std::string & token,
const std::string & tag = "") {
hf_cache::hf_file best;
size_t best_depth = 0;
@@ -589,32 +708,32 @@ static hf_cache::hf_file find_best_sibling(const hf_cache::hf_files & files,
model_bits = extract_quant_bits(model);
}
auto model_parts = string_split<std::string>(model, '/');
auto model_dir = model_parts.end() - 1;
for (const auto & f : files) {
if (!string_ends_with(f.path, ".gguf") ||
f.path.find(keyword) == std::string::npos) {
if (sidecar_token_of(f.path) != token) {
continue;
}
auto sib_parts = string_split<std::string>(f.path, '/');
auto sib_dir = sib_parts.end() - 1;
auto [_, dir] = std::mismatch(model_parts.begin(), model_dir,
sib_parts.begin(), sib_dir);
if (dir != sib_dir) {
continue;
size_t depth = 0;
if (!model.empty()) {
auto model_dir = model_parts.end() - 1;
auto [_, dir] = std::mismatch(model_parts.begin(), model_dir,
sib_parts.begin(), sib_dir);
if (dir != sib_dir) {
continue;
}
depth = dir - sib_parts.begin();
}
size_t depth = dir - sib_parts.begin();
auto bits = extract_quant_bits(f.path);
auto diff = std::abs(bits - model_bits);
std::string path_upper = f.path;
for (char & c : path_upper) {
c = (char) std::toupper((unsigned char) c);
}
bool exact = !tag_upper.empty() && path_upper.find("-" + tag_upper + ".") != std::string::npos;
// rank by the quant the sidecar belongs to, with the token segment
// stripped from its name
auto tag = sidecar_quant(f.path, token);
auto bits = quant_bits_from_tag(tag);
auto diff = std::abs(bits - model_bits);
bool exact = !tag_upper.empty() && tag == tag_upper;
if (!found || depth > best_depth ||
(depth == best_depth && exact && !best_exact) ||
@@ -637,43 +756,31 @@ static hf_cache::hf_file find_best_mmproj(const hf_cache::hf_files & files,
static hf_cache::hf_file find_best_mtp(const hf_cache::hf_files & files,
const std::string & model,
const std::string & tag = "") {
return find_best_sibling(files, model, "mtp-", tag);
return find_best_sibling(files, model, "mtp", tag);
}
static hf_cache::hf_file find_best_eagle3(const hf_cache::hf_files & files,
const std::string & model,
const std::string & tag = "") {
return find_best_sibling(files, model, "eagle3-", tag);
return find_best_sibling(files, model, "eagle3", tag);
}
static hf_cache::hf_file find_best_dflash(const hf_cache::hf_files & files,
const std::string & model,
const std::string & tag = "") {
return find_best_sibling(files, model, "dflash-", tag);
return find_best_sibling(files, model, "dflash", tag);
}
static hf_cache::hf_file find_best_dspark(const hf_cache::hf_files & files,
const std::string & model,
const std::string & tag = "") {
return find_best_sibling(files, model, "dspark-", tag);
return find_best_sibling(files, model, "dspark", tag);
}
// a plain model file: a GGUF whose name carries no sidecar token segment,
// so `smtp-Model.gguf` counts and every sidecar form does not
static bool gguf_filename_is_model(const std::string & filepath) {
if (!string_ends_with(filepath, ".gguf")) {
return false;
}
std::string filename = filepath;
if (auto pos = filename.rfind('/'); pos != std::string::npos) {
filename = filename.substr(pos + 1);
}
return filename.find("mmproj") == std::string::npos &&
filename.find("imatrix") == std::string::npos &&
filename.find("mtp-") == std::string::npos &&
filename.find("eagle3-") == std::string::npos &&
filename.find("dflash-") == std::string::npos &&
filename.find("dspark-") == std::string::npos;
return string_ends_with(filepath, ".gguf") && sidecar_token_of(filepath).empty();
}
static hf_cache::hf_file find_best_model(const hf_cache::hf_files & files,
@@ -765,8 +872,27 @@ common_download_hf_plan common_download_get_hf_plan(const common_params_model &
}
} else {
primary = find_best_model(all, tag);
// a `<quant>-<sidecar>` tag (e.g. `Q4_0-mtp`) requests that sidecar alone;
// every token-bearing file is a sidecar (find_best_model skips them),
// so the sidecar resolves here whenever the tag carries one
auto [base_tag, sidecar] = split_sidecar_tag(tag);
if (primary.path.empty() && !sidecar.empty()) {
auto found = find_best_sibling(all, "", sidecar, base_tag);
if (!found.path.empty()) {
if (sidecar == "mtp") plan.mtp = found;
else if (sidecar == "eagle3") plan.eagle3 = found;
else if (sidecar == "dflash") plan.dflash = found;
else if (sidecar == "dspark") plan.dspark = found;
else plan.mmproj = found;
}
}
// a requested sidecar can resolve on its own, without a full model of the same tag
if (primary.path.empty() && !opts.download_mtp && !opts.download_dflash && !opts.download_eagle3 && !opts.download_dspark) {
if (primary.path.empty() && sidecar.empty() &&
!opts.download_mtp && !opts.download_dflash && !opts.download_eagle3 && !opts.download_dspark) {
LOG_ERR("%s: no GGUF files found in repository %s\n", __func__, repo.c_str());
list_available_gguf_files(all);
return plan;
@@ -794,7 +920,7 @@ common_download_hf_plan common_download_get_hf_plan(const common_params_model &
plan.dspark = find_best_dspark(all, primary.path, tag);
}
if (primary.path.empty() &&
if (primary.path.empty() && plan.mmproj.local_path.empty() &&
plan.mtp.local_path.empty() && plan.dflash.local_path.empty() && plan.eagle3.local_path.empty() && plan.dspark.local_path.empty()) {
LOG_ERR("%s: no GGUF files found in repository %s\n", __func__, repo.c_str());
list_available_gguf_files(all);
@@ -968,17 +1094,34 @@ std::vector<common_cached_model_info> common_list_cached_models() {
auto files = hf_cache::get_cached_files();
for (const auto & f : files) {
auto split = get_gguf_split_info(f.path);
if (split.index != 1 || split.tag.empty() ||
split.prefix.find("mmproj") != std::string::npos ||
split.prefix.find("mtp-") != std::string::npos ||
split.prefix.find("eagle3-") != std::string::npos ||
split.prefix.find("dflash-") != std::string::npos ||
split.prefix.find("dspark-") != std::string::npos) {
continue;
// a sidecar file is listed under its own `<quant>-<sidecar>` tag, so a
// cached `mtp-Model-Q4_0.gguf`, `Model-mtp-Q4_0.gguf`, `Model-Q4_0-mtp.gguf`
// or short `mmproj-F16.gguf` shows up as `<repo>:Q4_0-mtp` / `<repo>:F16-mmproj`;
// files whose name carries no token stay loadable models
auto token = sidecar_token_of(f.path);
std::string tag;
if (token.empty()) {
auto split = get_gguf_split_info(f.path);
if (split.index != 1 || split.tag.empty()) {
continue;
}
tag = split.tag;
} else {
tag = sidecar_quant(f.path, token);
// a bare sidecar file has no tag to request it by, stay hidden
if (tag.empty()) {
continue;
}
tag += "-" + token;
}
if (seen.insert(f.repo_id + ":" + split.tag).second) {
result.push_back({f.repo_id, split.tag});
if (seen.insert(f.repo_id + ":" + tag).second) {
result.push_back({f.repo_id, tag});
}
}
@@ -1014,7 +1157,15 @@ bool common_download_remove(const std::string & hf_repo_with_tag) {
return hf_cache::remove_cached_repo(repo_id);
}
std::string tag_upper = tag;
// a `<quant>-<sidecar>` tag (`Q4_0-mtp`) targets that sidecar alone; a bare
// sidecar tag (`mtp`) is ambiguous across quants and is rejected
auto [base_tag, sidecar] = split_sidecar_tag(tag);
if (!sidecar.empty() && base_tag.empty()) {
LOG_ERR("%s: bare sidecar tag '%s': use `<quant>-<sidecar>`\n", __func__, tag.c_str());
return false;
}
std::string tag_upper = sidecar.empty() ? tag : base_tag;
for (char & c : tag_upper) {
c = (char) std::toupper((unsigned char) c);
}
@@ -1024,13 +1175,25 @@ bool common_download_remove(const std::string & hf_repo_with_tag) {
return false;
}
// collect snapshot entries whose tag matches
// collect the snapshot entries the tag selects; sidecar files keep their
// own tags, so a plain quant tag never removes them
std::vector<fs::path> to_remove;
for (const auto & f : files) {
auto split = get_gguf_split_info(f.path);
if (split.tag == tag_upper) {
to_remove.emplace_back(f.local_path);
auto token = sidecar_token_of(f.path);
if (sidecar.empty()) {
if (!token.empty()) {
continue;
}
if (get_gguf_split_info(f.path).tag != tag_upper) {
continue;
}
} else {
if (token != sidecar || sidecar_quant(f.path, sidecar) != tag_upper) {
continue;
}
}
to_remove.emplace_back(f.local_path);
}
if (to_remove.empty()) {
+137 -4
View File
@@ -15,6 +15,7 @@
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <map>
#include <thread>
#include <string>
@@ -139,7 +140,7 @@ static const std::vector<std::string> hole = {
"dflash-model-Q8_0.gguf",
};
// unsloth-style naming with UD quants and a suffix MTP file
// unsloth-style naming with UD quants and an uppercase infix MTP head
static const std::vector<std::string> unsloth = {
"model-UD-Q8_K_XL.gguf",
"mmproj-BF16.gguf",
@@ -180,6 +181,13 @@ static const std::vector<std::string> spark = {
"dspark-model-MXFP4.gguf",
};
// main weights plus a trailing-form mtp head, as in unsloth
// gemma-4-E2B-it-GGUF (Model-Q4_0-mtp.gguf)
static const std::vector<std::string> trailing = {
"model-BF16.gguf",
"model-BF16-mtp.gguf",
};
// dspark outranks dflash in the type auto-selection
static const std::vector<std::string> dspark_dflash = {
"model-Q8_0.gguf",
@@ -223,7 +231,7 @@ static const plan_case plan_cases[] = {
// no tag and no default match falls back to the first model in the listing
{"unsloth fallback", unsloth, "test/repo", "", true, true,
"model-UD-Q8_K_XL.gguf", {"model-UD-Q8_K_XL.gguf"},
"mmproj-BF16.gguf", "", "", "", ""},
"mmproj-BF16.gguf", "model-MTP-BF16.gguf", "", "", ""},
// explicit hf_file picks that exact file
{"flat hf_file", flat, "test/repo", "model-BF16.gguf", false, false,
@@ -258,10 +266,11 @@ static const plan_case plan_cases[] = {
"model-Q4_K_M.gguf", {"model-Q4_K_M.gguf"},
"", "mtp-model-Q4_0.gguf", "dflash-model-Q8_0.gguf", "", ""},
// the mtp- keyword is case sensitive, a suffix -MTP file is not discovered
// the uppercase infix MTP head is a sidecar too; it resolves at the
// nearest quant to the tag when the exact one does not exist
{"unsloth suffix mtp", unsloth, "test/repo:Q8_K_XL", "", true, false,
"model-UD-Q8_K_XL.gguf", {"model-UD-Q8_K_XL.gguf"},
"mmproj-BF16.gguf", "", "", "", ""},
"mmproj-BF16.gguf", "model-MTP-BF16.gguf", "", "", ""},
// vendor prefixes and the dot quant convention both match the tag,
// first match wins between two files at the same quant
@@ -283,6 +292,45 @@ static const plan_case plan_cases[] = {
{"spark tag sidecar", spark, "test/repo:BF16", "", true, false,
"", {},
"", "", "", "", "dspark-model-BF16.gguf"},
// a `<quant>-<sidecar>` tag resolves that sidecar alone, without a primary
{"hole quant-sidecar tag", hole, "test/repo:Q4_0-mtp", "", false, false,
"", {},
"", "mtp-model-Q4_0.gguf", "", "", ""},
{"spark quant-sidecar tag", spark, "test/repo:BF16-dspark", "", false, false,
"", {},
"", "", "", "", "dspark-model-BF16.gguf"},
// a bare sidecar tag resolves the sidecar at any quant
{"hole bare sidecar tag", hole, "test/repo:mtp", "", false, false,
"", {},
"", "mtp-model-Q4_0.gguf", "", "", ""},
// a trailing-form sidecar resolves as the sidecar alone, never as a primary
{"trailing quant-sidecar tag", trailing, "test/repo:BF16-mtp", "", false, false,
"", {},
"", "model-BF16-mtp.gguf", "", "", ""},
// the plain tag resolves the plain model; the trailing-token file is skipped
{"trailing plain tag", trailing, "test/repo:BF16", "", false, false,
"model-BF16.gguf", {"model-BF16.gguf"},
"", "", "", "", ""},
// a short-form sidecar (`mmproj-F16.gguf`) resolves by its bare quant tag
{"unsloth quant-sidecar tag", unsloth, "test/repo:BF16-mmproj", "", false, false,
"", {},
"mmproj-BF16.gguf", "", "", "", ""},
// an uppercase infix sidecar resolves through the lowercase tag
{"unsloth uppercase infix tag", unsloth, "test/repo:BF16-mtp", "", false, false,
"", {},
"", "model-MTP-BF16.gguf", "", "", ""},
// a sidecar token in the middle of the name resolves as the sidecar alone
{"subdir quant-sidecar tag", subdir, "test/repo:Q8_0-mtp", "", false, false,
"", {},
"", "model-mtp-Q8_0.gguf", "", "", ""},
};
static void check_plan(const plan_case & c) {
@@ -468,6 +516,90 @@ static void test_task_assembly() {
g_repos.clear();
}
//
// cache listing and removal against the isolated LLAMA_CACHE, using the
// same filename grammar the plan tests exercise above
//
static void cache_put(const std::string & repo, const std::string & path) {
namespace fs = std::filesystem;
auto local = fs::path(cached(repo, path));
fs::create_directories(local.parent_path());
{ std::ofstream(local) << "gguf"; }
auto repo_dir = local.parent_path().parent_path().parent_path();
auto refs = repo_dir / "refs";
fs::create_directories(refs);
auto ref = refs / "main";
if (!fs::exists(ref)) {
std::ofstream(ref) << COMMIT << "\n";
}
}
static bool cache_lists(const std::string & repo_tag) {
for (const auto & e : common_list_cached_models()) {
if (e.to_string() == repo_tag) {
return true;
}
}
return false;
}
static void test_cache_listing_and_remove() {
namespace fs = std::filesystem;
const std::string repo = "test/eh";
printf("test-model-resolution: cache listing and removal\n");
g_context = "cache fixture";
cache_put(repo, "gemma-4-E2B-it-BF16.gguf"); // main weights
cache_put(repo, "gemma-4-E2B-it-BF16-mtp.gguf"); // trailing-form draft head
cache_put(repo, "gemma-4-31B-it-MTP-BF16.gguf"); // uppercase infix draft head
cache_put(repo, "mmproj-gemma-4-E2B-it-BF16.gguf"); // mmproj sidecar
cache_put(repo, "mmproj-F16.gguf"); // short-form mmproj
cache_put(repo, "model-mtp-Q8_0.gguf"); // mid-name mtp sidecar
// a sidecar is listed under `<quant>-<sidecar>` in every form and case it
// can be named; only the token-less main weights stay a loadable model
REQUIRE(cache_lists("test/eh:BF16"));
REQUIRE(cache_lists("test/eh:BF16-mtp"));
REQUIRE(cache_lists("test/eh:BF16-mmproj"));
REQUIRE(cache_lists("test/eh:F16-mmproj"));
REQUIRE(cache_lists("test/eh:Q8_0-mtp"));
REQUIRE(!cache_lists("test/eh:MTP"));
// a plain quant tag removes the model files and leaves every sidecar,
// whatever form or case its name carries
g_context = "remove plain quant";
REQUIRE(common_download_remove("test/eh:BF16"));
REQUIRE(!fs::exists(cached(repo, "gemma-4-E2B-it-BF16.gguf")));
REQUIRE(fs::exists(cached(repo, "gemma-4-E2B-it-BF16-mtp.gguf")));
REQUIRE(fs::exists(cached(repo, "gemma-4-31B-it-MTP-BF16.gguf")));
REQUIRE(fs::exists(cached(repo, "mmproj-gemma-4-E2B-it-BF16.gguf")));
REQUIRE(fs::exists(cached(repo, "mmproj-F16.gguf")));
REQUIRE(fs::exists(cached(repo, "model-mtp-Q8_0.gguf")));
// a `<quant>-<sidecar>` tag removes every sidecar listed under it,
// across forms and cases
g_context = "remove quant-sidecar";
REQUIRE(common_download_remove("test/eh:BF16-mtp"));
REQUIRE(!fs::exists(cached(repo, "gemma-4-E2B-it-BF16-mtp.gguf")));
REQUIRE(!fs::exists(cached(repo, "gemma-4-31B-it-MTP-BF16.gguf")));
REQUIRE(fs::exists(cached(repo, "mmproj-gemma-4-E2B-it-BF16.gguf")));
// the short form removes by its bare quant tag
REQUIRE(common_download_remove("test/eh:F16-mmproj"));
REQUIRE(!fs::exists(cached(repo, "mmproj-F16.gguf")));
REQUIRE(common_download_remove("test/eh:Q8_0-mtp"));
REQUIRE(!fs::exists(cached(repo, "model-mtp-Q8_0.gguf")));
// a bare sidecar tag is ambiguous across quants and is rejected
g_context = "remove bare sidecar";
cache_put(repo, "mtp-Model-Q4_0.gguf");
REQUIRE(!common_download_remove("test/eh:mtp"));
REQUIRE(fs::exists(cached(repo, "mtp-Model-Q4_0.gguf")));
}
int main(void) {
// unbuffered, so a crash cannot swallow the reports already printed
setvbuf(stdout, nullptr, _IONBF, 0);
@@ -496,6 +628,7 @@ int main(void) {
test_plan_resolution();
test_task_assembly();
test_cache_listing_and_remove();
server.stop();
server_thread.join();
+13 -5
View File
@@ -1320,14 +1320,22 @@ bool server_models::remove(const std::string & name) {
return true;
}
// join before erasing - thread no longer acquires this mutex
if (it->second.th.joinable()) {
it->second.th.join();
// on the cancelled-download path the status flips to DOWNLOADED while the
// monitoring thread still has a mutex-guarded step left, so joining under
// the lock would deadlock - join outside, as load_models() does
std::thread th = std::move(it->second.th);
mapping.erase(name);
lk.unlock();
// join first so the monitoring thread's final mutex-guarded cleanup cannot
// race the disk removal, then remove from disk without holding the lock
// (best-effort: cancelled downloads may have no cached files)
if (th.joinable()) {
th.join();
}
// remove from disk (best-effort: cancelled downloads may have no cached files)
bool ok = common_download_remove(name);
mapping.erase(name);
if (!ok) {
SRV_WRN("removing model name=%s from disk returned false (no cached files?)\n", name.c_str());
}
+8 -5
View File
@@ -71,7 +71,7 @@
"svelte-check": "4.6.0",
"svelte-sonner": "1.1.1",
"tailwind-merge": "3.6.0",
"tailwind-variants": "3.2.2",
"tailwind-variants": "3.3.1",
"tailwindcss": "4.3.0",
"tw-animate-css": "1.4.0",
"typescript": "5.9.3",
@@ -16048,13 +16048,13 @@
}
},
"node_modules/tailwind-variants": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz",
"integrity": "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==",
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.3.1.tgz",
"integrity": "sha512-4pAvwUtM4HKBiRZftncAbpn6V9Hhwoa5Fl7O2u5zbp7Z5Cvu+/o/6+176WY3WCEES209543quG8zFIcXCsc5Jw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16.x",
"node": ">=16.9.x",
"pnpm": ">=7.x"
},
"peerDependencies": {
@@ -16064,6 +16064,9 @@
"peerDependenciesMeta": {
"tailwind-merge": {
"optional": true
},
"tailwindcss": {
"optional": true
}
}
},
+2 -1
View File
@@ -13,6 +13,7 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"reset": "rm -rf .svelte-kit node_modules",
"format": "eslint --fix . && prettier --write .",
"format:files": "sh -c 'eslint --fix \"$@\" && prettier --write \"$@\"' sh",
"lint": "prettier --check . && eslint .",
"test": "npm run test:ui -- --run && npm run test:client -- --run && npm run test:unit -- --run && npm run test:e2e",
"test:e2e": "playwright test",
@@ -90,7 +91,7 @@
"svelte-check": "4.6.0",
"svelte-sonner": "1.1.1",
"tailwind-merge": "3.6.0",
"tailwind-variants": "3.2.2",
"tailwind-variants": "3.3.1",
"tailwindcss": "4.3.0",
"tw-animate-css": "1.4.0",
"typescript": "5.9.3",
+12 -18
View File
@@ -18,20 +18,16 @@ import type {
ApiErrorResponse,
ApiLlamaCppServerProps,
ApiModelDataEntry,
ApiModelListResponse,
ApiModelLoadStage,
ApiModelsDownloadRequest,
ApiModelsDownloadResponse,
ApiModelsListResponse,
ApiModelsLoadResponse,
ApiModelsSseData,
ApiModelsSseEvent,
ApiModelsSseProgress,
ApiModelsUnloadResponse,
ApiProcessingState,
ApiRouterModelMeta,
ApiRouterModelsListResponse,
ApiRouterModelsLoadRequest,
ApiRouterModelsLoadResponse,
ApiRouterModelsStatusRequest,
ApiRouterModelsStatusResponse,
ApiRouterModelsUnloadRequest,
ApiRouterModelsUnloadResponse,
ChatAttachmentDisplayItem,
// Chat types
ChatMessagePromptProgress,
@@ -86,19 +82,17 @@ declare global {
ApiLlamaCppServerProps,
ApiModelDataEntry,
ApiModelLoadStage,
ModelDownloadProgress,
ApiModelsSseProgress,
ApiModelsSseData,
ApiModelsSseDownloadProgressData,
ApiModelsSseEvent,
ApiModelListResponse,
ApiModelsListResponse,
ApiModelsLoadResponse,
ApiModelsDownloadRequest,
ApiModelsDownloadResponse,
ApiModelsUnloadResponse,
ApiProcessingState,
ApiRouterModelMeta,
ApiRouterModelsLoadRequest,
ApiRouterModelsLoadResponse,
ApiRouterModelsStatusRequest,
ApiRouterModelsStatusResponse,
ApiRouterModelsListResponse,
ApiRouterModelsUnloadRequest,
ApiRouterModelsUnloadResponse,
// Chat types
ChatAttachmentDisplayItem,
ChatMessagePromptProgress,
@@ -220,8 +220,7 @@ export { default as ChatFormActionModels } from './ChatForm/ChatFormActions/Chat
*/
export { default as ChatFormActionAddToolsSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte';
/**
* Dropdown submenu for selecting reasoning effort level.
/** Dropdown submenu for selecting reasoning effort level.
*
* Shows a "Reasoning" sub-menu item with a lightbulb icon indicating
* thinking status, and a nested list of effort levels.
@@ -7,6 +7,7 @@
getMdastNodeHash,
isAppendMode
} from './markdown-utils';
import { SAFE_HTML_CONFIG } from './safe-html-config';
import {
ActionIconCopyToClipboard,
CodeBlockActions,
@@ -44,6 +45,7 @@
import { detectIncompleteCodeBlock, highlightCode, type IncompleteCodeBlock } from '$lib/utils';
import { sanitizeSvg } from '$lib/utils/sanitize-svg';
import { mountSvgShadow } from '$lib/utils/svg-shadow';
import DOMPurify from 'dompurify';
import type { Root as HastRoot, RootContent as HastRootContent } from 'hast';
import githubLightCss from 'highlight.js/styles/github.css?inline';
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
@@ -57,6 +59,8 @@
content: string;
class?: string;
disableMath?: boolean;
/** Render raw HTML found in the markdown (sanitized) instead of escaping it. */
allowHtml?: boolean;
}
interface MarkdownBlock {
@@ -65,7 +69,13 @@
contentHash?: string;
}
let { attachments, class: className = '', content, disableMath = false }: Props = $props();
let {
allowHtml = false,
attachments,
class: className = '',
content,
disableMath = false
}: Props = $props();
let containerRef = $state<HTMLDivElement>();
let renderedBlocks = $state<MarkdownBlock[]>([]);
@@ -119,6 +129,11 @@
let pendingMarkdown: string | null = null;
let isProcessing = false;
// Raw HTML in model cards renders after sanitization with an explicit allow
// list: no scripts, event handlers, forms, iframes or style blocks pass.
// Covers the tags model cards use plus the KaTeX output (spans with inline
// styles and MathML).
// Per-instance transform cache, avoids re-transforming stable blocks during streaming
// Garbage collected when component is destroyed (on conversation change)
const transformCache = new SvelteMap<string, string>();
@@ -183,7 +198,10 @@
index: number
): Promise<{ html: string; hash: string }> {
const hash = getMdastNodeHash(node, index);
const cached = transformCache.get(hash);
// the rendered HTML also depends on allowHtml (sanitized raw vs escaped),
// so the cache is keyed per mode
const cacheKey = `${allowHtml ? 'a' : 'p'}:${hash}`;
const cached = transformCache.get(cacheKey);
if (cached) {
return { hash, html: cached };
@@ -192,10 +210,13 @@
const singleNodeRoot = { children: [node], type: 'root' };
const transformedRoot = (await processorInstance.run(singleNodeRoot as MdastRoot)) as HastRoot;
const html = processorInstance.stringify(transformedRoot);
const safeHtml = allowHtml
? (DOMPurify.sanitize(html, SAFE_HTML_CONFIG) as unknown as string)
: html;
transformCache.set(hash, html);
transformCache.set(cacheKey, safeHtml);
return { hash, html };
return { hash, html: safeHtml };
}
/**
@@ -300,7 +321,7 @@
if (prefixMarkdown.trim()) {
const normalizedPrefix = preprocessLaTeX(prefixMarkdown);
const processorInstance = getMarkdownProcessor({ attachments, disableMath });
const processorInstance = getMarkdownProcessor({ allowHtml, attachments, disableMath });
const ast = processorInstance.parse(normalizedPrefix) as MdastRoot;
const mdastChildren = (ast as { children?: unknown[] }).children ?? [];
const nextBlocks: MarkdownBlock[] = [];
@@ -350,7 +371,7 @@
incompleteCodeBlock = null;
const normalized = preprocessLaTeX(markdown);
const processorInstance = getMarkdownProcessor({ attachments, disableMath });
const processorInstance = getMarkdownProcessor({ allowHtml, attachments, disableMath });
const ast = processorInstance.parse(normalized) as MdastRoot;
const mdastChildren = (ast as { children?: unknown[] }).children ?? [];
const stableCount = Math.max(mdastChildren.length - 1, 0);
@@ -394,6 +415,10 @@
)) as HastRoot;
unstableHtml = processorInstance.stringify(transformedRoot);
if (allowHtml) {
unstableHtml = DOMPurify.sanitize(unstableHtml, SAFE_HTML_CONFIG) as unknown as string;
}
}
renderedBlocks = nextBlocks;
@@ -41,12 +41,15 @@ export interface MarkdownProcessor {
export interface MarkdownProcessorOptions {
attachments?: DatabaseMessageExtra[];
disableMath?: boolean;
/** Render raw HTML found in the markdown instead of escaping it. */
allowHtml?: boolean;
}
const sharedPipelines = new Map<string, MarkdownProcessor>();
const attachmentPipelines = new WeakMap<object, MarkdownProcessor>();
const attachmentPipelines = new WeakMap<object, Map<string, MarkdownProcessor>>();
function buildPipeline({
allowHtml = false,
attachments,
disableMath = false
}: MarkdownProcessorOptions): MarkdownProcessor {
@@ -57,11 +60,15 @@ function buildPipeline({
proc = proc.use(remarkMath); // Parse $inline$ and $$block$$ math
}
proc = proc
.use(remarkBreaks) // Convert line breaks to <br>
proc = proc.use(remarkBreaks); // Convert line breaks to <br>
if (!allowHtml) {
// Treat raw HTML as literal text with preserved indentation
.use(remarkLiteralHtml)
.use(remarkRehype); // Convert Markdown AST to rehype
proc = proc.use(remarkLiteralHtml);
}
// Convert Markdown AST to rehype. Keep raw HTML as-is when allowHtml is set.
proc = proc.use(remarkRehype, allowHtml ? { allowDangerousHtml: true } : undefined);
if (!disableMath) {
proc = proc.use(rehypeKatex); // Render math using KaTeX
@@ -89,17 +96,26 @@ function buildPipeline({
export function getMarkdownProcessor(options: MarkdownProcessorOptions): MarkdownProcessor {
if (options.attachments && options.attachments.length > 0) {
let cached = attachmentPipelines.get(options.attachments);
let byOptions = attachmentPipelines.get(options.attachments);
if (!byOptions) {
byOptions = new Map<string, MarkdownProcessor>();
attachmentPipelines.set(options.attachments, byOptions);
}
const key = `${Boolean(options.disableMath)}:${Boolean(options.allowHtml)}`;
let cached = byOptions.get(key);
if (!cached) {
cached = buildPipeline(options);
attachmentPipelines.set(options.attachments, cached);
byOptions.set(key, cached);
}
return cached;
}
const key = String(Boolean(options.disableMath));
const key = `${Boolean(options.disableMath)}:${Boolean(options.allowHtml)}`;
let cached = sharedPipelines.get(key);
@@ -0,0 +1,121 @@
// Sanitization rules for raw HTML in model cards (MarkdownContent's
// allowHtml mode). Kept as its own module so the adversarial test in
// tests/client can import the exact shipped configuration.
/**
* Explicit DOMPurify allow list: the tags and attributes model cards may use
* plus the KaTeX output (spans with inline styles and MathML). Everything
* else - scripts, event handlers, forms, iframes, style tags - is stripped.
*/
export const SAFE_HTML_CONFIG = {
ALLOWED_ATTR: [
'class',
'style',
'id',
'title',
'role',
'aria-hidden',
'aria-label',
'aria-describedby',
'aria-expanded',
'href',
'target',
'rel',
'src',
'srcset',
'alt',
'width',
'height',
'align',
'valign',
'colspan',
'rowspan',
'controls',
'type',
// MathML
'xmlns',
'display',
'encoding',
'mathvariant'
],
ALLOWED_TAGS: [
// text and structure
'a',
'b',
'i',
'u',
's',
'em',
'strong',
'code',
'pre',
'kbd',
'sub',
'sup',
'br',
'hr',
'p',
'span',
'div',
'blockquote',
'center',
'ul',
'ol',
'li',
'dl',
'dt',
'dd',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'table',
'thead',
'tbody',
'tr',
'th',
'td',
'colgroup',
'col',
'caption',
// media
'img',
'picture',
'source',
'figure',
'figcaption',
'video',
'audio',
// MathML, for the KaTeX output
'math',
'mrow',
'mi',
'mo',
'mn',
'ms',
'mtext',
'mfrac',
'mroot',
'msqrt',
'msub',
'msup',
'msubsup',
'munder',
'mover',
'mmultiscripts',
'mtable',
'mtr',
'mtd',
'mspace',
'mglyph',
'maligngroup',
'malignmark',
'mpadded',
'mphantom',
'mstyle',
'semantics',
'annotation'
]
};
@@ -12,7 +12,7 @@
} from '@lucide/svelte';
import { ActionIcon, ModelId } from '$lib/components/app';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { ServerModelStatus } from '$lib/enums';
import { ModelCapability, ServerModelStatus } from '$lib/enums';
import { modelsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import { modelLoadFraction, modelLoadProgressText } from '$lib/utils';
@@ -60,7 +60,8 @@
let loadTitle = $derived(modelLoadProgressText(loadProgress));
let modalities = $derived(option.modalities);
let capabilities = $derived.by(() => ({
reasoning: modelsStore.props.checkModelSupportsThinking(option.model)
reasoning: modelsStore.props.checkModelSupportsThinking(option.model),
tools: option.capabilities.includes(ModelCapability.TOOL_USE)
}));
</script>
@@ -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;
@@ -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;
@@ -10,7 +10,8 @@ import {
Image as ImageIcon,
Lightbulb as ReasoningIcon,
Mic as AudioIcon,
Video as VideoIcon
Video as VideoIcon,
Wrench as ToolUseIcon
} from '@lucide/svelte';
import { FileTypeCategory, ModelCapability, ModelModality } from '$lib/enums';
import type { ModelCapabilities, ModelModalities } from '$lib/types/models';
@@ -49,16 +50,19 @@ export const MODALITY_FLAG_KEYS: Record<
};
export const CAPABILITY_ICONS: Record<ModelCapability, Component> = {
[ModelCapability.REASONING]: ReasoningIcon
[ModelCapability.REASONING]: ReasoningIcon,
[ModelCapability.TOOL_USE]: ToolUseIcon
} as const;
export const CAPABILITY_LABELS: Record<ModelCapability, string> = {
[ModelCapability.REASONING]: 'Reasoning'
[ModelCapability.REASONING]: 'Reasoning',
[ModelCapability.TOOL_USE]: 'Tool use'
} as const;
/** Maps a ModelCapability to the boolean flag it drives on the ModelCapabilities type */
export const CAPABILITY_FLAG_KEYS: Record<ModelCapability, keyof ModelCapabilities> = {
[ModelCapability.REASONING]: 'reasoning'
[ModelCapability.REASONING]: 'reasoning',
[ModelCapability.TOOL_USE]: 'tools'
};
// Shared SVG icon strings for copy and preview buttons
+3
View File
@@ -45,6 +45,9 @@ 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 './model-compatibility.constants';
export * from './huggingface.constants';
export * from './precision.constants';
export * from './pwa.constants';
export * from './routes.constants';
@@ -0,0 +1,32 @@
/**
* Model memory-fit constants.
*
* Mirrors the app's compatibility check (Model+Compatibility.swift):
* budget = RAM x RAM_BUDGET_RATIO - RAM_OVERHEAD_MB
* weightBytes = fileBytes x QUANT_WEIGHT
* a file fits when weightBytes <= budget. Kept here so the estimation util and
* any caller share one source.
*/
/** Bytes in one mebibyte (MiB), used to convert a file size to MB. */
export const MIB_BYTES = 1_048_576;
/** MB in one GB. */
export const MB_PER_GB = 1024;
/** Overhead multiplier applied to the file size when estimating weight memory. */
export const QUANT_WEIGHT = 1.05;
/** Share of RAM the app allows the model to occupy. */
export const RAM_BUDGET_RATIO = 0.75;
/** Fixed RAM overhead (MB) reserved for the system and KV cache. */
export const RAM_OVERHEAD_MB = 2048;
/**
* Memory tiers (GB) covering the RAM sizes common machines ship with, in
* small enough steps that the requirement reads honestly. Device-agnostic on
* purpose: the server exposes no host RAM, so the UI presents the tier and
* lets the user judge.
*/
export const MEM_TIERS = [4, 6, 8, 12, 16, 24, 32, 48, 64, 96, 128, 192, 256, 384, 512, 768, 1024];
@@ -2,35 +2,48 @@
* Parsing of `org/ModelName[-tag][:quant]` style model IDs.
*/
import { ModelAuxSidecar, ModelDraftSidecar } from '$lib/enums';
/** Any sidecar file type: a draft variant or an auxiliary sidecar like mmproj. */
export type ModelSidecar = ModelDraftSidecar | ModelAuxSidecar;
/** All sidecar filename tokens: the bare lowercase enum values, e.g. `mtp`, `mmproj`. */
export const SIDECAR_TOKENS: string[] = [
...Object.values(ModelDraftSidecar),
...Object.values(ModelAuxSidecar)
];
/** Separator between token alternatives in the sidecar regexes. */
const REGEX_ALTERNATION_SEPARATOR = '|';
const SIDECAR_TOKEN_ALTERNATION = SIDECAR_TOKENS.join(REGEX_ALTERNATION_SEPARATOR);
export const MODEL_ID = {
/**
* Matches an activated-parameter-count segment, e.g. `A10B`, `a2.4b`.
* The leading `A`/`a` distinguishes it from a regular params segment.
*/
ACTIVATED_PARAMS_RE: /^[Aa]\d+(\.\d+)?[BbMmKkTt]$/,
ACTIVATED_PARAMS_REGEX: /^[Aa]\d+(\.\d+)?[BbMmKkTt]$/,
/** Matches prefix for custom quantization types, e.g. `UD-Q8_K_XL`. */
CUSTOM_QUANTIZATION_PREFIX_RE: /^UD$/i,
CUSTOM_QUANTIZATION_PREFIX_REGEX: /^UD$/i,
/** Container format segments to exclude from tags (every model uses these). */
IGNORED_SEGMENTS: new Set(['GGUF', 'GGML']),
/** Sentinel value returned by `indexOf` when a substring is not found. */
NOT_FOUND: -1,
/** Separates `<org>` from `<model>` in a model ID, e.g. `org/ModelName`. */
ORG_SEPARATOR: '/',
/**
* Matches a parameter-count segment, e.g. `7B`, `1.5b`, `120M`.
* The optional leading `E` covers effective-parameter sizes, e.g. Gemma's
* `E2B`/`E4B` (MatFormer models sized by resident params).
*/
PARAMS_RE: /^[Ee]?\d+(\.\d+)?[BbMmKkTt]$/,
PARAMS_REGEX: /^[Ee]?\d+(\.\d+)?[BbMmKkTt]$/,
/**
* Matches a quantization/precision segment, e.g. `Q4_K_M`, `IQ4_XS`, `F16`, `BF16`, `MXFP4`.
* Case-insensitive to handle both uppercase and lowercase inputs.
*/
QUANTIZATION_SEGMENT_RE: /^(I?Q\d+(_[A-Z0-9]+)*|F\d+|BF\d+|MXFP\d+(_[A-Z0-9]+)*)$/i,
QUANTIZATION_SEGMENT_REGEX: /^(I?Q\d+(_[A-Z0-9]+)*|F\d+|BF\d+|MXFP\d+(_[A-Z0-9]+)*)$/i,
/** Separates the model path from the quantization tag, e.g. `model:Q4_K_M`. */
QUANTIZATION_SEPARATOR: ':',
@@ -38,6 +51,36 @@ export const MODEL_ID = {
/** Separates named segments within the model path, e.g. `ModelName-7B-GGUF`. */
SEGMENT_SEPARATOR: '-',
/**
* Sidecar token between name segments, e.g. `Model-mtp-Q4_0.gguf`,
* `model-eagle3-BF16.gguf`. Captures the name head and tail around the
* token; same case-insensitive rule as the prefix form.
*/
SIDECAR_INFIX_REGEX: new RegExp(`^(.*)-(${SIDECAR_TOKEN_ALTERNATION})-(.+)$`, 'i'),
/**
* Sidecar prefix that wraps a model id with a sidecar type, e.g.
* `mtp-<name>.gguf`, `dflash-<name>.gguf`, `dspark-<name>.gguf`,
* `eagle3-<name>.gguf`, `mmproj-<name>.gguf`. Captures the bare type
* token for typed lookup.
*
* The token matches case-insensitively (real repos ship uppercase
* heads, e.g. `Model-MTP-BF16.gguf`) and is normalized through
* `sidecarFromFileToken`; the server's filename grammar
* (common/download.cpp) matches the same segments.
*/
SIDECAR_PREFIX_REGEX: new RegExp(`^(${SIDECAR_TOKEN_ALTERNATION})-(.*)$`, 'i'),
/**
* Trailing `-<type>` suffix marking a GGUF with an embedded draft in the
* same weight file (MTP) or a sidecar download entry, e.g.
* `Hy3-IQ1_M-mtp.gguf`, `Q4_K_M-dspark`. An optional `-draft` tail covers
* standalone sidecar files, e.g. `Model-mtp-draft.gguf`. The captured
* prefix is the candidate model id; the caller decides whether it looks
* quantized. Case-insensitive, like the prefix form.
*/
SIDECAR_SUFFIX_REGEX: new RegExp(`^(.*)-(${SIDECAR_TOKEN_ALTERNATION})(-draft)?$`, 'i'),
/** Matches a trailing weight file extension, e.g. `model.gguf` -> `model`. */
WEIGHT_EXTENSION_RE: /\.(gguf|ggml)$/i
WEIGHT_EXTENSION_REGEX: /\.(gguf|ggml)$/i
};
@@ -0,0 +1,14 @@
/**
* 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';
/**
* Catalog repos fetched in parallel per batch; small on purpose so the HF API
* is not hit with the whole catalog at once.
*/
export const MODELS_DISCOVER_CATALOG_BATCH = 4;
@@ -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`;
@@ -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'
}
+11 -1
View File
@@ -57,6 +57,8 @@ export {
SpecialFileType
} from './files.enums';
export { HfEntryType, HfModelSort, SidecarForm } from './huggingface.enums';
export {
MCPConnectionPhase,
MCPLogLevel,
@@ -67,7 +69,15 @@ export {
JsonSchemaType
} from './mcp.enums';
export { ModelCapability, ModelModality } from './model.enums';
export {
ModelAuxSidecar,
ModelCapability,
ModelDraftSidecar,
ModelModality,
ModelSelectableFileKind
} from './model.enums';
export { ModelDownloadStopRequest } from './model.enums';
export { ServerRole, ServerModelStatus, ServerModelsSseEventType } from './server.enums';
+48 -1
View File
@@ -6,5 +6,52 @@ export enum ModelModality {
}
export enum ModelCapability {
REASONING = 'REASONING'
REASONING = 'reasoning',
TOOL_USE = 'tools'
}
/**
* Speculative-decoding draft sidecars (server spec-type draft-*).
* Filenames use the lowercase token, e.g. `mtp-<name>.gguf` or `-mtp` suffix.
*/
export enum ModelDraftSidecar {
/** DFlash block-diffusion draft (spec-type draft-dflash). */
DFLASH = 'dflash',
/** DSpark block-diffusion draft (spec-type draft-dspark). */
DSPARK = 'dspark',
/** EAGLE-3 speculative draft (spec-type draft-eagle3). */
EAGLE3 = 'eagle3',
/** Multi-token-prediction draft head (spec-type draft-mtp). */
MTP = 'mtp'
}
/**
* Non-draft sidecar file types. A sidecar is any auxiliary GGUF file
* accompanying the main model weights.
*/
export enum ModelAuxSidecar {
/** Importance-matrix data used to build imatrix quants; not loaded at serve time. */
IMATRIX = 'imatrix',
/** Multimodal projector: unlocks vision and/or audio input modalities. */
MMPROJ = 'mmproj'
}
/**
* Role of a selectable GGUF in the download options: the main weights, a
* speculative-decoding draft sidecar, or an auxiliary sidecar (mmproj).
*/
export enum ModelSelectableFileKind {
AUX = 'aux',
DRAFT = 'draft',
MAIN = 'main'
}
/**
* 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'
}
+6
View File
@@ -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',
@@ -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;
}
}
+11 -1
View File
@@ -136,7 +136,7 @@ export { ConversationTransferService } from './conversation-transfer.service';
*
* **Server Mode Behavior:**
* - **MODEL mode**: Only `list()` is relevant — single model always loaded
* - **ROUTER mode**: Full lifecycle — `list()`, `listRouter()`, `load()`, `unload()`
* - **ROUTER mode**: Full lifecycle — `list()`, `load()`, `unload()`
*
* **Endpoints:**
* - `GET /v1/models` — OpenAI-compatible model list (both modes)
@@ -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
*
+139 -32
View File
@@ -7,14 +7,16 @@
*/
import { base } from '$app/paths';
import { API_MODELS, MODEL_ID } from '$lib/constants';
import { API_MODELS, MODEL_ID, type ModelSidecar, SIDECAR_TOKENS } from '$lib/constants';
import { ServerModelStatus } from '$lib/enums';
import type { ParsedModelId } from '$lib/types/models';
import {
apiDelete,
apiFetch,
apiPost,
extractSseDataPayload,
normalizeModelName,
sidecarFromFileToken,
splitSseRecords
} from '$lib/utils';
import { getAuthHeaders } from '$lib/utils/api-headers';
@@ -22,6 +24,70 @@ import { getAuthHeaders } from '$lib/utils/api-headers';
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`
*/
static buildDownloadTag(
repoId: string,
quant: string | null,
sidecar: ModelSidecar | null
): string {
if (!quant && !sidecar) return repoId;
if (!quant) return `${repoId}:${sidecar}`;
const tag = sidecar ? `${quant}-${sidecar}` : quant;
return `${repoId}:${tag}`;
}
/**
* Cancel an in-flight download or remove a previously downloaded/failed
* entry from the server's model cache (ROUTER mode only).
*
* Sends DELETE `/models?model=<hfRepoWithTag>`:
* - while a download is running, the child subprocess is asked to exit
* and any partial `.tmp` files are removed;
* - once the entry has finished downloading or has failed, the cached
* files are removed from disk.
*
* @param hfRepoWithTag - HuggingFace repo id in the same `<repo>:<tag>`
* format returned by `buildDownloadTag`.
* @returns Server acknowledgement containing the success flag
*/
static async cancelDownload(hfRepoWithTag: string): Promise<ApiModelsDownloadResponse> {
return apiDelete<ApiModelsDownloadResponse>(API_MODELS.DELETE, {
model: hfRepoWithTag
});
}
/**
* Trigger a model download from HuggingFace (ROUTER mode only).
*
* Sends a POST request to `/models`. The response returns immediately; the
* actual download runs in the background and tracks progress through
* `/models/sse`. The server picks the file that matches the supplied tag
* (when present) and additionally pulls mmproj / draft sidecar weights as
* appropriate for the model.
*
* @param hfRepoWithTag - HuggingFace repo id, optionally suffixed with
* `:<tag>` (e.g. `ggml-org/gemma-3-4b-it-GGUF:Q4_K_M`
* or `:IQ1_M-mtp` for an embedded-draft GGUF).
* @returns Server acknowledgement containing the success flag
*/
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.
*
@@ -32,14 +98,6 @@ export class ModelsService {
return model.status.value === ServerModelStatus.LOADED;
}
/**
*
*
* Load/Unload
*
*
*/
/**
* Check if a model is currently loading.
*
@@ -50,25 +108,39 @@ export class ModelsService {
return model.status.value === ServerModelStatus.LOADING;
}
/**
*
*
* Load/Unload
*
*
*/
/**
* True when a router entry id is a sidecar-only entry, e.g. `org/model:Q4_0-mtp`
* or `org/model:mmproj`. Such entries mark a downloaded sidecar file, not a
* loadable model, so the selector skips them.
*/
static isSidecarEntry(modelId: string): boolean {
const idx = modelId.indexOf(MODEL_ID.QUANTIZATION_SEPARATOR);
if (idx === MODEL_ID.NOT_FOUND) return false;
const tag = modelId.slice(idx + 1).toLowerCase();
const dash = tag.lastIndexOf(MODEL_ID.SEGMENT_SEPARATOR);
const token = dash === -1 ? tag : tag.slice(dash + 1);
return SIDECAR_TOKENS.includes(token);
}
/**
* Fetch list of models from OpenAI-compatible endpoint.
* Works in both MODEL and ROUTER modes.
*
* @returns List of available models with basic metadata
*/
static async list(): Promise<ApiModelListResponse> {
return apiFetch<ApiModelListResponse>(API_MODELS.LIST);
}
/**
* Fetch list of all models with detailed metadata (ROUTER mode).
* Returns models with load status, paths, and other metadata
* beyond what the OpenAI-compatible endpoint provides.
*
* @returns List of models with detailed status and configuration info
*/
static async listRouter(): Promise<ApiRouterModelsListResponse> {
return apiFetch<ApiRouterModelsListResponse>(API_MODELS.LIST);
static async list(): Promise<ApiModelsListResponse> {
return apiFetch<ApiModelsListResponse>(API_MODELS.LIST);
}
/**
@@ -80,14 +152,14 @@ export class ModelsService {
* @param extraArgs - Optional additional arguments to pass to the model instance
* @returns Load response from the server
*/
static async load(modelId: string, extraArgs?: string[]): Promise<ApiRouterModelsLoadResponse> {
static async load(modelId: string, extraArgs?: string[]): Promise<ApiModelsLoadResponse> {
const payload: { model: string; extra_args?: string[] } = { model: modelId };
if (extraArgs && extraArgs.length > 0) {
payload.extra_args = extraArgs;
}
return apiPost<ApiRouterModelsLoadResponse>(API_MODELS.LOAD, payload);
return apiPost<ApiModelsLoadResponse>(API_MODELS.LOAD, payload);
}
/**
@@ -108,11 +180,46 @@ export class ModelsService {
params: null,
quantization: null,
raw: modelId,
sidecar: null,
tags: []
};
// strip directory path and weight extension so a bare `-m /path/file.gguf`
// parses like a clean repo id; the HF `org/model` form is preserved
const source = normalizeModelName(modelId).replace(MODEL_ID.WEIGHT_EXTENSION_RE, '');
let source = normalizeModelName(modelId).replace(MODEL_ID.WEIGHT_EXTENSION_REGEX, '');
// 0. Detect sidecar prefix (mtp-, dflash-, mmproj-) before any other
// splitting so the inner id parses cleanly.
const prefixMatch = source.match(MODEL_ID.SIDECAR_PREFIX_REGEX);
if (prefixMatch) {
result.sidecar = sidecarFromFileToken(prefixMatch[1].toLowerCase());
source = prefixMatch[2];
// a sidecar filename's remainder may be just the quant token,
// e.g. `mtp-Q4_0.gguf` or `mmproj-F16.gguf`
if (MODEL_ID.QUANTIZATION_SEGMENT_REGEX.test(source)) {
result.quantization = source.toUpperCase();
source = '';
}
} else {
// 0b. Detect `-<type>` suffix (`-mtp`, `-dflash`, `-dspark`, `-eagle3`).
// Only strip it when the segment preceding it looks like a real quant
// token, so a model literally named `MyModel-mtp` is not mistaken for a
// draft one.
const suffixMatch = source.match(MODEL_ID.SIDECAR_SUFFIX_REGEX);
if (suffixMatch) {
const candidate = suffixMatch[1];
const headSeg = candidate.split(MODEL_ID.SEGMENT_SEPARATOR).pop();
if (headSeg && MODEL_ID.QUANTIZATION_SEGMENT_REGEX.test(headSeg)) {
result.sidecar = sidecarFromFileToken(suffixMatch[2].toLowerCase());
source = candidate;
}
}
}
// 1. Extract colon-separated quantization (e.g. `model:Q4_K_M`)
const colonIdx = source.indexOf(MODEL_ID.QUANTIZATION_SEPARATOR);
@@ -143,7 +250,7 @@ export class ModelsService {
if (dotIdx !== MODEL_ID.NOT_FOUND && !result.quantization) {
const afterDot = modelStr.slice(dotIdx + 1);
if (MODEL_ID.QUANTIZATION_SEGMENT_RE.test(afterDot)) {
if (MODEL_ID.QUANTIZATION_SEGMENT_REGEX.test(afterDot)) {
result.quantization = afterDot;
modelStr = modelStr.slice(0, dotIdx);
}
@@ -158,8 +265,8 @@ export class ModelsService {
const last = segments[segments.length - 1];
const secondLast = segments.length > 2 ? segments[segments.length - 2] : null;
if (MODEL_ID.QUANTIZATION_SEGMENT_RE.test(last)) {
if (secondLast && MODEL_ID.CUSTOM_QUANTIZATION_PREFIX_RE.test(secondLast)) {
if (MODEL_ID.QUANTIZATION_SEGMENT_REGEX.test(last)) {
if (secondLast && MODEL_ID.CUSTOM_QUANTIZATION_PREFIX_REGEX.test(secondLast)) {
result.quantization = `${secondLast}-${last}`;
segments.splice(segments.length - 2, 2);
} else {
@@ -176,10 +283,10 @@ export class ModelsService {
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
if (paramsIdx === MODEL_ID.NOT_FOUND && MODEL_ID.PARAMS_RE.test(seg)) {
if (paramsIdx === MODEL_ID.NOT_FOUND && MODEL_ID.PARAMS_REGEX.test(seg)) {
paramsIdx = i;
result.params = seg.toUpperCase();
} else if (paramsIdx !== MODEL_ID.NOT_FOUND && MODEL_ID.ACTIVATED_PARAMS_RE.test(seg)) {
} else if (paramsIdx !== MODEL_ID.NOT_FOUND && MODEL_ID.ACTIVATED_PARAMS_REGEX.test(seg)) {
activatedParamsIdx = i;
result.activatedParams = seg.toUpperCase();
}
@@ -220,8 +327,8 @@ export class ModelsService {
* @param modelId - Model identifier to unload
* @returns Unload response from the server
*/
static async unload(modelId: string): Promise<ApiRouterModelsUnloadResponse> {
return apiPost<ApiRouterModelsUnloadResponse>(API_MODELS.UNLOAD, { model: modelId });
static async unload(modelId: string): Promise<ApiModelsUnloadResponse> {
return apiPost<ApiModelsUnloadResponse>(API_MODELS.UNLOAD, { model: modelId });
}
/**
+3
View File
@@ -40,6 +40,9 @@ export { mcpStore } from './mcp/index.svelte';
// MODELS
export { modelsStore } from './models/index.svelte';
// MODELS DISCOVER (HuggingFace browse state for the discover dialog)
export { modelsDiscoverStore } from './models-discover/index.svelte';
// SERVER
export { serverStore } from './server.svelte';
@@ -0,0 +1,275 @@
/**
* modelsDiscoverStore - Models Discover browse state
*
* Owns the HuggingFace GGUF model list shown in the discover sidebar
* (DialogModelsDiscover). By default the list is the curated catalog set;
* search replaces it with matches across all of HuggingFace. Both paths fetch
* the same fields, so a row renders the same badges and sizes either way.
*/
import { MODELS_DISCOVER_CATALOG_BATCH } from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
import type {
HfCatalogBuild,
HfCatalogEntry,
HfModelInfo,
HfModelSibling
} from '$lib/types/huggingface';
import { isAuxSidecar } from '$lib/utils';
import { SvelteMap } from 'svelte/reactivity';
/** Min/max GGUF file size (bytes) across the quants of one repo. */
export interface ModelsDiscoverSizeRange {
max: number;
min: number;
}
class ModelsDiscoverStore {
error = $state<string | null>(null);
models = $state<HfModelInfo[]>([]);
/** First model in the list - discover auto-opens this one. */
firstModel = $derived(this.models[0] ?? null);
loading = $state(false);
/** True while a search is in flight; only the newest request owns this flag. */
searching = $state(false);
private catalog: HfCatalogEntry[] = [];
/** Repo id -> size range, for catalog rows and lazily measured search rows. */
private catalogSizeRanges = new SvelteMap<string, ModelsDiscoverSizeRange>();
private defaultModels: HfModelInfo[] = [];
private fetched = false;
private searchRequestId = 0;
/** In-flight `sizeRange()` lookups, keyed by repo id. */
private sizeRangePending = new Map<string, Promise<ModelsDiscoverSizeRange | undefined>>();
/**
* Cached size range for a repo, without measuring: the synchronous part of
* `sizeRange()`, for rendering a row before its measurement resolves.
*/
cachedSizeRangeFor(modelId: string): ModelsDiscoverSizeRange | undefined {
return this.catalogSizeRanges.get(modelId);
}
/**
* Catalog family description for a repo id, or undefined when the repo is
* not part of the catalog (e.g. a search result outside the curated list).
*/
descriptionFor(modelId: string): string | undefined {
return this.catalog.find((entry) =>
entry.sizes.some((size) => size.builds.some((build) => build.repo === modelId))
)?.description;
}
/**
* Fetch the default list from the llama.app catalog, one repo per catalog
* size in display order (newest family first). Every repo is fetched by ID
* with its file tree, so the rows carry chat-template capabilities, context
* length and a real size range - the same data a search result gets.
* No-op when already loaded or in flight.
*/
async fetch(): Promise<void> {
if (this.loading || this.fetched) return;
this.loading = true;
this.error = null;
try {
const catalog = await HuggingFaceService.getCatalog();
this.catalog = catalog;
const builds = this.catalogBuilds();
this.defaultModels = [];
// fetch in small batches: not every repo hits the HF API at once,
// and the rows land as they arrive instead of all at the end
for (let i = 0; i < builds.length; i += MODELS_DISCOVER_CATALOG_BATCH) {
const batch = await Promise.all(
builds.slice(i, i + MODELS_DISCOVER_CATALOG_BATCH).map(async (build) => {
const [info, tree] = await Promise.all([
HuggingFaceService.getDetails(build.repo),
HuggingFaceService.getTree(build.repo)
]);
return { build, info, tree };
})
);
for (const { build, info, tree } of batch) {
if (!info) continue;
this.catalogSizeRanges.set(build.repo, this.sizeRangeFor(build, tree));
// the catalog repo id, not the HF response `id`, drives selection
this.defaultModels.push({ ...info, id: build.repo, modelId: build.repo } as HfModelInfo);
}
// show what has landed, unless a search owns the list right now
if (!this.searching) {
this.models = [...this.defaultModels];
}
}
this.fetched = true;
} catch (err) {
this.error = err instanceof Error ? err.message : 'Failed to fetch models';
} finally {
this.loading = false;
}
}
/** Replace the list with search results; an empty query restores the default list. */
async search(query: string): Promise<void> {
const trimmed = query.trim();
const requestId = ++this.searchRequestId;
if (!trimmed) {
this.searching = false;
this.models = this.defaultModels;
this.error = null;
return;
}
this.searching = true;
try {
const results = await HuggingFaceService.searchByQuery(trimmed, { limit: 50 });
if (requestId !== this.searchRequestId) return;
this.models = results;
this.error = null;
} catch (err) {
if (requestId !== this.searchRequestId) return;
this.error = err instanceof Error ? err.message : 'Search failed';
} finally {
if (requestId === this.searchRequestId) this.searching = false;
}
}
/**
* Size range for a repo not measured yet - a search result, or a catalog
* repo whose tree came back empty. Fetches the file tree once per repo and
* caches it, so remounting a row (scrolling, searching back) is free.
*/
sizeRange(modelId: string): Promise<ModelsDiscoverSizeRange | undefined> {
const cached = this.catalogSizeRanges.get(modelId);
if (cached) return Promise.resolve(cached);
const pending = this.sizeRangePending.get(modelId);
if (pending) return pending;
const request = (async () => {
const tree = await HuggingFaceService.getTree(modelId);
const range = this.sizeRangeOfMainQuants(tree);
if (range) this.catalogSizeRanges.set(modelId, range);
return range;
})()
.catch(() => undefined)
.finally(() => this.sizeRangePending.delete(modelId));
this.sizeRangePending.set(modelId, request);
return request;
}
/**
* Bytes of every quant the catalog lists under this repo, parsed from the
* `size` strings when a build carries no `sizeBytes`. Never empty: an
* unparsable entry contributes the build's own size.
*/
private buildSizeBytes(build: HfCatalogBuild): number[] {
const sizes = this.catalog
.flatMap((entry) => entry.sizes)
.flatMap((size) => size.builds.filter((b) => b.repo === build.repo))
.map((b) => b.sizeBytes ?? HuggingFaceService.parseSizeBytes(b.size))
.filter((bytes): bytes is number => Boolean(bytes) && bytes > 0);
return sizes.length > 0 ? sizes : [build.sizeBytes ?? 0];
}
/**
* One build per catalog size, newest family first (by release date).
* Prefers the official ggml-org repo, falling back to the first build so
* families published only by other orgs (mistralai, unsloth) still show up.
* Returns an empty array when the catalog is empty.
*/
private catalogBuilds(): HfCatalogBuild[] {
return [...this.catalog]
.sort((a, b) => b.released.localeCompare(a.released))
.flatMap((entry) =>
entry.sizes.flatMap((size) => {
const build = size.builds.find((b) => b.repo.startsWith('ggml-org/')) ?? size.builds[0];
return build ? [build] : [];
})
);
}
/** Byte sizes of every non-sidecar quant file in a tree, shards collapsed. */
private quantSizesOf(tree: HfModelSibling[]): number[] {
return HuggingFaceService.collapseGgufShards(
HuggingFaceService.filterByExtension(tree, '.gguf')
)
.filter((f) => {
const { quant, sidecar } = HuggingFaceService.extractQuantMeta(f.path) ?? {};
return Boolean(quant) && (sidecar === null || sidecar === undefined);
})
.map((f) => f.size ?? 0)
.filter((size) => size > 0);
}
/**
* Size range of one catalog build: every quant in the repo's file tree, plus
* the draft sidecars those files carry (mtp, dflash, ...) so the downloaded
* model fits within the range. Falls back to the catalog `size` / `sizeBytes`
* strings when the tree yielded nothing (partial fetch, sharded-only repo).
*/
private sizeRangeFor(build: HfCatalogBuild, tree: HfModelSibling[]): ModelsDiscoverSizeRange {
const quantSizes = this.quantSizesOf(tree);
if (quantSizes.length === 0) {
const listed = this.buildSizeBytes(build);
return { max: Math.max(...listed), min: Math.min(...listed) };
}
const draftSizes = tree
.filter((f) => {
const sidecar = HuggingFaceService.extractQuantMeta(f.path)?.sidecar;
return sidecar !== null && sidecar !== undefined && !isAuxSidecar(sidecar);
})
.map((f) => f.size ?? 0)
.filter((size) => size > 0);
const extra = draftSizes.length > 0 ? Math.max(...draftSizes) : 0;
return {
max: Math.max(...quantSizes) + extra,
min: Math.min(...quantSizes) + (draftSizes.length > 0 ? Math.min(...draftSizes) : 0)
};
}
/**
* Size range across the main-model quants of a file tree, draft sidecars
* excluded (they only widen the range when a row advertises them).
*/
private sizeRangeOfMainQuants(tree: HfModelSibling[]): ModelsDiscoverSizeRange | undefined {
const sizes = this.quantSizesOf(tree);
if (sizes.length === 0) return undefined;
return { max: Math.max(...sizes), min: Math.min(...sizes) };
}
}
export const modelsDiscoverStore = new ModelsDiscoverStore();
+45 -28
View File
@@ -193,17 +193,20 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
}
/**
* Fetch router models with full metadata (ROUTER mode only).
* No-op in router mode fetch() already calls listRouter() internally.
* Fetch models with full metadata (ROUTER mode only).
* No-op in MODEL mode - fetch() already calls list() internally.
* Kept for API compatibility (e.g. handleOpenChange dropdown open handler).
*/
async fetchRouterModels(): Promise<void> {
if (!serverStore.isRouterMode) return;
try {
const response = await ModelsService.listRouter();
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();
@@ -358,30 +361,45 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
* Both MODEL and ROUTER modes share the same mapping logic;
* they differ only in which endpoint is called.
*/
private buildModelOptions(
response: ApiModelListResponse | ApiRouterModelsListResponse
): 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;
private buildModelOptions(response: ApiModelsListResponse): ModelOption[] {
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). */
@@ -390,7 +408,6 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
return this.buildModelOptions(response);
}
/**
* Filter to models visible in the UI (ui !== false).
*/
@@ -422,7 +439,7 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
const router = serverStore.isRouterMode;
if (router) {
const response = await ModelsService.listRouter();
const response = await ModelsService.list();
this.routerModels = response.data;
this.models = this.buildModelOptions(response);
+408 -4
View File
@@ -7,12 +7,22 @@
* modelsStore; the host owns the router model rows the feed 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 +40,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 +100,128 @@ 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 previously downloaded/failed model
* from the server cache (ROUTER mode only). The cached row is dropped via the
* feed's model_remove event.
*/
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;
}
}
/**
* Cancel an in-flight load (ROUTER mode only). The server force-kills a
* LOADING model on unload; 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);
for (const repoWithTag of JSON.parse(raw ?? '[]') as string[]) {
this.pausedDownloads.set(repoWithTag, null);
}
} catch {
// unreadable or corrupt: start without the paused set
}
}
/**
* Trigger a model download from HuggingFace via POST /models
* (ggml-org/llama.cpp#23976). The download runs in the background on the
* server; the model appears in the list once the feed reports models_reload.
* Progress is reported by the /models/sse feed; resuming a paused download
* (same tag) continues from the partial files the pause 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;
@@ -49,6 +229,38 @@ export class ModelStatusManager {
await this.load(modelId);
}
/**
* All tracked downloads (in flight or paused) with their last reported
* progress, for the models selector's "Download in progress" section.
* Paused entries carry their frozen progress snapshot.
*/
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];
}
/**
* Current download progress (bytes) for a `<repo>:<tag>` identifier, or null
* when no download is being reported by the /models/sse feed.
*/
getDownloadProgress(repoWithTag: string): ModelDownloadProgress | null {
return this.downloadProgress.get(repoWithTag) ?? null;
}
/**
* Current load progress for a model, or null when not loading.
*/
@@ -56,10 +268,57 @@ export class ModelStatusManager {
return this.loadProgress.get(modelId) ?? null;
}
/**
* Last reported progress of a paused download, or null when no progress
* event arrived before the pause.
*/
getPausedDownloadProgress(repoWithTag: string): ModelDownloadProgress | null {
return this.pausedDownloads.get(repoWithTag) ?? null;
}
/** Whether the most recent download attempt for the given entry failed. */
hasFailedDownload(repoWithTag: string): boolean {
return this.failedDownloads.has(repoWithTag);
}
/**
* True when the feed reports an active download for the given `<repo>:<tag>`.
* Cleared on download_finished / download_failed.
*/
isDownloadInProgress(repoWithTag: string): boolean {
return this.downloadProgress.has(repoWithTag);
}
/**
* True when the user paused an in-flight download and it has not been resumed.
*/
isDownloadPaused(repoWithTag: string): boolean {
return this.pausedDownloads.has(repoWithTag);
}
/**
* True when the given `<repo>:<tag>` is already a fully downloaded model
* registered with the server (i.e. it shows up 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;
}
/**
* True when the given sidecar file (repo-relative path) has been pulled as
* the `--model-draft` or `--mmproj` of some registered model.
*/
isSidecarDownloaded(repoId: string, filePath: string): boolean {
return this.downloadedSidecars.has(`${repoId}/${filePath}`);
}
async load(modelId: string): Promise<void> {
if (this.host.isModelLoaded(modelId)) return;
@@ -90,6 +349,31 @@ export class ModelStatusManager {
}
}
/**
* Pause an in-flight download (ROUTER mode only). The server stops the
* download child but keeps the partial files on disk, so re-posting the
* tag (downloadModel) resumes the download where it stopped. The feed
* reports the stop as download_failed; a 'pause' stop request marks it as such.
*/
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 and keep it live with auto reconnect.
* Idempotent and router mode only.
@@ -141,6 +425,87 @@ export class ModelStatusManager {
this.statusAbort?.abort();
this.statusAbort = null;
this.loadProgress.clear();
this.downloadProgress.clear();
this.failedDownloads.clear();
this.stopRequests.clear();
}
/**
* Drop the stored progress for the model and toast the outcome.
* 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
* delete-and-retry path.
*/
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)}`);
}
}
/**
* Bucket the per-file byte counts from a `download_progress` envelope.
* Total = sum of `total` across files (plan size), downloaded sum of `done`.
*/
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
});
}
/**
@@ -151,7 +516,7 @@ export class ModelStatusManager {
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;
@@ -202,7 +567,33 @@ export class ModelStatusManager {
break;
case ServerModelsSseEventType.DOWNLOAD_PROGRESS:
this.applyDownloadProgress(event);
break;
case ServerModelsSseEventType.DOWNLOAD_FINISHED:
case ServerModelsSseEventType.DOWNLOAD_FAILED:
this.applyDownloadFinished(event);
break;
}
}
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
}
}
@@ -226,7 +617,15 @@ export class ModelStatusManager {
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();
}
/**
@@ -236,6 +635,11 @@ export class ModelStatusManager {
await ModelsService.watchModelEvents(signal, (event) => this.applyStatusEvent(event));
}
private setPausedDownload(repoWithTag: string, progress: ModelDownloadProgress | null): void {
this.pausedDownloads.set(repoWithTag, progress);
this.persistPausedDownloads();
}
/**
* Update one model row status in place, reassigning to trigger reactivity.
*/
+36 -67
View File
@@ -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 {
@@ -174,12 +201,6 @@ export interface ApiModelDetails {
};
}
export interface ApiModelListResponse {
object: string;
data: ApiModelDataEntry[];
models?: ApiModelDetails[];
}
export interface ApiLlamaCppServerProps {
default_generation_settings: {
id: number;
@@ -448,79 +469,27 @@ export interface ApiProcessingState {
}
/**
* Router model metadata - extended from ApiModelDataEntry with additional router-specific fields
* @deprecated Use ApiModelDataEntry instead - the /models endpoint returns this structure directly
* Response from POST /models/load
*/
export interface ApiRouterModelMeta {
/** Model identifier (e.g., "ggml-org/Qwen2.5-Omni-7B-GGUF:latest") */
name: string;
/** Path to model file or manifest */
path: string;
/** Optional path to multimodal projector */
path_mmproj?: string;
/** Whether model is in HuggingFace cache */
in_cache: boolean;
/** Port where model instance is running (0 if not loaded) */
port?: number;
/** Current status of the model */
status: ApiModelStatus;
/** Error message if status is FAILED */
error?: string;
}
/**
* Request to load a model
*/
export interface ApiRouterModelsLoadRequest {
model: string;
}
/**
* Response from loading a model
*/
export interface ApiRouterModelsLoadResponse {
export interface ApiModelsLoadResponse {
success: boolean;
error?: string;
}
/**
* Request to check model status
* Response with list of all models from /v1/models and /models endpoints
* (same structure regardless of server mode)
*/
export interface ApiRouterModelsStatusRequest {
model: string;
}
/**
* Response with model status
*/
export interface ApiRouterModelsStatusResponse {
model: string;
status: ModelStatus;
port?: number;
error?: string;
}
/**
* Response with list of all models from /models endpoint
* Note: This is the same as ApiModelListResponse - the endpoint returns the same structure
* regardless of server mode (MODEL or ROUTER)
*/
export interface ApiRouterModelsListResponse {
export interface ApiModelsListResponse {
object: string;
data: ApiModelDataEntry[];
models?: ApiModelDetails[];
}
/**
* Request to unload a model
* Response from POST /models/unload
*/
export interface ApiRouterModelsUnloadRequest {
model: string;
}
/**
* Response from unloading a model
*/
export interface ApiRouterModelsUnloadResponse {
export interface ApiModelsUnloadResponse {
success: boolean;
error?: string;
}
+227
View File
@@ -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 -9
View File
@@ -14,9 +14,11 @@ export type {
ApiModelLoadStage,
ApiModelsSseProgress,
ApiModelsSseData,
ApiModelsSseDownloadProgressData,
ApiModelsSseEvent,
ApiModelsDownloadRequest,
ApiModelsDownloadResponse,
ApiModelDetails,
ApiModelListResponse,
ApiLlamaCppServerProps,
ApiChatCompletionRequest,
ApiChatCompletionToolCallFunctionDelta,
@@ -26,18 +28,29 @@ export type {
ApiChatCompletionResponse,
ApiSlotData,
ApiProcessingState,
ApiRouterModelMeta,
ApiRouterModelsLoadRequest,
ApiRouterModelsLoadResponse,
ApiRouterModelsStatusRequest,
ApiRouterModelsStatusResponse,
ApiRouterModelsListResponse,
ApiRouterModelsUnloadRequest,
ApiRouterModelsUnloadResponse,
ApiModelsLoadResponse,
ApiModelsListResponse,
ApiModelsUnloadResponse,
AudioInputFormat,
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,
@@ -92,10 +105,20 @@ export type {
ModelCapabilities,
ModelModalities,
ModelOption,
ModelDownloadFileProgress,
ModelDownloadProgress,
ModelLoadProgress,
ModalityCapabilities
} from './models';
// Models discover types
export type {
ModelBitDepthRow,
ModelDownloadEntryState,
ModelQuantOption,
ModelSelectableFile
} from './models-discover';
// Settings types
export type {
SettingsConfigValue,
+30
View File
@@ -0,0 +1,30 @@
import type { ModelSelectableFileKind } from '$lib/enums';
import type { HfModelSibling } from '$lib/types/huggingface';
/**
* Option of a quant `<select>` in the download-options command builder; the
* picks only compose the serve command and are not bound to the quant chips.
*/
export interface ModelQuantOption {
/** Quant token, or the file name when the file carries no quant (e.g. BF16). */
label: string;
/** Repo-relative file path the option stands for. */
path: string;
}
/** Download state of a single repo entry, injected by the integration layer. */
export interface ModelDownloadEntryState {
/** Server identifier the entry's actions (pause / resume / cancel / retry) target. */
repoWithTag: string;
isDownloading: boolean;
progress: ModelDownloadProgress | null;
isDownloaded: boolean;
isPaused: boolean;
isFailed: boolean;
}
/** A group of GGUF files sharing one bit-depth bucket. */
export type ModelBitDepthRow = { bitDepth: number; files: HfModelSibling[] };
/** A selectable GGUF, tagged with its role: main weights, draft, or aux (mmproj). */
export type ModelSelectableFile = HfModelSibling & { kind: ModelSelectableFileKind };
+19 -8
View File
@@ -1,3 +1,4 @@
import type { ModelSidecar } from '$lib/constants/model-id.constants';
import type { ApiModelDataEntry, ApiModelDetails, ApiModelLoadStage } from '$lib/types/api';
export interface ModelModalities {
@@ -8,6 +9,7 @@ export interface ModelModalities {
export interface ModelCapabilities {
reasoning: boolean;
tools: boolean;
}
export interface ModelOption {
@@ -24,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;
@@ -42,12 +54,11 @@ export interface ParsedModelId {
params: string | null;
activatedParams: string | null;
quantization: string | null;
sidecar: ModelSidecar | null;
tags: string[];
}
/**
* Modality capabilities for file validation
*/
/** Modality capabilities for file validation. */
export interface ModalityCapabilities {
hasVision: boolean;
hasAudio: boolean;
+35 -1
View File
@@ -49,7 +49,7 @@ export interface ApiFetchOptions extends Omit<RequestInit, 'headers'> {
* @example
* ```typescript
* // GET request
* const models = await apiFetch<ApiModelListResponse>('/v1/models');
* const models = await apiFetch<ApiModelsListResponse>('/v1/models');
*
* // POST request
* const result = await apiFetch<ApiResponse>('/models/load', {
@@ -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.
+1 -1
View File
@@ -16,7 +16,7 @@ import {
} from '$lib/constants';
import type { ToolExecutionResult } from '$lib/types';
function detectOs(userAgent: string): string {
export function detectOs(userAgent: string): string {
for (const [pattern, os] of BROWSER_INFO_OS_UA_PATTERNS) {
if (pattern.test(userAgent)) return os;
}
@@ -0,0 +1,29 @@
/**
* Detects whether a model's chat template supports tool calling.
*
* There is no server flag for tool support, so we infer it from the chat
* template. A template that accepts a `tools` array or emits tool-call tokens
* is treated as tool-capable.
*/
/** Tool-call tokens emitted by the template for assistant tool calls, matched case-insensitively. */
const TOOL_CALL_TOKENS = [
'tool_call',
'tool_calls',
'function_call',
'tool_use',
'<tool',
'<|tool'
];
/** Jinja reference to the `tools` array passed in by the caller. */
const JINJA_TOOLS_VAR = /\{\{[^{}]*\btools\b[^{}]*\}\}|\{%[^{}]*\btools\b[^{}]*%\}/i;
export function detectToolUseSupport(t: string): boolean {
if (!t) return false;
if (JINJA_TOOLS_VAR.test(t)) return true;
const template = t.toLowerCase();
return TOOL_CALL_TOKENS.some((token) => template.includes(token));
}
+11 -2
View File
@@ -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
@@ -107,6 +107,9 @@ export {
// Model name utilities
export { normalizeModelName, isValidModelName } from './model-names';
// Sidecar token utilities
export { isAuxSidecar, isDraftSidecar, sidecarFromFileToken } from './sidecars';
// Portal utilities
export { portalToBody } from './portal-to-body';
@@ -340,7 +343,13 @@ export { buildSandboxToolDefinition, SANDBOX_TOOL_DEFINITION } from './sandbox-t
export { executeGetDatetimeTool } from './get-datetime';
// Browser fallback for the server's get_info tool
export { executeBrowserInfoTool } from './browser-info';
export { detectOs, executeBrowserInfoTool } from './browser-info';
// Tool-use support detection from a chat template
export { detectToolUseSupport } from './chat-template-tool-detector';
// Model memory estimation
export { minMemoryTierGb } from './model-compatibility';
// Cryptography utilities
@@ -0,0 +1,37 @@
/**
* Model memory estimation.
*
* Mirrors the app's compatibility check (Model+Compatibility.swift): the
* runtime budget is RAM x 0.75 minus a fixed overhead, and a file fits when
* its size with headroom stays under that budget. The result is the smallest
* memory tier that can run the model, so the UI presents an honest machine
* requirement instead of a raw file size. Context length and
* device-specific budgets are deliberately ignored - callers present the
* requirement and let the user judge.
*/
import {
MB_PER_GB,
MEM_TIERS,
MIB_BYTES,
QUANT_WEIGHT,
RAM_BUDGET_RATIO,
RAM_OVERHEAD_MB
} from '$lib/constants';
/**
* Smallest memory tier (GB) that can run a model of the given file size,
* or null if nothing fits even the largest tier.
*/
export function minMemoryTierGb(sizeBytes: number): number | null {
if (!sizeBytes) return null;
const weightMb = (sizeBytes / MIB_BYTES) * QUANT_WEIGHT;
for (const tier of MEM_TIERS) {
const budgetMb = tier * MB_PER_GB * RAM_BUDGET_RATIO - RAM_OVERHEAD_MB;
if (weightMb <= budgetMb) return tier;
}
return null;
}
+12 -1
View File
@@ -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;
}
+19
View File
@@ -0,0 +1,19 @@
import { type ModelSidecar, SIDECAR_TOKENS } from '$lib/constants';
import { ModelAuxSidecar, ModelDraftSidecar } from '$lib/enums';
const SIDECAR_TOKEN_SET = new Set<string>(SIDECAR_TOKENS);
const DRAFT_SIDECAR_SET = new Set<string>(Object.values(ModelDraftSidecar));
const AUX_SIDECAR_SET = new Set<string>(Object.values(ModelAuxSidecar));
/** Map a lowercase filename token (e.g. `mtp`) to its sidecar enum value. */
export function sidecarFromFileToken(token: string): ModelSidecar | null {
return SIDECAR_TOKEN_SET.has(token) ? (token as ModelSidecar) : null;
}
export function isDraftSidecar(sidecar: ModelSidecar): sidecar is ModelDraftSidecar {
return DRAFT_SIDECAR_SET.has(sidecar);
}
export function isAuxSidecar(sidecar: ModelSidecar): sidecar is ModelAuxSidecar {
return AUX_SIDECAR_SET.has(sidecar);
}
@@ -0,0 +1,149 @@
// Adversarial checks for MarkdownContent's allowHtml mode: the discover README
// renders HuggingFace model cards, which are third-party content, so every
// payload below must come out neutralized - both right after sanitize and
// after an innerHTML re-parse, which is where mutation XSS would surface.
import MarkdownContent from '$lib/components/app/content/MarkdownContent/MarkdownContent.svelte';
import { SAFE_HTML_CONFIG } from '$lib/components/app/content/MarkdownContent/safe-html-config';
import DOMPurify from 'dompurify';
import { mount, unmount } from 'svelte';
import { describe, expect, it } from 'vitest';
const HANDLER_ATTR = /\son[a-z]+\s*=/i;
const JS_URL = /javascript\s*:/i;
/** Payload execution canary: set by any payload that runs. */
function xssFired(): boolean {
return (window as { __mdXss?: number }).__mdXss !== undefined;
}
function roundTrip(html: string): string {
const el = document.createElement('div');
el.innerHTML = html;
document.body.appendChild(el);
const again = el.innerHTML;
el.remove();
return again;
}
const PAYLOADS: Record<string, string> = {
'details ontoggle': '<details open ontoggle="window.__mdXss=1">x</details>',
'dom clobbering': '<p id="content" name="location"><input name="domain"></p>',
'form and input': '<form><input autofocus onfocus="window.__mdXss=1"></form>',
'iframe javascript src': '<iframe src="javascript:alert(1)"></iframe>',
'iframe srcdoc': '<iframe srcdoc="<script>window.__mdXss=1</script>"></iframe>',
'img onerror': '<img src=x onerror="window.__mdXss=1">',
'link case and entity href': '<a href="JaVaScRiPt&colon;alert(1)">x</a>',
'link data:text/html href':
'<a href="data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==">x</a>',
'link javascript href': '<a href="javascript:alert(1)">x</a>',
'link vbscript href': '<a href="vbscript:msgbox(1)">x</a>',
'math href': '<math href="javascript:alert(1)"></math>',
'mathml annotation-xml integration point':
'<math><annotation-xml encoding="text/html"><img src=x onerror="window.__mdXss=1"></annotation-xml></math>',
'mathml mtext style mXSS':
'<math><mtext><table><mglyph><style><img src=x onerror="window.__mdXss=1"></style></mglyph></table></mtext></math>',
noscript: '<noscript><p title="</noscript><img src=x onerror="window.__mdXss=1>">',
script: '<script>window.__mdXss=1</script>',
'srcset javascript': '<img srcset="javascript:alert(1) 1x, x 2x" src=x>',
'style tag': '<style>@import url(javascript:alert(1));</style>',
'svg foreignObject smuggling':
'<svg><foreignObject><img src=x onerror="window.__mdXss=1"></foreignObject></svg>',
'svg set-attribute animation': '<svg><set attributeName="onmouseover" to="alert(1)"/></svg>',
'unclosed tag': '<img src="x" onerror="window.__mdXss=1"',
'uppercase tags': '<IMG SRC=x ONERROR="window.__mdXss=1">',
'video source onerror': '<video><source onerror="window.__mdXss=1" src=x></video>',
'xlink href': '<math><mtext xlink:href="javascript:alert(1)"></mtext></math>'
};
describe('SAFE_HTML_CONFIG adversarial battery', () => {
it('neutralizes every payload after sanitize and after innerHTML re-parse', () => {
const failures: string[] = [];
for (const [name, payload] of Object.entries(PAYLOADS)) {
const clean = DOMPurify.sanitize(payload, SAFE_HTML_CONFIG) as string;
const again = roundTrip(clean);
if (HANDLER_ATTR.test(clean) || HANDLER_ATTR.test(again)) {
failures.push(`${name}: handler attribute survived: ${again}`);
}
if (JS_URL.test(clean) || JS_URL.test(again)) {
failures.push(`${name}: javascript: URL survived: ${again}`);
}
}
expect(failures).toEqual([]);
});
it('keeps benign model-card markup intact', () => {
const clean = DOMPurify.sanitize(
'<h2 id="title">Title</h2><p>Text with <a href="https://example.com" target="_blank" rel="noopener">a link</a>, <img src="https://example.com/i.png" alt="i" width="100">, <code>code</code>, <table><tr><td colspan="2">cell</td></tr></table>, <math><semantics><annotation encoding="application/x-tex">x^2</annotation></semantics></math></p>',
SAFE_HTML_CONFIG
) as string;
expect(clean).toContain('href="https://example.com"');
expect(clean).toContain('<img');
expect(clean).toContain('colspan="2"');
expect(clean).toContain('<annotation');
});
});
describe('MarkdownContent end to end', () => {
it('renders an allowHtml payload README without executing it', async () => {
const evil = [
'# Evil card',
'',
'<img src=x onerror="window.__mdXss=1">',
'<a href="javascript:alert(1)">x</a>',
'<script>window.__mdXss=1</script>',
'<iframe srcdoc="<script>window.__mdXss=1</script>"></iframe>',
'<math><annotation-xml encoding="text/html"><img src=x onerror="window.__mdXss=1"></annotation-xml></math>',
'',
'normal **markdown** continues'
].join('\n');
const target = document.createElement('div');
document.body.appendChild(target);
mount(MarkdownContent, { props: { allowHtml: true, content: evil }, target });
for (let i = 0; i < 100 && !target.textContent?.includes('normal'); i++) {
await new Promise((r) => setTimeout(r, 50));
}
await new Promise((r) => setTimeout(r, 200));
expect(target.textContent).toContain('normal');
expect(xssFired()).toBe(false);
expect(target.querySelector('script')).toBeNull();
expect(target.querySelector('iframe')).toBeNull();
target.remove();
});
it('escapes raw HTML to literal text in the default mode', async () => {
const target = document.createElement('div');
document.body.appendChild(target);
const component = mount(MarkdownContent, {
props: {
content: '<script>window.__mdXss=1</script><img src=x onerror="window.__mdXss=1">'
},
target
});
for (let i = 0; i < 100 && !target.textContent?.includes('onerror'); i++) {
await new Promise((r) => setTimeout(r, 50));
}
await new Promise((r) => setTimeout(r, 200));
expect(xssFired()).toBe(false);
expect(target.querySelector('img[src="x"]')).toBeNull();
expect(target.textContent).toContain('onerror');
target.remove();
if (component) unmount(component);
});
});
@@ -1,3 +1,4 @@
import { ModelAuxSidecar, ModelDraftSidecar } from '$lib/enums';
import { ModelsService } from '$lib/services/models.service';
import { describe, expect, it } from 'vitest';
@@ -12,6 +13,7 @@ describe('parseModelId', () => {
params: null,
quantization: null,
raw: 'model-name-1',
sidecar: null,
tags: []
});
@@ -22,6 +24,7 @@ describe('parseModelId', () => {
params: null,
quantization: null,
raw: 'org/model-name-2',
sidecar: null,
tags: []
});
});
@@ -105,6 +108,7 @@ describe('parseModelId', () => {
params: null,
quantization: 'Q2_K_XL',
raw: 'unsloth/DeepSeek-V4-Flash-0731-GGUF:Q2_K_XL',
sidecar: null,
tags: []
});
@@ -115,6 +119,7 @@ describe('parseModelId', () => {
params: null,
quantization: 'Q4_K_XL',
raw: 'unsloth/Laguna-S-2.1-GGUF:Q4_K_XL',
sidecar: null,
tags: []
});
@@ -125,6 +130,7 @@ describe('parseModelId', () => {
params: null,
quantization: null,
raw: 'org/Model-Name-GGUF',
sidecar: null,
tags: []
});
});
@@ -137,6 +143,7 @@ describe('parseModelId', () => {
params: '8B',
quantization: null,
raw: 'meta-llama/Llama-3.1-8B',
sidecar: null,
tags: []
});
@@ -147,6 +154,7 @@ describe('parseModelId', () => {
params: '120B',
quantization: 'MXFP4',
raw: 'openai/gpt-oss-120b-MXFP4',
sidecar: null,
tags: []
});
@@ -157,6 +165,7 @@ describe('parseModelId', () => {
params: '20B',
quantization: 'Q4_K_M',
raw: 'openai/gpt-oss-20b:Q4_K_M',
sidecar: null,
tags: []
});
@@ -167,6 +176,7 @@ describe('parseModelId', () => {
params: '30B',
quantization: 'BF16',
raw: 'Qwen/Qwen3-Coder-30B-A3B-Instruct-1M-BF16',
sidecar: null,
tags: ['Instruct', '1M']
});
});
@@ -179,6 +189,7 @@ describe('parseModelId', () => {
params: '17B',
quantization: 'Q4_K_M',
raw: 'meta-llama/Llama-4-Scout-17B-16E-Instruct-Q4_K_M',
sidecar: null,
tags: ['16E', 'Instruct']
});
@@ -189,6 +200,7 @@ describe('parseModelId', () => {
params: null,
quantization: 'IQ4_XS',
raw: 'MiniMaxAI/MiniMax-M2-IQ4_XS',
sidecar: null,
tags: []
});
@@ -199,6 +211,7 @@ describe('parseModelId', () => {
params: null,
quantization: 'UD-Q3_K_XL',
raw: 'MiniMaxAI/MiniMax-M2-UD-Q3_K_XL',
sidecar: null,
tags: []
});
@@ -209,6 +222,7 @@ describe('parseModelId', () => {
params: '123B',
quantization: 'Q4_K_M',
raw: 'mistralai/Devstral-2-123B-Instruct-2512-Q4_K_M',
sidecar: null,
tags: ['Instruct', '2512']
});
@@ -219,6 +233,7 @@ describe('parseModelId', () => {
params: '24B',
quantization: 'Q8_0',
raw: 'mistralai/Devstral-Small-2-24B-Instruct-2512-Q8_0',
sidecar: null,
tags: ['Instruct', '2512']
});
@@ -229,6 +244,7 @@ describe('parseModelId', () => {
params: null,
quantization: 'MXFP4_MOE',
raw: 'noctrex/GLM-4.7-Flash-MXFP4_MOE',
sidecar: null,
tags: []
});
@@ -239,6 +255,7 @@ describe('parseModelId', () => {
params: null,
quantization: 'Q4_K_M',
raw: 'Qwen/Qwen3-Coder-Next-Q4_K_M',
sidecar: null,
tags: []
});
@@ -249,6 +266,7 @@ describe('parseModelId', () => {
params: '120B',
quantization: 'Q4_K_M',
raw: 'openai/gpt-oss-120b-Q4_K_M',
sidecar: null,
tags: []
});
@@ -259,6 +277,7 @@ describe('parseModelId', () => {
params: '20B',
quantization: 'F16',
raw: 'openai/gpt-oss-20b-F16',
sidecar: null,
tags: []
});
@@ -269,6 +288,7 @@ describe('parseModelId', () => {
params: null,
quantization: 'Q4_K_M',
raw: 'nomic-embed-text-v2-moe.Q4_K_M',
sidecar: null,
tags: []
});
});
@@ -304,4 +324,41 @@ describe('parseModelId', () => {
tags: ['it']
});
});
it('parses sidecar file tokens', () => {
// sidecar prefix: bare filename or multi-slash path reduces to the filename
expect(parseModelId('mtp-Q4_0.gguf')).toMatchObject({
quantization: 'Q4_0',
sidecar: ModelDraftSidecar.MTP
});
expect(parseModelId('ggml-org/Model-GGUF/mtp-Q4_0.gguf')).toMatchObject({
quantization: 'Q4_0',
sidecar: ModelDraftSidecar.MTP
});
expect(parseModelId('ggml-org/Model-GGUF/mmproj-F16.gguf')).toMatchObject({
quantization: 'F16',
sidecar: ModelAuxSidecar.MMPROJ
});
// embedded-draft suffix: -<type> only strips when preceded by a quant
expect(parseModelId('ggml-org/Hy3-IQ1_M-mtp')).toMatchObject({
modelName: 'Hy3',
quantization: 'IQ1_M',
sidecar: ModelDraftSidecar.MTP
});
// a model literally named MyModel-mtp is not a draft
expect(parseModelId('ggml-org/MyModel-mtp')).toMatchObject({
modelName: 'MyModel-mtp',
sidecar: null
});
// no sidecar
expect(parseModelId('ggml-org/model-Q4_K_M')).toMatchObject({
quantization: 'Q4_K_M',
sidecar: null
});
});
});
@@ -0,0 +1,127 @@
import { ModelAuxSidecar, ModelDraftSidecar, SidecarForm } from '$lib/enums';
import { HuggingFaceService } from '$lib/services/huggingface.service';
import { ModelsService } from '$lib/services/models.service';
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);
});
});