mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-10-29 02:06:31 +08:00
* 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 * 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 * ui : add the discover models components The Discover surface: the curated-catalog list with its search and skeletons, the details pane with its readme, metadata, Hub stats and download options, and the standalone download progress bar that replaces the plain one. The quant download button sizes its select with a new xs trigger, so the select gains that size and the muted box look. The store stays incomplete when a repo fails, so the next mount retries it. Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash * ui : put the discover view in the models manager The manager dialog gains its second view: the Discover Models call to action fades the table out, slides the title with its compass mark, and fades the discover surface in; the arrow button returns to the table. Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash # Conflicts: # tools/ui/src/lib/components/ui/button/button.svelte * ui : add the Enable Discover Models setting The flag gates browsing and downloading HuggingFace GGUF models, so it is declared on the discover layer; the repo-tree lookup in the manager layer above reads it from here. Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash * ui : resume model downloads from the pause point A stopped download keeps its partial file instead of deleting it, so re-posting the tag resumes from disk. The first progress record now carries the resume offset, the paused snapshot survives into the resumed entry, and a pause settles as soon as it is requested. Assisted-by: pi * ui : lead the discover list with downloads The discover sidebar lists running downloads above the suggested models, and steps aside while a query is active. A paused download stays in the manager table, where it is resumed or dropped. Assisted-by: pi * ui : make resuming a paused download work Re-posting the tag of a paused download hit the router's "already exists" check, because a paused download parks its entry as DOWNLOADED until the next reload: a download entry that is not running is now replaced and resumed. The UI keeps the paused row when the request still fails, instead of dropping it and looking finished. Assisted-by: pi * ui : remember unfinished model downloads A partial file is named after its content hash, so nothing on disk says which model it belongs to once the router restarts. The router now keeps the tags of the downloads it started, clears a tag when the download finishes or is deleted, and lists what is left as still downloading without starting anything. The UI adopts those tags as paused on every list fetch, so a refresh or a fresh open shows an incomplete download and can resume it from the files already on disk. Assisted-by: pi * ui : show a paused download's state on its chip A paused quant chip reads as paused at rest, the way a running one shows its spinner, and offers the resume action on hover. The selector's download row gets a View in Discover action beside its cancel. Assisted-by: pi * ui : collapse the download options by default The copyable download command starts collapsed so the option rows lead; expanding it stays one click away. Assisted-by: pi * ui : keep decision models out of the hub lists Decision models answer with calibrated probabilities instead of text, so llama-ui cannot run them: the hub data layer drops them from the catalog and every search, keyed by the decision-model tag or a text-classification pipeline. Assisted-by: pi * ui : read download tags as quant and sidecar A `<repo>:<quant>-<sidecar>` tag now parses into its quant and sidecar instead of one opaque quant token, and ModelsService.draftSidecarsByRepo collects the router listing's sidecar-only entries per repo, replacing the inline copy in the models store. Assisted-by: pi * ui : cluster draft downloads under their main model Tracked downloads group by repo: the main weights lead and the draft sidecars ride inside their item as compact sub-lines, on the selector, the discover list and the manager table. The main model's controls pause, resume and cancel the whole cluster, and the manager's download rows select into the configuration pane. Assisted-by: pi * ui : act on tracked downloads from the table rows The table passes its clusters' draft sub-lines to the model rows. A row's actions cascade pause and resume, an in-flight row offers Cancel download while a paused one keeps Delete from disk, and the confirm dialog's copy follows the state. Assisted-by: pi * ui : give downloads a configuration pane of their own Selecting a tracked download opens the pane in a download mode: live progress with pause, resume and cancel, and See in Discover, instead of metadata and load actions that a model without files cannot offer. Assisted-by: pi * ui : make the discover details self-fetching and responsive ModelsDiscoverDetailsContainer owns the details, tree and readme fetches so hosts outside the discover view can render the pane, and the details layout adapts to narrow panes with container queries instead of viewport breakpoints. Assisted-by: pi * ui : download a matching draft with the main model A main quant download now pulls the quant-matched draft of the most preferred kind the repo ships (DFlash before MTP), fire-and-forget, so speculative decoding is ready when the main lands. Manual draft chips stay available; the options row also collapses to one column in narrow panes. Assisted-by: pi * ui : keep discover off by default and couple it with the hub setting Discover Models starts unchecked. Enabling it turns the Hugging Face Hub setting on and disabling it turns it back off, so the checkbox never lies; with discover off the hub setting stays available on its own for the display data. Stored and reset state are corrected the same way on load. Assisted-by: pi * ui : open the manage models dialog on the manager view The dialog now opens on the manager instead of returning to the last view, discover entries still land on the discover view while the setting allows it, and the discover surfaces follow the setting's state. Assisted-by: pi * ui : give the discover search room for its focus ring The sticky search bar insets from the panel edges so the focus ring stays inside the scroll container, and rows scroll behind an opaque bar instead of through it. Assisted-by: pi * common: settle sidecar names on the listing and drop unfinished partials An infix or trailing sidecar token is ambiguous: draft heads use it (Model-MTP-BF16.gguf), so do full models with embedded heads (Model-Q8D-MTP.gguf). Such a name is a sidecar only next to token-free main weights of the same family, while mmproj or imatrix anywhere in the name, or a leading token, decides alone (mmproj_f16.gguf). A quant tag always carries its bit count, so a sidecar named after no quant (mtp-Model-it.gguf) gets the bare tag: the listing, the plan and remove all use it. An imatrix tag resolves into a plan slot of its own, downloaded but never wired into the params. remove resolves the tag the way a download does and deletes the partial files of every planned blob, so cancelling a running download or dropping one left unfinished by an earlier run leaves nothing on disk. The router passes its token and releases its lock around the call, and records the unfinished downloads with an atomic write. llama download succeeds on a sidecar-only plan and prints its paths. * ui : move dialog views and draft states into enums and constants The models dialog's views become the ModelsDialogView enum, the draft subline's states join ModelRowDownloadState, the view fade and the copy-feedback reset move to ui constants, and the dialog's compass marks come from DISCOVER_ICON. Assisted-by: pi * ui : drop the unused discover surface The dead openDiscoverModels twin, the commented-out command divider, the details pane's unused onClose and its header's close button, and a redundant type cast leave; the command's copy feedback joins the shared reset constant. Assisted-by: pi * ui : trim the download option comments and highlight the chat template The comment verbosity the options panel and its chips carried shrinks to the non-obvious why, and the chat template dialog renders through SyntaxHighlightedCode as jinja instead of a plain pre block. Assisted-by: pi * common: only a .gguf file is a sidecar, and mmproj/imatrix as whole words A non-GGUF file named like a sidecar (quantization-mmproj-Q8_0.json) is picked as the mmproj, and a sidecar word inside a longer word of a model name (Phi-2-OmniMatrix.i1-Q4_K_M.gguf) drops the model. * ui: dedupe draft sidecar badges per quant The draft sidecar badges deduped by kind and repo without the quant, so with a draft cached at two quants only one badge survived, and a delete of the other main quant found no badge to remove its sidecar with. Also restores the getDownloadEntries doc lost when the clusters method landed above it. Assisted-by: pi (zai-org/GLM-5.3-Flash) * ui: adopt the incomplete download status and drop the paused-ids registry The store inferred an unfinished download from a downloading entry it held no local state for, which misread a download running in another tab as paused and offered a resume the server rejects. The listing carries the "incomplete" status now, so the adoption keys off it directly. The paused ids no longer need to survive in localStorage: the server remembers them across restarts, and the progress snapshot was stale after a reload anyway. The stale localStorage key is removed. Assisted-by: pi (zai-org/GLM-5.3-Flash) * ui: quant-match the pause and cancel cascades Pause, resume and cancel cascaded to every draft sidecar of the repo while delete only took the quant-matched ones, so canceling a Q4_0 download also killed a Q8_0 draft that belongs to another quant's main. Every cascade acts on the quant-matched drafts now, matching what a delete takes with it. Assisted-by: pi (zai-org/GLM-5.3-Flash) * ui: collapse the duplicated draft subline markup The downloaded and in-flight branches repeated the identical connector, label and badge markup; one block with a conditional tail keeps the two states in sync. Assisted-by: pi (zai-org/GLM-5.3-Flash) * ui: scrub positioned styles out of sanitized model card html The style attribute stays allowed for the KaTeX output, which let a third party card draw a fixed overlay over the app. The sanitize hook drops position:fixed and position:absolute values and leaves the KaTeX layout styles alone. Assisted-by: pi (zai-org/GLM-5.3-Flash) * ui: run the discover mount fetch once and reuse the shared repoOf helper fetch() toggles its loading flag synchronously inside the mount effect, which re-ran the fetch on every toggle and retried failed catalog repos endlessly; the effect now untracks the call. The local repoOf copy and the duplicate services import give way to the shared helpers. Assisted-by: pi (zai-org/GLM-5.3-Flash) * ui: narrow the spec-type map to the draft sidecars SPEC_TYPE carried empty entries for the aux sidecars it is never indexed with; the map and the command component now type the badge as a draft sidecar through a draftBadgeOf helper instead of a cast. Assisted-by: pi (zai-org/GLM-5.3-Flash) * ui: name the decision-model tags and markdown cache keys The decision-model hub tag and pipeline tag move into the huggingface constants next to the other pipeline tags, and the markdown transform cache prefixes get names instead of bare one-letter tokens. Assisted-by: pi (zai-org/GLM-5.3-Flash) * ui: read the discover setting through one truthiness check Three places read the setting with !== false and one with Boolean(); all four read it the same way now. Assisted-by: pi (zai-org/GLM-5.3-Flash) * ui: compare the models dialog view against its enum The template compared the view state against the 'manage' literal in three spots while the script used the enum. Assisted-by: pi (zai-org/GLM-5.3-Flash) * nit: Remove `+` before draft sidecar badge * server: list unfinished downloads as incomplete A download that never finished was reported as "downloading", the same status a live download carries, so a client could not tell a running download from a dead one. The tags remembered in unfinished-downloads now list under a dedicated "incomplete" status, without an instance. Assisted-by: pi (zai-org/GLM-5.3-Flash) * server: download a model with its sidecars as -hf does Resolution goes back to master. POST /models takes the sidecar kinds to pull with the model (mmproj by default) and the download child gets them as -hf flags, so the server picks the files. /models lists real models only, each with the sidecars it has on disk, and incomplete downloads keep their sidecars across restarts. A stopped download keeps its partial files for the next attempt, and DELETE purges them through the same plan the download follows. * ui: download sidecars as parameters of their model The download options keep their chips, but a chip no longer posts a sidecar tag: the store maps it to its model and the sidecar kinds to pull, the quant chip takes its companion draft in the same download, and draft badges come from the sidecars each cached model lists. The server adds the sidecars a cached model lacks when it is posted again, and DELETE removes a single sidecar of a cached model. * ui : mirror the server's sidecar lookup in draft badges The server resolves a sidecar by the case-sensitive `<kind>-` token in the same directory as the weights. The UI matched the token loosely, so a badge could offer a draft the server never pulls. Assisted-by: DeepSeek-V4.1-Flash * ui : post the model's sidecar kinds with its download The store keeps one download per model: the chip's kinds ride along with the model's own sidecars, and a download row no longer expands into one row per sidecar. Draft badges carry no quant of their own yet, so a sidecar of a cached model is never labelled with the model's quant. Assisted-by: DeepSeek-V4.1-Flash * ui : join a quant's draft into its download chip One chip per quant, its draft badged inside it and its size covering both files. The draft is the file the server would pick: the same directory, the exact quant, else the closest bit depth, so a 4-bit model can pair with a 16-bit head. Assisted-by: DeepSeek-V4.1-Flash * ui : drop the draft download rows from the manager and selector A download is one row per model again: the drafts ride with it, and the rows read the sidecars on disk off the listing instead of pairing sidecar entries with their model. Pause, resume and cancel act on the model's own download. Assisted-by: DeepSeek-V4.1-Flash * ui : keep the discover download list one row per model The list leads with one row per downloading model, its sidecars as sub-lines. A row badges only the draft kinds the server can resolve, so a badge never outlives the file its lookup would pick. Assisted-by: DeepSeek-V4.1-Flash * ui : keep the discover chips readable in dark mode The metadata chips, the chat template button and the View on Hugging Face link carried a faint border and no surface over the dialog background, so they were near invisible. They take the tertiary button surface now, and their icons size with size-* so the variant's base rule leaves them alone. Assisted-by: DeepSeek-V4.1-Flash * ui : carry the draft through the download options One chip per main quant, its draft badged on it and the chip's downloaded state covering both files. The drafts sit behind an opt-in checkbox with an inline type pick, and each row opens its own command: it pins the row's quant and only names the draft type, so -hfd is gone. Assisted-by: DeepSeek-V4.1-Flash * ui : line the details skeleton up with the loaded pane Same root padding and container context as the loaded view, so the container queries resolve alike. The download options card pads its blocks instead of itself, the readme gets its card, and a description line stands in for the catalog paragraph. Assisted-by: DeepSeek-V4.1-Flash * common : drop the partials of a removed sidecar Removing a sidecar alone left the .downloadInProgress files of the sidecars a download had planned in blobs/. The removal purges them through the same plan the download follows, and the test covers a finished draft beside an unfinished one, whose partial goes while the model keeps its files. Assisted-by: DeepSeek-V4.1-Flash * server : name why a download is refused A model that is already downloading, or that is running, answered "already exists". It now says which: "is already downloading", or "is running, unload it first", both before the resumable check. The router test covers the two. Assisted-by: DeepSeek-V4.1-Flash * ui : post a download once per click Two quick clicks on the same chip posted twice, and the second answer was "already exists". The store tracks the model as pending, returns early while it is pending or in flight, and reads it as in flight before the server answers, so the chip shows the download at once. The new client test pins the flows: one POST per double click, the chip reading downloaded once the list reports the model, a resume keeping its sidecars, a cancel dropping the whole download, and a reload restoring the paused state. Assisted-by: DeepSeek-V4.1-Flash * ui : keep a plain download row the height of the others The sub-lines wrapper of a download row was rendered even with nothing in it, so its padding made the row taller than a plain one and the overlaid progress bar sat in that empty strip. It renders only with sub-lines now, in the selector and in the discover list, and the selector row takes the phone padding its neighbours have. Assisted-by: DeepSeek-V4.1-Flash * ui : badge a repo's draft once The args of a loaded model and the listing of a cached one report the same draft in different words, one with the file's quant and one with the kind alone, so the merge kept both and a repo row showed the same kind twice. The badges merge per kind now, the args one winning as it knows the quant, and the test pins both sources of a real repo. Assisted-by: DeepSeek-V4.1-Flash * server : report the sidecars of a preset or a download on its way A model the preset also names is registered as a preset, so its drafts were never reported and no row could badge them. A download on its way reported none either, though the record of the kinds it pulls is right there: those win over the plan now, the plan resolves through the preset's hf-repo, and a models dir stays out of it. Assisted-by: DeepSeek-V4.1-Flash * ui : badge a download's drafts and open its model from the row A draft still coming down badges the model id now, the way a landed one does, while an on-disk draft keeps its checked sub-line. The row itself opens the model, the status slot beside it pauses or resumes, and the manager resolves a focus on a download row so its panel opens. Assisted-by: DeepSeek-V4.1-Flash * common : report a removed sidecar that only had a partial Removing a sidecar alone dropped the result of the partial purge, so a sidecar that only ever landed as a .downloadInProgress blob answered false and the UI had no reason to refetch. The purge result is folded into the return now, and the test covers a partial-only removal. Assisted-by: DeepSeek-V4.1-Flash * ui : ignore a pause while the download POST is still on its way The chip reads as downloading before the server answers, so a quick second click unloaded a model that had not started and left the store neither in flight nor paused. A pause returns early while the POST is pending, before it touches the feed or the paused marker. The client test holds the POST open and pins it. Assisted-by: DeepSeek-V4.1-Flash * ui : offer a missing draft as its own chip A model already on disk without its draft had no way to add it: the quant chip carried the draft, so it read as a download of both. The quant chip keeps to its own state now, its joined draft shows only while it rides along or is on disk, and a downloaded quant missing its draft gets a chip of its own - badge, size, download - that posts the model with that kind, so the server keeps the files already there and pulls the draft. Assisted-by: DeepSeek-V4.1-Flash * server : list a paused sidecar download by the files on disk A model on disk whose sidecar download stops keeps its unfinished record, and the listing reported the kinds that record plans as if they were on disk: the UI checked a draft that never landed and offered no way to add it. The planned kinds now stand in only while the download runs. * chore: Clean up comments * server : list sidecars only for a model that resolves from a HF repo A preset on a local file has no HF repo, yet the listing resolved its name as one: the plan threw on the invalid repo format and GET /models failed for every model. The sidecars now come from the repo a preset names or a cached model is listed under, and a local file lists without them. * common : fix download cancel regression Signed-off-by: Adrien Gallouët <angt@huggingface.co> * common : preserve model partials when removing sidecars Signed-off-by: Adrien Gallouët <angt@huggingface.co> * server : one sidecar table and an unfinished downloads class Address review from @ggerganov. The sidecar kinds live in one table in common/download, mapping each name to its download option and plan file; the server validates, builds its options and lists cached sidecars through it instead of its own lists. The unfinished downloads move into server_unfinished_downloads, a record with its own mutex and a struct per entry, and server_models exposes them through unfinished_downloads_get and unfinished_downloads_remove. * common: one sidecar table, and removals that keep shared files The resolution, the cached listing and the removal follow a single table of sidecar kinds, so a kind is declared once with its file keyword, whether it is a draft, its option and its plan file. The server reads the same table for the kinds it accepts, the default sidecars and the child flags. Removing a tag keeps the files another cached tag still loads, such as an mmproj named after the removed quant. A sidecar removed on its own goes for every tag that resolves it. An unfinished download records the exact paths of its partials when it starts. A removal deletes those without asking the hub, and keeps a partial another recorded download also writes. The record is taken once the child is spawned, and the files go under the models lock, so a download of the same tag cannot start while they are removed. * server: drop stale partials and refuse a tag that resolves nothing Recording a download again deletes the partials of the earlier record that the new plan no longer writes, such as those of an older hub revision or of a sidecar the new request leaves out. A posted tag that resolves no file of the repo is refused before the download starts. Removing a model follows a single path, whether the reload erased its entry during the wait or not. * server: fix the CI race between the router download tests The pytest workers run in parallel on one shared cache, so a download test could find the file another test had just fetched and see no progress, or start from the unfinished downloads record another test wrote. Each download test now runs on an empty cache of its own. * server: refuse a DELETE of an unknown name and decide downloads in load() A DELETE of a name that is neither a model nor a recorded download went to common_download_remove, and without a tag it wiped the whole repo cache, including the files of a loaded quant or of a download on its way. Such a name is now not found, as it was before. A POST that downloads over an existing entry checked its state outside the lock, so a load of the same model in between got its instance killed. The checks now run in load(), under the lock, right before the spawn. The fake load time of the tests applies to model loads only, since a download worker loads none. * server: add incomplete_sidecars to the /models API A sidecar added to a cached model and then paused kept its record, but the listing showed only the complete model, so a fresh UI lost the pause. The model entry now carries incomplete_sidecars, the kinds its unfinished download has not written yet; a POST with the kind resumes one and a DELETE with it drops its partial. * common : drop a kept partial when a resume gets 416 Now that partials survive between attempts, an old or complete partial makes every retry fail with the same 416. Delete it so the next attempt can start over from zero. Signed-off-by: Adrien Gallouët <angt@huggingface.co> * common: drop a kept partial when a resume gets 200 A server that ignores the range answers 200 to a resume, and the kept partial then fails every attempt the same way a 416 does. Drop it as well, so the next attempt starts over without a range. * server: refuse a download that shares a partial with one on its way Two downloads of quants that share a file, the mmproj or an mtp picked by the closest bits, appended to the same partial and renamed a file with interleaved bytes. A download whose plan writes a partial that a running download also writes is now refused until that one is done. * common : check the saved etag before resuming a partial download Before resuming, compare the etag saved with the partial to the one the server now serves. If it is missing or different, start over, so a resumed download never mixes bytes from two versions of the file. Signed-off-by: Adrien Gallouët <angt@huggingface.co> --------- Signed-off-by: Adrien Gallouët <angt@huggingface.co> Co-authored-by: Pascal <admin@serveurperso.com> Co-authored-by: Adrien Gallouët <angt@huggingface.co>
1174 lines
39 KiB
C++
1174 lines
39 KiB
C++
#include "arg.h"
|
|
|
|
#include "build-info.h"
|
|
#include "common.h"
|
|
#include "log.h"
|
|
#include "download.h"
|
|
#include "hf-cache.h"
|
|
#include "json.h"
|
|
|
|
#include <algorithm>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <future>
|
|
#include <map>
|
|
#include <mutex>
|
|
#include <regex>
|
|
#include <unordered_set>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <vector>
|
|
|
|
#include "http.h"
|
|
|
|
#ifndef __EMSCRIPTEN__
|
|
#ifdef __linux__
|
|
#include <linux/limits.h>
|
|
#elif defined(_WIN32)
|
|
# if !defined(PATH_MAX)
|
|
# define PATH_MAX MAX_PATH
|
|
# endif
|
|
#elif defined(_AIX)
|
|
#include <sys/limits.h>
|
|
#else
|
|
#include <sys/syslimits.h>
|
|
#endif
|
|
#endif
|
|
|
|
//
|
|
// downloader
|
|
//
|
|
|
|
// the suffix of the file a download writes until it completes
|
|
static const std::string PARTIAL_SUFFIX = ".downloadInProgress";
|
|
|
|
static void write_etag(const std::string & path, const std::string & etag) {
|
|
const std::string etag_path = path + ".etag";
|
|
fs_write_atomic(std::filesystem::u8path(etag_path), etag);
|
|
LOG_DBG("%s: file etag saved: %s\n", __func__, etag_path.c_str());
|
|
}
|
|
|
|
static std::string read_etag(const std::string & path) {
|
|
const std::string etag_path = path + ".etag";
|
|
if (!std::filesystem::exists(etag_path)) {
|
|
return {};
|
|
}
|
|
std::ifstream etag_in(etag_path);
|
|
if (!etag_in) {
|
|
LOG_ERR("%s: could not open .etag file for reading: %s\n", __func__, etag_path.c_str());
|
|
return {};
|
|
}
|
|
std::string etag;
|
|
std::getline(etag_in, etag);
|
|
return etag;
|
|
}
|
|
|
|
static bool is_http_status_ok(int status) {
|
|
return status >= 200 && status < 400;
|
|
}
|
|
|
|
std::pair<std::string, std::string> common_download_split_repo_tag(const std::string & hf_repo_with_tag) {
|
|
auto parts = string_split<std::string>(hf_repo_with_tag, ':');
|
|
std::string tag = parts.size() > 1 ? parts.back() : "";
|
|
std::string hf_repo = parts[0];
|
|
if (string_split<std::string>(hf_repo, '/').size() != 2) {
|
|
throw std::invalid_argument("error: invalid HF repo format, expected <user>/<model>[:quant]\n");
|
|
}
|
|
return {hf_repo, tag};
|
|
}
|
|
|
|
class ProgressBar : public common_download_callback {
|
|
static inline std::mutex mutex;
|
|
static inline std::map<const ProgressBar *, int> lines;
|
|
static inline int max_line = 0;
|
|
|
|
std::string filename;
|
|
size_t len = 0;
|
|
|
|
static void cleanup(const ProgressBar * line) {
|
|
lines.erase(line);
|
|
if (lines.empty()) {
|
|
max_line = 0;
|
|
}
|
|
}
|
|
|
|
static bool is_output_a_tty() {
|
|
return common_is_tty(stdout);
|
|
}
|
|
|
|
public:
|
|
ProgressBar() = default;
|
|
|
|
void on_start(const common_download_progress & p) override {
|
|
filename = p.url;
|
|
|
|
if (auto pos = filename.rfind('/'); pos != std::string::npos) {
|
|
filename = filename.substr(pos + 1);
|
|
}
|
|
if (auto pos = filename.find('?'); pos != std::string::npos) {
|
|
filename = filename.substr(0, pos);
|
|
}
|
|
for (size_t i = 0; i < filename.size(); ++i) {
|
|
if ((filename[i] & 0xC0) != 0x80) {
|
|
if (len++ == 39) {
|
|
filename.resize(i);
|
|
filename += "…";
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void on_done(const common_download_progress &, bool) override {
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
cleanup(this);
|
|
}
|
|
|
|
void on_update(const common_download_progress & p) override {
|
|
if (!p.total || !is_output_a_tty()) {
|
|
return;
|
|
}
|
|
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
|
|
if (lines.find(this) == lines.end()) {
|
|
lines[this] = max_line++;
|
|
std::cout << "\n";
|
|
}
|
|
int lines_up = max_line - lines[this];
|
|
|
|
size_t bar = (55 - len) * 2;
|
|
size_t pct = (100 * p.downloaded) / p.total;
|
|
size_t pos = (bar * p.downloaded) / p.total;
|
|
|
|
if (lines_up > 0) {
|
|
std::cout << "\033[" << lines_up << "A";
|
|
}
|
|
std::cout << '\r' << "Downloading " << filename << " ";
|
|
|
|
for (size_t i = 0; i < bar; i += 2) {
|
|
std::cout << (i + 1 < pos ? "─" : (i < pos ? "╴" : " "));
|
|
}
|
|
std::cout << std::setw(4) << pct << "%\033[K";
|
|
|
|
if (lines_up > 0) {
|
|
std::cout << "\033[" << lines_up << "B";
|
|
}
|
|
std::cout << '\r' << std::flush;
|
|
|
|
if (p.downloaded == p.total) {
|
|
cleanup(this);
|
|
}
|
|
}
|
|
|
|
ProgressBar(const ProgressBar &) = delete;
|
|
ProgressBar & operator=(const ProgressBar &) = delete;
|
|
};
|
|
|
|
static bool common_pull_file(httplib::Client & cli,
|
|
const std::string & resolve_path,
|
|
const std::string & path_tmp,
|
|
bool supports_ranges,
|
|
common_download_progress & p,
|
|
common_download_callback * callback) {
|
|
std::ofstream ofs(path_tmp, std::ios::binary | std::ios::app);
|
|
if (!ofs.is_open()) {
|
|
LOG_ERR("%s: error opening local file for writing: %s\n", __func__, path_tmp.c_str());
|
|
return false;
|
|
}
|
|
|
|
httplib::Headers headers;
|
|
if (supports_ranges && p.downloaded > 0) {
|
|
headers.emplace("Range", "bytes=" + std::to_string(p.downloaded) + "-");
|
|
}
|
|
|
|
const char * func = __func__; // avoid __func__ inside a lambda
|
|
size_t progress_step = 0;
|
|
|
|
auto res = cli.Get(resolve_path, headers,
|
|
[&](const httplib::Response &response) {
|
|
if (p.downloaded > 0 && response.status != 206) {
|
|
LOG_WRN("%s: server did not respond with 206 Partial Content for a resume request. Status: %d\n", func, response.status);
|
|
if (response.status == 416 || response.status == 200) {
|
|
// the kept partial cannot be resumed: 416 for a complete or stale one,
|
|
// 200 from a server that ignores the range; drop it, so the next
|
|
// attempt starts over instead of wedging on the same answer
|
|
ofs.close();
|
|
remove(path_tmp.c_str());
|
|
}
|
|
return false;
|
|
}
|
|
if (p.downloaded == 0 && response.status != 200) {
|
|
LOG_WRN("%s: download received non-successful status code: %d\n", func, response.status);
|
|
return false;
|
|
}
|
|
if (p.total == 0 && response.has_header("Content-Length")) {
|
|
try {
|
|
size_t content_length = std::stoull(response.get_header_value("Content-Length"));
|
|
p.total = p.downloaded + content_length;
|
|
} catch (const std::exception &e) {
|
|
LOG_WRN("%s: invalid Content-Length header: %s\n", func, e.what());
|
|
}
|
|
}
|
|
return true;
|
|
},
|
|
[&](const char *data, size_t len) {
|
|
ofs.write(data, len);
|
|
if (!ofs) {
|
|
LOG_ERR("%s: error writing to file: %s\n", func, path_tmp.c_str());
|
|
return false;
|
|
}
|
|
p.downloaded += len;
|
|
progress_step += len;
|
|
|
|
if (progress_step >= p.total / 1000 || p.downloaded == p.total) {
|
|
if (callback) {
|
|
callback->on_update(p);
|
|
if (callback->is_cancelled() && (p.total == 0 || p.downloaded != p.total)) {
|
|
return false;
|
|
}
|
|
}
|
|
progress_step = 0;
|
|
}
|
|
return true;
|
|
},
|
|
nullptr
|
|
);
|
|
|
|
if (!res) {
|
|
LOG_ERR("%s: download failed: %s (status: %d)\n",
|
|
__func__,
|
|
httplib::to_string(res.error()).c_str(),
|
|
res ? res->status : -1);
|
|
return false;
|
|
}
|
|
|
|
ofs.close();
|
|
if (!ofs) {
|
|
LOG_ERR("%s: error closing file: %s\n", __func__, path_tmp.c_str());
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// download one single file from remote URL to local path
|
|
// returns status code or -1 on error
|
|
static int common_download_file_single_online(const std::string & url,
|
|
const std::string & path,
|
|
const common_download_opts & opts,
|
|
bool skip_etag) {
|
|
static const int max_attempts = 3;
|
|
static const int retry_delay_seconds = 2;
|
|
|
|
const bool file_exists = std::filesystem::exists(std::filesystem::u8path(path));
|
|
|
|
if (file_exists && skip_etag) {
|
|
LOG_DBG("%s: using cached file: %s\n", __func__, path.c_str());
|
|
return 304; // 304 Not Modified - fake cached response
|
|
}
|
|
|
|
auto [cli, parts] = common_http_client(url);
|
|
|
|
httplib::Headers headers;
|
|
for (const auto & h : opts.headers) {
|
|
headers.emplace(h.first, h.second);
|
|
}
|
|
if (headers.find("User-Agent") == headers.end()) {
|
|
headers.emplace("User-Agent", "llama-cpp/" + std::string(llama_build_info()));
|
|
}
|
|
if (!opts.bearer_token.empty()) {
|
|
headers.emplace("Authorization", "Bearer " + opts.bearer_token);
|
|
}
|
|
cli.set_default_headers(headers);
|
|
|
|
std::string last_etag;
|
|
if (file_exists) {
|
|
last_etag = read_etag(path);
|
|
} else {
|
|
LOG_DBG("%s: no previous model file found %s\n", __func__, path.c_str());
|
|
}
|
|
|
|
auto head = cli.Head(parts.path);
|
|
if (!head || head->status < 200 || head->status >= 300) {
|
|
LOG_TRC("%s: HEAD failed, status: %d\n", __func__, head ? head->status : -1);
|
|
if (file_exists) {
|
|
LOG_TRC("%s: using cached file (HEAD failed): %s\n", __func__, path.c_str());
|
|
return 304; // 304 Not Modified - fake cached response
|
|
}
|
|
return head ? head->status : -1;
|
|
}
|
|
|
|
std::string etag;
|
|
if (head->has_header("ETag")) {
|
|
etag = head->get_header_value("ETag");
|
|
}
|
|
|
|
common_download_progress p;
|
|
p.url = url;
|
|
if (head->has_header("Content-Length")) {
|
|
try {
|
|
p.total = std::stoull(head->get_header_value("Content-Length"));
|
|
} catch (const std::exception& e) {
|
|
LOG_WRN("%s: invalid Content-Length in HEAD response: %s\n", __func__, e.what());
|
|
}
|
|
}
|
|
|
|
bool supports_ranges = false;
|
|
if (head->has_header("Accept-Ranges")) {
|
|
supports_ranges = head->get_header_value("Accept-Ranges") != "none";
|
|
}
|
|
|
|
if (file_exists) {
|
|
if (etag.empty()) {
|
|
LOG_DBG("%s: using cached file (no server etag): %s\n", __func__, path.c_str());
|
|
return 304; // 304 Not Modified - fake cached response
|
|
}
|
|
if (!last_etag.empty() && last_etag == etag) {
|
|
LOG_DBG("%s: using cached file (same etag): %s\n", __func__, path.c_str());
|
|
return 304; // 304 Not Modified - fake cached response
|
|
}
|
|
// pass this point, the file exists but is different from the server version, so we need to redownload it
|
|
if (remove(path.c_str()) != 0) {
|
|
LOG_ERR("%s: unable to delete file: %s\n", __func__, path.c_str());
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
{ // silent
|
|
std::error_code ec;
|
|
std::filesystem::create_directories(std::filesystem::path(path).parent_path(), ec);
|
|
}
|
|
|
|
bool success = false;
|
|
const std::string path_temporary = path + PARTIAL_SUFFIX;
|
|
int delay = retry_delay_seconds;
|
|
|
|
if (!skip_etag && !etag.empty()) {
|
|
if (std::filesystem::exists(path_temporary) && read_etag(path_temporary) != etag) {
|
|
LOG_WRN("%s: partial file does not match the server etag, discarding it: %s\n", __func__, path_temporary.c_str());
|
|
if (remove(path_temporary.c_str()) != 0) {
|
|
LOG_ERR("%s: unable to delete file: %s\n", __func__, path_temporary.c_str());
|
|
return -1;
|
|
}
|
|
}
|
|
write_etag(path_temporary, etag);
|
|
}
|
|
|
|
if (opts.callback) {
|
|
// report the resume offset, so a resumed file does not start at 0
|
|
p.downloaded = supports_ranges && std::filesystem::exists(path_temporary) ?
|
|
std::filesystem::file_size(path_temporary) : 0;
|
|
opts.callback->on_start(p);
|
|
}
|
|
|
|
for (int i = 0; i < max_attempts; ++i) {
|
|
if (opts.callback && opts.callback->is_cancelled()) {
|
|
break;
|
|
}
|
|
if (i) {
|
|
LOG_WRN("%s: retrying after %d seconds...\n", __func__, delay);
|
|
std::this_thread::sleep_for(std::chrono::seconds(delay));
|
|
delay *= retry_delay_seconds;
|
|
}
|
|
|
|
size_t existing_size = 0;
|
|
|
|
if (std::filesystem::exists(path_temporary)) {
|
|
if (supports_ranges) {
|
|
existing_size = std::filesystem::file_size(path_temporary);
|
|
} else if (remove(path_temporary.c_str()) != 0) {
|
|
LOG_ERR("%s: unable to delete file: %s\n", __func__, path_temporary.c_str());
|
|
break;
|
|
}
|
|
}
|
|
|
|
p.downloaded = existing_size;
|
|
|
|
LOG_DBG("%s: downloading from %s to %s (etag:%s)...\n",
|
|
__func__, common_http_show_masked_url(parts).c_str(),
|
|
path_temporary.c_str(), etag.c_str());
|
|
|
|
if (common_pull_file(cli, parts.path, path_temporary, supports_ranges, p, opts.callback)) {
|
|
if (std::rename(path_temporary.c_str(), path.c_str()) != 0) {
|
|
LOG_ERR("%s: unable to rename file: %s to %s\n", __func__, path_temporary.c_str(), path.c_str());
|
|
break;
|
|
}
|
|
if (!etag.empty() && !skip_etag) {
|
|
write_etag(path, etag);
|
|
remove((path_temporary + ".etag").c_str());
|
|
}
|
|
success = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (opts.callback) {
|
|
opts.callback->on_done(p, success);
|
|
}
|
|
// a stopped download keeps path_temporary, so the next attempt resumes from it
|
|
if (!success) {
|
|
LOG_ERR("%s: download failed after %d attempts\n", __func__, max_attempts);
|
|
return -1; // max attempts reached
|
|
}
|
|
|
|
return head->status;
|
|
}
|
|
|
|
std::pair<long, std::vector<char>> common_remote_get_content(const std::string & url,
|
|
const common_remote_params & params) {
|
|
auto [cli, parts] = common_http_client(url);
|
|
|
|
httplib::Headers headers;
|
|
for (const auto & h : params.headers) {
|
|
headers.emplace(h.first, h.second);
|
|
}
|
|
if (headers.find("User-Agent") == headers.end()) {
|
|
headers.emplace("User-Agent", "llama-cpp/" + std::string(llama_build_info()));
|
|
}
|
|
|
|
if (params.timeout > 0) {
|
|
cli.set_read_timeout(params.timeout, 0);
|
|
cli.set_write_timeout(params.timeout, 0);
|
|
}
|
|
|
|
std::vector<char> buf;
|
|
auto res = cli.Get(parts.path, headers,
|
|
[&](const char *data, size_t len) {
|
|
buf.insert(buf.end(), data, data + len);
|
|
return params.max_size == 0 ||
|
|
buf.size() <= static_cast<size_t>(params.max_size);
|
|
},
|
|
nullptr
|
|
);
|
|
|
|
if (!res) {
|
|
throw std::runtime_error("error: cannot make GET request");
|
|
}
|
|
|
|
return { res->status, std::move(buf) };
|
|
}
|
|
|
|
int common_download_file_single(const std::string & url,
|
|
const std::string & path,
|
|
const common_download_opts & opts,
|
|
bool skip_etag) {
|
|
if (!opts.offline) {
|
|
ProgressBar tty_cb;
|
|
common_download_opts online_opts = opts;
|
|
if (!online_opts.callback) {
|
|
online_opts.callback = &tty_cb;
|
|
}
|
|
return common_download_file_single_online(url, path, online_opts, skip_etag);
|
|
}
|
|
|
|
if (!std::filesystem::exists(std::filesystem::u8path(path))) {
|
|
LOG_ERR("%s: required file is not available in cache (offline mode): %s\n", __func__, path.c_str());
|
|
return -1;
|
|
}
|
|
|
|
LOG_DBG("%s: using cached file (offline mode): %s\n", __func__, path.c_str());
|
|
|
|
// notify the callback that the file was cached
|
|
if (opts.callback) {
|
|
common_download_progress p;
|
|
p.url = url;
|
|
p.cached = true;
|
|
opts.callback->on_start(p);
|
|
opts.callback->on_done(p, true);
|
|
}
|
|
|
|
return 304; // Not Modified - fake cached response
|
|
}
|
|
|
|
struct gguf_split_info {
|
|
std::string prefix; // tag included
|
|
std::string tag;
|
|
int index;
|
|
int count;
|
|
};
|
|
|
|
static gguf_split_info get_gguf_split_info(const std::string & path) {
|
|
static const std::regex re_split("^(.+)-([0-9]{5})-of-([0-9]{5})$", std::regex::icase);
|
|
static const std::regex re_tag("[-.]([A-Z0-9_]+)$", std::regex::icase);
|
|
std::smatch m;
|
|
|
|
std::string prefix = path;
|
|
if (!string_remove_suffix(prefix, ".gguf")) {
|
|
return {};
|
|
}
|
|
|
|
int index = 1;
|
|
int count = 1;
|
|
|
|
if (std::regex_match(prefix, m, re_split)) {
|
|
index = std::stoi(m[2].str());
|
|
count = std::stoi(m[3].str());
|
|
prefix = m[1].str();
|
|
}
|
|
|
|
std::string tag;
|
|
if (std::regex_search(prefix, m, re_tag)) {
|
|
tag = m[1].str();
|
|
for (char & c : tag) {
|
|
c = std::toupper((unsigned char)c);
|
|
}
|
|
}
|
|
|
|
return {std::move(prefix), std::move(tag), index, count};
|
|
}
|
|
|
|
// 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);
|
|
|
|
auto pos = split.tag.find_first_of("0123456789");
|
|
if (pos == std::string::npos) {
|
|
return 0;
|
|
}
|
|
|
|
return std::stoi(split.tag.substr(pos));
|
|
}
|
|
|
|
static hf_cache::hf_files get_split_files(const hf_cache::hf_files & files,
|
|
const hf_cache::hf_file & file) {
|
|
auto split = get_gguf_split_info(file.path);
|
|
|
|
if (split.count <= 1) {
|
|
return {file};
|
|
}
|
|
hf_cache::hf_files result;
|
|
|
|
for (const auto & f : files) {
|
|
auto split_f = get_gguf_split_info(f.path);
|
|
if (split_f.count == split.count && split_f.prefix == split.prefix) {
|
|
result.push_back(f);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// pick the best sibling GGUF whose filename contains `keyword` (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
|
|
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 & tag = "") {
|
|
hf_cache::hf_file best;
|
|
size_t best_depth = 0;
|
|
int best_diff = 0;
|
|
bool best_exact = false;
|
|
bool found = false;
|
|
|
|
std::string tag_upper = tag;
|
|
for (char & c : tag_upper) {
|
|
c = (char) std::toupper((unsigned char) c);
|
|
}
|
|
|
|
int model_bits = 0;
|
|
if (!tag_upper.empty()) {
|
|
auto pos = tag_upper.find_first_of("0123456789");
|
|
model_bits = pos == std::string::npos ? 0 : std::stoi(tag_upper.substr(pos));
|
|
} else {
|
|
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) {
|
|
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 = 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;
|
|
|
|
if (!found || depth > best_depth ||
|
|
(depth == best_depth && exact && !best_exact) ||
|
|
(depth == best_depth && exact == best_exact && diff < best_diff)) {
|
|
best = f;
|
|
best_depth = depth;
|
|
best_diff = diff;
|
|
best_exact = exact;
|
|
found = true;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
static const std::vector<common_download_sidecar> sidecars = {
|
|
{ "mmproj", "mmproj", false, &common_download_opts::download_mmproj, &common_download_hf_plan::mmproj },
|
|
{ "mtp", "mtp-", true, &common_download_opts::download_mtp, &common_download_hf_plan::mtp },
|
|
{ "dflash", "dflash-", true, &common_download_opts::download_dflash, &common_download_hf_plan::dflash },
|
|
{ "eagle3", "eagle3-", true, &common_download_opts::download_eagle3, &common_download_hf_plan::eagle3 },
|
|
{ "dspark", "dspark-", true, &common_download_opts::download_dspark, &common_download_hf_plan::dspark },
|
|
};
|
|
|
|
const std::vector<common_download_sidecar> & common_download_sidecars() {
|
|
return sidecars;
|
|
}
|
|
|
|
bool common_download_opts_add_sidecar(common_download_opts & opts, const std::string & kind) {
|
|
for (const auto & s : sidecars) {
|
|
if (kind == s.name) {
|
|
opts.*s.opt = true;
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
std::vector<std::string> common_download_plan_sidecars(const common_download_hf_plan & plan) {
|
|
std::vector<std::string> out;
|
|
for (const auto & s : sidecars) {
|
|
if (!(plan.*s.file).path.empty()) {
|
|
out.emplace_back(s.name);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
std::vector<common_download_partial> common_download_plan_partials(const common_download_hf_plan & plan) {
|
|
std::vector<common_download_partial> out;
|
|
|
|
// a file in the cache resolves to its snapshot entry, a file still to download to its blob
|
|
auto add = [&](const std::string & kind, const hf_cache::hf_file & f) {
|
|
if (!f.local_path.empty() && f.local_path != f.final_path) {
|
|
out.push_back({ kind, f.local_path + PARTIAL_SUFFIX });
|
|
}
|
|
};
|
|
|
|
for (const auto & f : plan.model_files) {
|
|
add("", f);
|
|
}
|
|
for (const auto & s : sidecars) {
|
|
add(s.name, plan.*s.file);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// true when `name` carries the keyword of a sidecar kind
|
|
static bool name_is_sidecar(const std::string & name) {
|
|
for (const auto & s : sidecars) {
|
|
if (name.find(s.keyword) != std::string::npos) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
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("imatrix") == std::string::npos && !name_is_sidecar(filename);
|
|
}
|
|
|
|
static hf_cache::hf_file find_best_model(const hf_cache::hf_files & files,
|
|
const std::string & tag) {
|
|
std::vector<std::string> tags;
|
|
|
|
if (!tag.empty()) {
|
|
tags.push_back(tag);
|
|
} else {
|
|
tags = {"Q4_K_M", "Q8_0"};
|
|
}
|
|
|
|
for (const auto & t : tags) {
|
|
std::regex pattern(t + "[.-]", std::regex::icase);
|
|
for (const auto & f : files) {
|
|
if (gguf_filename_is_model(f.path) &&
|
|
std::regex_search(f.path, pattern)) {
|
|
auto split = get_gguf_split_info(f.path);
|
|
if (split.count > 1 && split.index != 1) {
|
|
continue;
|
|
}
|
|
return f;
|
|
}
|
|
}
|
|
}
|
|
|
|
// fallback to first available model only if tag is empty
|
|
if (tag.empty()) {
|
|
for (const auto & f : files) {
|
|
if (gguf_filename_is_model(f.path)) {
|
|
auto split = get_gguf_split_info(f.path);
|
|
if (split.count > 1 && split.index != 1) {
|
|
continue;
|
|
}
|
|
return f;
|
|
}
|
|
}
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
static void list_available_gguf_files(const hf_cache::hf_files & files) {
|
|
LOG_INF("Available GGUF files:\n");
|
|
for (const auto & f : files) {
|
|
if (string_ends_with(f.path, ".gguf")) {
|
|
LOG_INF(" - %s\n", f.path.c_str());
|
|
}
|
|
}
|
|
}
|
|
|
|
common_download_hf_plan common_download_get_hf_plan(const common_params_model & model, const common_download_opts & opts) {
|
|
common_download_hf_plan plan;
|
|
hf_cache::hf_files all;
|
|
|
|
auto [repo, tag] = common_download_split_repo_tag(model.hf_repo);
|
|
|
|
if (!opts.offline) {
|
|
all = hf_cache::get_repo_files(repo, opts.bearer_token);
|
|
}
|
|
if (all.empty()) {
|
|
all = hf_cache::get_cached_files(repo);
|
|
}
|
|
if (all.empty()) {
|
|
return plan;
|
|
}
|
|
|
|
// if preset.ini exists in the repo root, download only that file
|
|
for (const auto & f : all) {
|
|
if (f.path == "preset.ini") {
|
|
plan.preset = f;
|
|
return plan;
|
|
}
|
|
}
|
|
|
|
hf_cache::hf_file primary;
|
|
|
|
if (!model.hf_file.empty()) {
|
|
for (const auto & f : all) {
|
|
if (f.path == model.hf_file) {
|
|
primary = f;
|
|
break;
|
|
}
|
|
}
|
|
if (primary.path.empty()) {
|
|
LOG_ERR("%s: file '%s' not found in repository\n", __func__, model.hf_file.c_str());
|
|
list_available_gguf_files(all);
|
|
return plan;
|
|
}
|
|
} else {
|
|
primary = find_best_model(all, tag);
|
|
// a requested draft can resolve on its own, without a full model of the same tag
|
|
const bool want_draft = std::any_of(sidecars.begin(), sidecars.end(), [&](const common_download_sidecar & s) {
|
|
return s.draft && opts.*s.opt;
|
|
});
|
|
if (primary.path.empty() && !want_draft) {
|
|
LOG_ERR("%s: no GGUF files found in repository %s\n", __func__, repo.c_str());
|
|
list_available_gguf_files(all);
|
|
return plan;
|
|
}
|
|
}
|
|
|
|
if (!primary.path.empty()) {
|
|
plan.primary = primary;
|
|
plan.model_files = get_split_files(all, primary);
|
|
}
|
|
|
|
// a draft resolves at the tag, the mmproj pairs with the model
|
|
for (const auto & s : sidecars) {
|
|
if (!(opts.*s.opt)) {
|
|
continue;
|
|
}
|
|
if (s.draft) {
|
|
plan.*s.file = find_best_sibling(all, primary.path, s.keyword, tag);
|
|
} else if (!primary.path.empty()) {
|
|
plan.*s.file = find_best_sibling(all, primary.path, s.keyword);
|
|
}
|
|
}
|
|
|
|
const bool has_draft = std::any_of(sidecars.begin(), sidecars.end(), [&](const common_download_sidecar & s) {
|
|
return s.draft && !(plan.*s.file).local_path.empty();
|
|
});
|
|
if (primary.path.empty() && !has_draft) {
|
|
LOG_ERR("%s: no GGUF files found in repository %s\n", __func__, repo.c_str());
|
|
list_available_gguf_files(all);
|
|
}
|
|
|
|
return plan;
|
|
}
|
|
|
|
void common_download_run_tasks(const std::vector<common_download_task> & tasks) {
|
|
std::vector<std::future<int>> futures;
|
|
for (const auto & task : tasks) {
|
|
futures.push_back(std::async(std::launch::async,
|
|
[&task]() {
|
|
return common_download_file_single(task.url, task.local_path, task.opts, task.is_hf);
|
|
}
|
|
));
|
|
}
|
|
|
|
for (size_t i = 0; i < futures.size(); ++i) {
|
|
std::string url = tasks[i].url;
|
|
int status = futures[i].get();
|
|
bool is_ok = is_http_status_ok(status);
|
|
if (!is_ok) {
|
|
throw std::runtime_error(string_format("Download '%s' failed with status code: %d", url.c_str(), status));
|
|
}
|
|
}
|
|
}
|
|
|
|
std::vector<std::string> common_download_get_all_parts(const std::string & url) {
|
|
auto split = get_gguf_split_info(url);
|
|
|
|
if (split.count <= 1) {
|
|
return {url};
|
|
}
|
|
|
|
std::vector<std::string> parts;
|
|
for (int i = 1; i <= split.count; i++) {
|
|
auto suffix = string_format("-%05d-of-%05d.gguf", i, split.count);
|
|
parts.push_back(split.prefix + suffix);
|
|
}
|
|
return parts;
|
|
}
|
|
|
|
//
|
|
// Docker registry functions
|
|
//
|
|
|
|
static std::string common_docker_get_token(const std::string & repo) {
|
|
std::string url = "https://auth.docker.io/token?service=registry.docker.io&scope=repository:" + repo + ":pull";
|
|
|
|
common_remote_params params;
|
|
auto res = common_remote_get_content(url, params);
|
|
|
|
if (res.first != 200) {
|
|
throw std::runtime_error("Failed to get Docker registry token, HTTP code: " + std::to_string(res.first));
|
|
}
|
|
|
|
std::string response_str(res.second.begin(), res.second.end());
|
|
common_json response = common_json::parse(response_str);
|
|
|
|
if (!response.contains("token")) {
|
|
throw std::runtime_error("Docker registry token response missing 'token' field");
|
|
}
|
|
|
|
return response["token"].get<std::string>();
|
|
}
|
|
|
|
std::string common_docker_resolve_model(const std::string & docker) {
|
|
// Parse ai/smollm2:135M-Q4_0
|
|
size_t colon_pos = docker.find(':');
|
|
std::string repo, tag;
|
|
if (colon_pos != std::string::npos) {
|
|
repo = docker.substr(0, colon_pos);
|
|
tag = docker.substr(colon_pos + 1);
|
|
} else {
|
|
repo = docker;
|
|
tag = "latest";
|
|
}
|
|
|
|
// ai/ is the default
|
|
size_t slash_pos = docker.find('/');
|
|
if (slash_pos == std::string::npos) {
|
|
repo.insert(0, "ai/");
|
|
}
|
|
|
|
LOG_INF("%s: Downloading Docker Model: %s:%s\n", __func__, repo.c_str(), tag.c_str());
|
|
try {
|
|
// --- helper: digest validation ---
|
|
auto validate_oci_digest = [](const std::string & digest) -> std::string {
|
|
// Expected: algo:hex ; start with sha256 (64 hex chars)
|
|
// You can extend this map if supporting other algorithms in future.
|
|
static const std::regex re("^sha256:([a-fA-F0-9]{64})$");
|
|
std::smatch m;
|
|
if (!std::regex_match(digest, m, re)) {
|
|
throw std::runtime_error("Invalid OCI digest format received in manifest: " + digest);
|
|
}
|
|
// normalize hex to lowercase
|
|
std::string normalized = digest;
|
|
std::transform(normalized.begin()+7, normalized.end(), normalized.begin()+7, [](unsigned char c){
|
|
return std::tolower(c);
|
|
});
|
|
return normalized;
|
|
};
|
|
|
|
std::string token = common_docker_get_token(repo); // Get authentication token
|
|
|
|
// Get manifest
|
|
// TODO: cache the manifest response so that it appears in the model list
|
|
const std::string url_prefix = "https://registry-1.docker.io/v2/" + repo;
|
|
std::string manifest_url = url_prefix + "/manifests/" + tag;
|
|
common_remote_params manifest_params;
|
|
manifest_params.headers.push_back({"Authorization", "Bearer " + token});
|
|
manifest_params.headers.push_back({"Accept",
|
|
"application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json"
|
|
});
|
|
auto manifest_res = common_remote_get_content(manifest_url, manifest_params);
|
|
if (manifest_res.first != 200) {
|
|
throw std::runtime_error("Failed to get Docker manifest, HTTP code: " + std::to_string(manifest_res.first));
|
|
}
|
|
|
|
std::string manifest_str(manifest_res.second.begin(), manifest_res.second.end());
|
|
common_json manifest = common_json::parse(manifest_str);
|
|
std::string gguf_digest; // Find the GGUF layer
|
|
if (manifest.contains("layers")) {
|
|
for (const auto & layer : manifest["layers"]) {
|
|
if (layer.contains("mediaType")) {
|
|
std::string media_type = layer["mediaType"].get<std::string>();
|
|
if (media_type == "application/vnd.docker.ai.gguf.v3" ||
|
|
media_type.find("gguf") != std::string::npos) {
|
|
gguf_digest = layer["digest"].get<std::string>();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (gguf_digest.empty()) {
|
|
throw std::runtime_error("No GGUF layer found in Docker manifest");
|
|
}
|
|
|
|
// Validate & normalize digest
|
|
gguf_digest = validate_oci_digest(gguf_digest);
|
|
LOG_DBG("%s: Using validated digest: %s\n", __func__, gguf_digest.c_str());
|
|
|
|
// Prepare local filename
|
|
std::string model_filename = repo;
|
|
std::replace(model_filename.begin(), model_filename.end(), '/', '_');
|
|
model_filename += "_" + tag + ".gguf";
|
|
std::string local_path = fs_path_to_utf8(fs_get_cache_file(model_filename));
|
|
|
|
const std::string blob_url = url_prefix + "/blobs/" + gguf_digest;
|
|
common_download_opts opts;
|
|
opts.bearer_token = token;
|
|
const int http_status = common_download_file_single(blob_url, local_path, opts);
|
|
if (!is_http_status_ok(http_status)) {
|
|
throw std::runtime_error("Failed to download Docker Model");
|
|
}
|
|
|
|
LOG_INF("%s: Downloaded Docker Model to: %s\n", __func__, local_path.c_str());
|
|
return local_path;
|
|
} catch (const std::exception & e) {
|
|
LOG_ERR("%s: Docker Model download failed: %s\n", __func__, e.what());
|
|
throw;
|
|
}
|
|
}
|
|
|
|
// a cached file that lists a model under its tag: the first split of a model, not a sidecar
|
|
static bool is_listed_model(const gguf_split_info & split) {
|
|
return split.index == 1 && !split.tag.empty() && !name_is_sidecar(split.prefix);
|
|
}
|
|
|
|
std::vector<common_cached_model_info> common_list_cached_models() {
|
|
std::unordered_set<std::string> seen;
|
|
std::vector<common_cached_model_info> result;
|
|
|
|
auto files = hf_cache::get_cached_files();
|
|
|
|
for (const auto & f : files) {
|
|
auto split = get_gguf_split_info(f.path);
|
|
if (!is_listed_model(split)) {
|
|
continue;
|
|
}
|
|
if (seen.insert(f.repo_id + ":" + split.tag).second) {
|
|
result.push_back({f.repo_id, split.tag});
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
std::string common_download_resolve_path(const std::string & hf_repo_with_tag, const std::string & hf_file) {
|
|
auto [repo, tag] = common_download_split_repo_tag(hf_repo_with_tag);
|
|
|
|
auto files = hf_cache::get_cached_files(repo);
|
|
if (files.empty()) {
|
|
return "";
|
|
}
|
|
|
|
if (!hf_file.empty()) {
|
|
for (const auto & f : files) {
|
|
if (f.path == hf_file) {
|
|
return f.local_path;
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
return find_best_model(files, tag).local_path;
|
|
}
|
|
|
|
// the files -hf loads from the cache, the model with every sidecar, for each cached
|
|
// tag of `repo_id` other than `tag`
|
|
static std::unordered_set<std::string> files_in_use(const std::string & repo_id, const std::string & tag) {
|
|
common_download_opts opts;
|
|
opts.offline = true;
|
|
for (const auto & s : sidecars) {
|
|
opts.*s.opt = true;
|
|
}
|
|
|
|
std::unordered_set<std::string> tags;
|
|
std::unordered_set<std::string> used;
|
|
|
|
for (const auto & f : hf_cache::get_cached_files(repo_id)) {
|
|
auto split = get_gguf_split_info(f.path);
|
|
if (!is_listed_model(split) || split.tag == tag || !tags.insert(split.tag).second) {
|
|
continue;
|
|
}
|
|
|
|
common_params_model model;
|
|
model.hf_repo = repo_id + ":" + split.tag;
|
|
|
|
auto plan = common_download_get_hf_plan(model, opts);
|
|
|
|
for (const auto & m : plan.model_files) {
|
|
used.insert(m.local_path);
|
|
}
|
|
for (const auto & s : sidecars) {
|
|
const auto & sf = plan.*s.file;
|
|
if (!sf.local_path.empty()) {
|
|
used.insert(sf.local_path);
|
|
}
|
|
}
|
|
}
|
|
return used;
|
|
}
|
|
|
|
static bool remove_snapshot_entries(const std::string & repo_id, const std::vector<std::filesystem::path> & to_remove);
|
|
|
|
bool common_download_remove(const std::string & hf_repo_with_tag) {
|
|
namespace fs = std::filesystem;
|
|
|
|
auto [repo_id, tag] = common_download_split_repo_tag(hf_repo_with_tag);
|
|
|
|
if (tag.empty()) {
|
|
return hf_cache::remove_cached_repo(repo_id);
|
|
}
|
|
|
|
std::string tag_upper = tag;
|
|
for (char & c : tag_upper) {
|
|
c = (char) std::toupper((unsigned char) c);
|
|
}
|
|
|
|
auto files = hf_cache::get_cached_files(repo_id);
|
|
if (files.empty()) {
|
|
return false;
|
|
}
|
|
|
|
// collect snapshot entries whose tag matches, but a file another tag loads stays
|
|
const auto used = files_in_use(repo_id, tag_upper);
|
|
|
|
std::vector<fs::path> to_remove;
|
|
for (const auto & f : files) {
|
|
auto split = get_gguf_split_info(f.path);
|
|
if (split.tag == tag_upper && used.find(f.local_path) == used.end()) {
|
|
to_remove.emplace_back(f.local_path);
|
|
}
|
|
}
|
|
|
|
if (to_remove.empty()) {
|
|
return false;
|
|
}
|
|
|
|
return remove_snapshot_entries(repo_id, to_remove);
|
|
}
|
|
|
|
// remove snapshot entries of `repo_id`, then the blobs no remaining entry references
|
|
static bool remove_snapshot_entries(const std::string & repo_id, const std::vector<std::filesystem::path> & to_remove) {
|
|
namespace fs = std::filesystem;
|
|
|
|
// resolve blob paths from symlinks before deleting snapshot entries
|
|
std::vector<fs::path> blobs_to_check;
|
|
for (const auto & p : to_remove) {
|
|
std::error_code ec;
|
|
if (fs::is_symlink(p, ec)) {
|
|
auto target = fs::read_symlink(p, ec);
|
|
if (!ec) {
|
|
blobs_to_check.push_back((p.parent_path() / target).lexically_normal());
|
|
}
|
|
}
|
|
}
|
|
|
|
// remove snapshot entries
|
|
for (const auto & p : to_remove) {
|
|
std::error_code ec;
|
|
fs::remove(p, ec);
|
|
if (ec) {
|
|
LOG_WRN("%s: failed to remove %s: %s\n", __func__, p.string().c_str(), ec.message().c_str());
|
|
}
|
|
}
|
|
|
|
if (blobs_to_check.empty()) {
|
|
return true;
|
|
}
|
|
|
|
// collect blobs still referenced by remaining snapshot entries
|
|
std::unordered_set<std::string> still_referenced;
|
|
for (const auto & f : hf_cache::get_cached_files(repo_id)) {
|
|
fs::path p(f.local_path);
|
|
std::error_code ec;
|
|
if (fs::is_symlink(p, ec)) {
|
|
auto target = fs::read_symlink(p, ec);
|
|
if (!ec) {
|
|
still_referenced.insert((p.parent_path() / target).lexically_normal().string());
|
|
}
|
|
}
|
|
}
|
|
|
|
// remove orphaned blobs
|
|
for (const auto & blob : blobs_to_check) {
|
|
if (still_referenced.find(blob.string()) == still_referenced.end()) {
|
|
std::error_code ec;
|
|
fs::remove(blob, ec);
|
|
if (ec) {
|
|
LOG_WRN("%s: failed to remove blob %s: %s\n", __func__, blob.string().c_str(), ec.message().c_str());
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
bool common_download_remove_sidecars(const std::string & hf_repo_with_tag, const common_download_opts & opts) {
|
|
common_params_model model;
|
|
model.hf_repo = hf_repo_with_tag;
|
|
|
|
common_download_opts cached = opts;
|
|
cached.offline = true;
|
|
|
|
auto plan = common_download_get_hf_plan(model, cached);
|
|
|
|
std::vector<std::filesystem::path> to_remove;
|
|
for (const auto & s : sidecars) {
|
|
const auto & f = plan.*s.file;
|
|
if (!f.local_path.empty()) {
|
|
to_remove.emplace_back(std::filesystem::u8path(f.local_path));
|
|
}
|
|
}
|
|
if (to_remove.empty()) {
|
|
return false;
|
|
}
|
|
|
|
return remove_snapshot_entries(common_download_split_repo_tag(hf_repo_with_tag).first, to_remove);
|
|
}
|