char-fork/llama.cpp

git clone https://git.t4t.associates/char-fork/llama.cpp

Xuan-Son Nguyencommon: add json.h abstraction (#27511)d9f918d2d

char/aliceai-t5-moe
35.7 KiB1090 linesraw
1#include "arg.h"
2
3#include "build-info.h"
4#include "common.h"
5#include "log.h"
6#include "download.h"
7#include "hf-cache.h"
8#include "json.h"
9
10#include <algorithm>
11#include <filesystem>
12#include <fstream>
13#include <future>
14#include <map>
15#include <mutex>
16#include <regex>
17#include <unordered_set>
18#include <string>
19#include <thread>
20#include <vector>
21
22#include "http.h"
23
24#ifndef __EMSCRIPTEN__
25#ifdef __linux__
26#include <linux/limits.h>
27#elif defined(_WIN32)
28#   if !defined(PATH_MAX)
29#   define PATH_MAX MAX_PATH
30#   endif
31#elif defined(_AIX)
32#include <sys/limits.h>
33#else
34#include <sys/syslimits.h>
35#endif
36#endif
37
38// isatty
39#if defined(_WIN32)
40#include <io.h>
41#else
42#include <unistd.h>
43#endif
44
45//
46// downloader
47//
48
49// validate repo name format: owner/repo
50static void write_file(const std::string & fname, const std::string & content) {
51    const std::string fname_tmp = fname + ".tmp";
52    std::ofstream     file(fname_tmp);
53    if (!file) {
54        throw std::runtime_error(string_format("error: failed to open file '%s'\n", fname.c_str()));
55    }
56
57    try {
58        file << content;
59        file.close();
60
61        // Makes write atomic
62        if (rename(fname_tmp.c_str(), fname.c_str()) != 0) {
63            LOG_ERR("%s: unable to rename file: %s to %s\n", __func__, fname_tmp.c_str(), fname.c_str());
64            // If rename fails, try to delete the temporary file
65            if (remove(fname_tmp.c_str()) != 0) {
66                LOG_ERR("%s: unable to delete temporary file: %s\n", __func__, fname_tmp.c_str());
67            }
68        }
69    } catch (...) {
70        // If anything fails, try to delete the temporary file
71        if (remove(fname_tmp.c_str()) != 0) {
72            LOG_ERR("%s: unable to delete temporary file: %s\n", __func__, fname_tmp.c_str());
73        }
74
75        throw std::runtime_error(string_format("error: failed to write file '%s'\n", fname.c_str()));
76    }
77}
78
79static void write_etag(const std::string & path, const std::string & etag) {
80    const std::string etag_path = path + ".etag";
81    write_file(etag_path, etag);
82    LOG_DBG("%s: file etag saved: %s\n", __func__, etag_path.c_str());
83}
84
85static std::string read_etag(const std::string & path) {
86    const std::string etag_path = path + ".etag";
87    if (!std::filesystem::exists(etag_path)) {
88        return {};
89    }
90    std::ifstream etag_in(etag_path);
91    if (!etag_in) {
92        LOG_ERR("%s: could not open .etag file for reading: %s\n", __func__, etag_path.c_str());
93        return {};
94    }
95    std::string etag;
96    std::getline(etag_in, etag);
97    return etag;
98}
99
100static bool is_http_status_ok(int status) {
101    return status >= 200 && status < 400;
102}
103
104std::pair<std::string, std::string> common_download_split_repo_tag(const std::string & hf_repo_with_tag) {
105    auto parts = string_split<std::string>(hf_repo_with_tag, ':');
106    std::string tag = parts.size() > 1 ? parts.back() : "";
107    std::string hf_repo = parts[0];
108    if (string_split<std::string>(hf_repo, '/').size() != 2) {
109        throw std::invalid_argument("error: invalid HF repo format, expected <user>/<model>[:quant]\n");
110    }
111    return {hf_repo, tag};
112}
113
114class ProgressBar : public common_download_callback {
115    static inline std::mutex mutex;
116    static inline std::map<const ProgressBar *, int> lines;
117    static inline int max_line = 0;
118
119    std::string filename;
120    size_t len = 0;
121
122    static void cleanup(const ProgressBar * line) {
123        lines.erase(line);
124        if (lines.empty()) {
125            max_line = 0;
126        }
127    }
128
129    static bool is_output_a_tty() {
130#if defined(_WIN32)
131        return _isatty(_fileno(stdout));
132#else
133        return isatty(1);
134#endif
135    }
136
137public:
138    ProgressBar() = default;
139
140    void on_start(const common_download_progress & p) override {
141        filename = p.url;
142
143        if (auto pos = filename.rfind('/'); pos != std::string::npos) {
144            filename = filename.substr(pos + 1);
145        }
146        if (auto pos = filename.find('?'); pos != std::string::npos) {
147            filename = filename.substr(0, pos);
148        }
149        for (size_t i = 0; i < filename.size(); ++i) {
150            if ((filename[i] & 0xC0) != 0x80) {
151                if (len++ == 39) {
152                    filename.resize(i);
153                    filename += "…";
154                    break;
155                }
156            }
157        }
158    }
159
160    void on_done(const common_download_progress &, bool) override {
161        std::lock_guard<std::mutex> lock(mutex);
162        cleanup(this);
163    }
164
165    void on_update(const common_download_progress & p) override {
166        if (!p.total || !is_output_a_tty()) {
167            return;
168        }
169
170        std::lock_guard<std::mutex> lock(mutex);
171
172        if (lines.find(this) == lines.end()) {
173            lines[this] = max_line++;
174            std::cout << "\n";
175        }
176        int lines_up = max_line - lines[this];
177
178        size_t bar = (55 - len) * 2;
179        size_t pct = (100 * p.downloaded) / p.total;
180        size_t pos = (bar * p.downloaded) / p.total;
181
182        if (lines_up > 0) {
183            std::cout << "\033[" << lines_up << "A";
184        }
185        std::cout << '\r' << "Downloading " << filename << " ";
186
187        for (size_t i = 0; i < bar; i += 2) {
188            std::cout << (i + 1 < pos ? "─" : (i < pos ? "╴" : " "));
189        }
190        std::cout << std::setw(4) << pct << "%\033[K";
191
192        if (lines_up > 0) {
193            std::cout << "\033[" << lines_up << "B";
194        }
195        std::cout << '\r' << std::flush;
196
197        if (p.downloaded == p.total) {
198            cleanup(this);
199        }
200    }
201
202    ProgressBar(const ProgressBar &) = delete;
203    ProgressBar & operator=(const ProgressBar &) = delete;
204};
205
206static bool common_pull_file(httplib::Client & cli,
207                             const std::string & resolve_path,
208                             const std::string & path_tmp,
209                             bool supports_ranges,
210                             common_download_progress & p,
211                             common_download_callback * callback) {
212    std::ofstream ofs(path_tmp, std::ios::binary | std::ios::app);
213    if (!ofs.is_open()) {
214        LOG_ERR("%s: error opening local file for writing: %s\n", __func__, path_tmp.c_str());
215        return false;
216    }
217
218    httplib::Headers headers;
219    if (supports_ranges && p.downloaded > 0) {
220        headers.emplace("Range", "bytes=" + std::to_string(p.downloaded) + "-");
221    }
222
223    const char * func = __func__; // avoid __func__ inside a lambda
224    size_t progress_step = 0;
225
226    auto res = cli.Get(resolve_path, headers,
227        [&](const httplib::Response &response) {
228            if (p.downloaded > 0 && response.status != 206) {
229                LOG_WRN("%s: server did not respond with 206 Partial Content for a resume request. Status: %d\n", func, response.status);
230                return false;
231            }
232            if (p.downloaded == 0 && response.status != 200) {
233                LOG_WRN("%s: download received non-successful status code: %d\n", func, response.status);
234                return false;
235            }
236            if (p.total == 0 && response.has_header("Content-Length")) {
237                try {
238                    size_t content_length = std::stoull(response.get_header_value("Content-Length"));
239                    p.total = p.downloaded + content_length;
240                } catch (const std::exception &e) {
241                    LOG_WRN("%s: invalid Content-Length header: %s\n", func, e.what());
242                }
243            }
244            return true;
245        },
246        [&](const char *data, size_t len) {
247            ofs.write(data, len);
248            if (!ofs) {
249                LOG_ERR("%s: error writing to file: %s\n", func, path_tmp.c_str());
250                return false;
251            }
252            p.downloaded += len;
253            progress_step += len;
254
255            if (progress_step >= p.total / 1000 || p.downloaded == p.total) {
256                if (callback) {
257                    callback->on_update(p);
258                    if (callback->is_cancelled()) {
259                        return false;
260                    }
261                }
262                progress_step = 0;
263            }
264            return true;
265        },
266        nullptr
267    );
268
269    if (!res) {
270        LOG_ERR("%s: download failed: %s (status: %d)\n",
271                __func__,
272                httplib::to_string(res.error()).c_str(),
273                res ? res->status : -1);
274        return false;
275    }
276
277    return true;
278}
279
280// download one single file from remote URL to local path
281// returns status code or -1 on error
282static int common_download_file_single_online(const std::string & url,
283                                              const std::string & path,
284                                              const common_download_opts & opts,
285                                              bool skip_etag) {
286    static const int max_attempts        = 3;
287    static const int retry_delay_seconds = 2;
288
289    const bool file_exists = std::filesystem::exists(path);
290
291    if (file_exists && skip_etag) {
292        LOG_DBG("%s: using cached file: %s\n", __func__, path.c_str());
293        return 304; // 304 Not Modified - fake cached response
294    }
295
296    auto [cli, parts] = common_http_client(url);
297
298    httplib::Headers headers;
299    for (const auto & h : opts.headers) {
300        headers.emplace(h.first, h.second);
301    }
302    if (headers.find("User-Agent") == headers.end()) {
303        headers.emplace("User-Agent", "llama-cpp/" + std::string(llama_build_info()));
304    }
305    if (!opts.bearer_token.empty()) {
306        headers.emplace("Authorization", "Bearer " + opts.bearer_token);
307    }
308    cli.set_default_headers(headers);
309
310    std::string last_etag;
311    if (file_exists) {
312        last_etag = read_etag(path);
313    } else {
314        LOG_DBG("%s: no previous model file found %s\n", __func__, path.c_str());
315    }
316
317    auto head = cli.Head(parts.path);
318    if (!head || head->status < 200 || head->status >= 300) {
319        LOG_TRC("%s: HEAD failed, status: %d\n", __func__, head ? head->status : -1);
320        if (file_exists) {
321            LOG_TRC("%s: using cached file (HEAD failed): %s\n", __func__, path.c_str());
322            return 304; // 304 Not Modified - fake cached response
323        }
324        return head ? head->status : -1;
325    }
326
327    std::string etag;
328    if (head->has_header("ETag")) {
329        etag = head->get_header_value("ETag");
330    }
331
332    common_download_progress p;
333    p.url = url;
334    if (head->has_header("Content-Length")) {
335        try {
336            p.total = std::stoull(head->get_header_value("Content-Length"));
337        } catch (const std::exception& e) {
338            LOG_WRN("%s: invalid Content-Length in HEAD response: %s\n", __func__, e.what());
339        }
340    }
341
342    bool supports_ranges = false;
343    if (head->has_header("Accept-Ranges")) {
344        supports_ranges = head->get_header_value("Accept-Ranges") != "none";
345    }
346
347    if (file_exists) {
348        if (etag.empty()) {
349            LOG_DBG("%s: using cached file (no server etag): %s\n", __func__, path.c_str());
350            return 304; // 304 Not Modified - fake cached response
351        }
352        if (!last_etag.empty() && last_etag == etag) {
353            LOG_DBG("%s: using cached file (same etag): %s\n", __func__, path.c_str());
354            return 304; // 304 Not Modified - fake cached response
355        }
356        // pass this point, the file exists but is different from the server version, so we need to redownload it
357        if (remove(path.c_str()) != 0) {
358            LOG_ERR("%s: unable to delete file: %s\n", __func__, path.c_str());
359            return -1;
360        }
361    }
362
363    { // silent
364        std::error_code ec;
365        std::filesystem::create_directories(std::filesystem::path(path).parent_path(), ec);
366    }
367
368    bool success = false;
369    const std::string path_temporary = path + ".downloadInProgress";
370    int delay = retry_delay_seconds;
371
372    if (opts.callback) {
373        opts.callback->on_start(p);
374    }
375
376    for (int i = 0; i < max_attempts; ++i) {
377        if (opts.callback && opts.callback->is_cancelled()) {
378            break;
379        }
380        if (i) {
381            LOG_WRN("%s: retrying after %d seconds...\n", __func__, delay);
382            std::this_thread::sleep_for(std::chrono::seconds(delay));
383            delay *= retry_delay_seconds;
384        }
385
386        size_t existing_size = 0;
387
388        if (std::filesystem::exists(path_temporary)) {
389            if (supports_ranges) {
390                existing_size = std::filesystem::file_size(path_temporary);
391            } else if (remove(path_temporary.c_str()) != 0) {
392                LOG_ERR("%s: unable to delete file: %s\n", __func__, path_temporary.c_str());
393                break;
394            }
395        }
396
397        p.downloaded = existing_size;
398
399        LOG_DBG("%s: downloading from %s to %s (etag:%s)...\n",
400                __func__, common_http_show_masked_url(parts).c_str(),
401                path_temporary.c_str(), etag.c_str());
402
403        if (common_pull_file(cli, parts.path, path_temporary, supports_ranges, p, opts.callback)) {
404            if (std::rename(path_temporary.c_str(), path.c_str()) != 0) {
405                LOG_ERR("%s: unable to rename file: %s to %s\n", __func__, path_temporary.c_str(), path.c_str());
406                break;
407            }
408            if (!etag.empty() && !skip_etag) {
409                write_etag(path, etag);
410            }
411            success = true;
412            break;
413        }
414    }
415
416    if (opts.callback) {
417        opts.callback->on_done(p, success);
418    }
419    if (opts.callback && opts.callback->is_cancelled() &&
420        std::filesystem::exists(path_temporary)) {
421        if (remove(path_temporary.c_str()) != 0) {
422            LOG_ERR("%s: unable to delete temporary file: %s\n", __func__, path_temporary.c_str());
423        }
424    }
425    if (!success) {
426        LOG_ERR("%s: download failed after %d attempts\n", __func__, max_attempts);
427        return -1; // max attempts reached
428    }
429
430    return head->status;
431}
432
433std::pair<long, std::vector<char>> common_remote_get_content(const std::string          & url,
434                                                             const common_remote_params & params) {
435    auto [cli, parts] = common_http_client(url);
436
437    httplib::Headers headers;
438    for (const auto & h : params.headers) {
439        headers.emplace(h.first, h.second);
440    }
441    if (headers.find("User-Agent") == headers.end()) {
442        headers.emplace("User-Agent", "llama-cpp/" + std::string(llama_build_info()));
443    }
444
445    if (params.timeout > 0) {
446        cli.set_read_timeout(params.timeout, 0);
447        cli.set_write_timeout(params.timeout, 0);
448    }
449
450    std::vector<char> buf;
451    auto res = cli.Get(parts.path, headers,
452        [&](const char *data, size_t len) {
453            buf.insert(buf.end(), data, data + len);
454            return params.max_size == 0 ||
455                   buf.size() <= static_cast<size_t>(params.max_size);
456        },
457        nullptr
458    );
459
460    if (!res) {
461        throw std::runtime_error("error: cannot make GET request");
462    }
463
464    return { res->status, std::move(buf) };
465}
466
467int common_download_file_single(const std::string & url,
468                                const std::string & path,
469                                const common_download_opts & opts,
470                                bool skip_etag) {
471    if (!opts.offline) {
472        ProgressBar tty_cb;
473        common_download_opts online_opts = opts;
474        if (!online_opts.callback) {
475            online_opts.callback = &tty_cb;
476        }
477        return common_download_file_single_online(url, path, online_opts, skip_etag);
478    }
479
480    if (!std::filesystem::exists(path)) {
481        LOG_ERR("%s: required file is not available in cache (offline mode): %s\n", __func__, path.c_str());
482        return -1;
483    }
484
485    LOG_DBG("%s: using cached file (offline mode): %s\n", __func__, path.c_str());
486
487    // notify the callback that the file was cached
488    if (opts.callback) {
489        common_download_progress p;
490        p.url = url;
491        p.cached = true;
492        opts.callback->on_start(p);
493        opts.callback->on_done(p, true);
494    }
495
496    return 304; // Not Modified - fake cached response
497}
498
499struct gguf_split_info {
500    std::string prefix; // tag included
501    std::string tag;
502    int index;
503    int count;
504};
505
506static gguf_split_info get_gguf_split_info(const std::string & path) {
507    static const std::regex re_split("^(.+)-([0-9]{5})-of-([0-9]{5})$", std::regex::icase);
508    static const std::regex re_tag("[-.]([A-Z0-9_]+)$", std::regex::icase);
509    std::smatch m;
510
511    std::string prefix = path;
512    if (!string_remove_suffix(prefix, ".gguf")) {
513        return {};
514    }
515
516    int index = 1;
517    int count = 1;
518
519    if (std::regex_match(prefix, m, re_split)) {
520        index = std::stoi(m[2].str());
521        count = std::stoi(m[3].str());
522        prefix = m[1].str();
523    }
524
525    std::string tag;
526    if (std::regex_search(prefix, m, re_tag)) {
527        tag = m[1].str();
528        for (char & c : tag) {
529            c = std::toupper((unsigned char)c);
530        }
531    }
532
533    return {std::move(prefix), std::move(tag), index, count};
534}
535
536// Q4_0 -> 4, F16 -> 16, NVFP4 -> 4, Q8_K_M -> 8, etc
537static int extract_quant_bits(const std::string & filename) {
538    auto split = get_gguf_split_info(filename);
539
540    auto pos = split.tag.find_first_of("0123456789");
541    if (pos == std::string::npos) {
542        return 0;
543    }
544
545    return std::stoi(split.tag.substr(pos));
546}
547
548static hf_cache::hf_files get_split_files(const hf_cache::hf_files & files,
549                                          const hf_cache::hf_file  & file) {
550    auto split = get_gguf_split_info(file.path);
551
552    if (split.count <= 1) {
553        return {file};
554    }
555    hf_cache::hf_files result;
556
557    for (const auto & f : files) {
558        auto split_f = get_gguf_split_info(f.path);
559        if (split_f.count == split.count && split_f.prefix == split.prefix) {
560            result.push_back(f);
561        }
562    }
563    return result;
564}
565
566// pick the best sibling GGUF whose filename contains `keyword` (e.g. "mmproj" / "mtp"),
567// preferring deeper shared directory prefix with the model, then exact `tag` match,
568// then closest quantization to the tag when given, or to the model otherwise
569static hf_cache::hf_file find_best_sibling(const hf_cache::hf_files & files,
570                                           const std::string        & model,
571                                           const std::string        & keyword,
572                                           const std::string        & tag = "") {
573    hf_cache::hf_file best;
574    size_t best_depth = 0;
575    int best_diff = 0;
576    bool best_exact = false;
577    bool found = false;
578
579    std::string tag_upper = tag;
580    for (char & c : tag_upper) {
581        c = (char) std::toupper((unsigned char) c);
582    }
583
584    int model_bits = 0;
585    if (!tag_upper.empty()) {
586        auto pos = tag_upper.find_first_of("0123456789");
587        model_bits = pos == std::string::npos ? 0 : std::stoi(tag_upper.substr(pos));
588    } else {
589        model_bits = extract_quant_bits(model);
590    }
591    auto model_parts = string_split<std::string>(model, '/');
592    auto model_dir = model_parts.end() - 1;
593
594    for (const auto & f : files) {
595        if (!string_ends_with(f.path, ".gguf") ||
596            f.path.find(keyword) == std::string::npos) {
597            continue;
598        }
599
600        auto sib_parts = string_split<std::string>(f.path, '/');
601        auto sib_dir = sib_parts.end() - 1;
602
603        auto [_, dir] = std::mismatch(model_parts.begin(), model_dir,
604                                      sib_parts.begin(), sib_dir);
605        if (dir != sib_dir) {
606            continue;
607        }
608
609        size_t depth = dir - sib_parts.begin();
610        auto bits = extract_quant_bits(f.path);
611        auto diff = std::abs(bits - model_bits);
612
613        std::string path_upper = f.path;
614        for (char & c : path_upper) {
615            c = (char) std::toupper((unsigned char) c);
616        }
617        bool exact = !tag_upper.empty() && path_upper.find("-" + tag_upper + ".") != std::string::npos;
618
619        if (!found || depth > best_depth ||
620            (depth == best_depth && exact && !best_exact) ||
621            (depth == best_depth && exact == best_exact && diff < best_diff)) {
622            best = f;
623            best_depth = depth;
624            best_diff = diff;
625            best_exact = exact;
626            found = true;
627        }
628    }
629    return best;
630}
631
632static hf_cache::hf_file find_best_mmproj(const hf_cache::hf_files & files,
633                                          const std::string        & model) {
634    return find_best_sibling(files, model, "mmproj");
635}
636
637static hf_cache::hf_file find_best_mtp(const hf_cache::hf_files & files,
638                                       const std::string        & model,
639                                       const std::string        & tag = "") {
640    return find_best_sibling(files, model, "mtp-", tag);
641}
642
643static hf_cache::hf_file find_best_eagle3(const hf_cache::hf_files & files,
644                                          const std::string        & model,
645                                          const std::string        & tag = "") {
646    return find_best_sibling(files, model, "eagle3-", tag);
647}
648
649static hf_cache::hf_file find_best_dflash(const hf_cache::hf_files & files,
650                                          const std::string        & model,
651                                          const std::string        & tag = "") {
652    return find_best_sibling(files, model, "dflash-", tag);
653}
654
655static hf_cache::hf_file find_best_dspark(const hf_cache::hf_files & files,
656                                          const std::string        & model,
657                                          const std::string        & tag = "") {
658    return find_best_sibling(files, model, "dspark-", tag);
659}
660
661static bool gguf_filename_is_model(const std::string & filepath) {
662    if (!string_ends_with(filepath, ".gguf")) {
663        return false;
664    }
665
666    std::string filename = filepath;
667    if (auto pos = filename.rfind('/'); pos != std::string::npos) {
668        filename = filename.substr(pos + 1);
669    }
670
671    return filename.find("mmproj")  == std::string::npos &&
672           filename.find("imatrix") == std::string::npos &&
673           filename.find("mtp-")    == std::string::npos &&
674           filename.find("eagle3-") == std::string::npos &&
675           filename.find("dflash-") == std::string::npos &&
676           filename.find("dspark-") == std::string::npos;
677}
678
679static hf_cache::hf_file find_best_model(const hf_cache::hf_files & files,
680                                         const std::string        & tag) {
681    std::vector<std::string> tags;
682
683    if (!tag.empty()) {
684        tags.push_back(tag);
685    } else {
686        tags = {"Q4_K_M", "Q8_0"};
687    }
688
689    for (const auto & t : tags) {
690        std::regex pattern(t + "[.-]", std::regex::icase);
691        for (const auto & f : files) {
692            if (gguf_filename_is_model(f.path) &&
693                std::regex_search(f.path, pattern)) {
694                auto split = get_gguf_split_info(f.path);
695                if (split.count > 1 && split.index != 1) {
696                    continue;
697                }
698                return f;
699            }
700        }
701    }
702
703    // fallback to first available model only if tag is empty
704    if (tag.empty()) {
705        for (const auto & f : files) {
706            if (gguf_filename_is_model(f.path)) {
707                auto split = get_gguf_split_info(f.path);
708                if (split.count > 1 && split.index != 1) {
709                    continue;
710                }
711                return f;
712            }
713        }
714    }
715
716    return {};
717}
718
719static void list_available_gguf_files(const hf_cache::hf_files & files) {
720    LOG_INF("Available GGUF files:\n");
721    for (const auto & f : files) {
722        if (string_ends_with(f.path, ".gguf")) {
723            LOG_INF(" - %s\n", f.path.c_str());
724        }
725    }
726}
727
728common_download_hf_plan common_download_get_hf_plan(const common_params_model & model, const common_download_opts & opts) {
729    common_download_hf_plan plan;
730    hf_cache::hf_files all;
731
732    auto [repo, tag] = common_download_split_repo_tag(model.hf_repo);
733
734    if (!opts.offline) {
735        all = hf_cache::get_repo_files(repo, opts.bearer_token);
736    }
737    if (all.empty()) {
738        all = hf_cache::get_cached_files(repo);
739    }
740    if (all.empty()) {
741        return plan;
742    }
743
744    // if preset.ini exists in the repo root, download only that file
745    for (const auto & f : all) {
746        if (f.path == "preset.ini") {
747            plan.preset = f;
748            return plan;
749        }
750    }
751
752    hf_cache::hf_file primary;
753
754    if (!model.hf_file.empty()) {
755        for (const auto & f : all) {
756            if (f.path == model.hf_file) {
757                primary = f;
758                break;
759            }
760        }
761        if (primary.path.empty()) {
762            LOG_ERR("%s: file '%s' not found in repository\n", __func__, model.hf_file.c_str());
763            list_available_gguf_files(all);
764            return plan;
765        }
766    } else {
767        primary = find_best_model(all, tag);
768        // a requested sidecar can resolve on its own, without a full model of the same tag
769        if (primary.path.empty() && !opts.download_mtp && !opts.download_dflash && !opts.download_eagle3 && !opts.download_dspark) {
770            LOG_ERR("%s: no GGUF files found in repository %s\n", __func__, repo.c_str());
771            list_available_gguf_files(all);
772            return plan;
773        }
774    }
775
776    if (!primary.path.empty()) {
777        plan.primary = primary;
778        plan.model_files = get_split_files(all, primary);
779    }
780
781    if (opts.download_mmproj && !primary.path.empty()) {
782        plan.mmproj = find_best_mmproj(all, primary.path);
783    }
784    if (opts.download_mtp) {
785        plan.mtp = find_best_mtp(all, primary.path, tag);
786    }
787    if (opts.download_dflash) {
788        plan.dflash = find_best_dflash(all, primary.path, tag);
789    }
790    if (opts.download_eagle3) {
791        plan.eagle3 = find_best_eagle3(all, primary.path, tag);
792    }
793    if (opts.download_dspark) {
794        plan.dspark = find_best_dspark(all, primary.path, tag);
795    }
796
797    if (primary.path.empty() &&
798        plan.mtp.local_path.empty() && plan.dflash.local_path.empty() && plan.eagle3.local_path.empty() && plan.dspark.local_path.empty()) {
799        LOG_ERR("%s: no GGUF files found in repository %s\n", __func__, repo.c_str());
800        list_available_gguf_files(all);
801    }
802
803    return plan;
804}
805
806void common_download_run_tasks(const std::vector<common_download_task> & tasks) {
807    std::vector<std::future<int>> futures;
808    for (const auto & task : tasks) {
809        futures.push_back(std::async(std::launch::async,
810            [&task]() {
811                return common_download_file_single(task.url, task.local_path, task.opts, task.is_hf);
812            }
813        ));
814    }
815
816    for (size_t i = 0; i < futures.size(); ++i) {
817        std::string url = tasks[i].url;
818        int status = futures[i].get();
819        bool is_ok = is_http_status_ok(status);
820        if (!is_ok) {
821            throw std::runtime_error(string_format("Download '%s' failed with status code: %d", url.c_str(), status));
822        }
823    }
824}
825
826std::vector<std::string> common_download_get_all_parts(const std::string & url) {
827    auto split = get_gguf_split_info(url);
828
829    if (split.count <= 1) {
830        return {url};
831    }
832
833    std::vector<std::string> parts;
834    for (int i = 1; i <= split.count; i++) {
835        auto suffix = string_format("-%05d-of-%05d.gguf", i, split.count);
836        parts.push_back(split.prefix + suffix);
837    }
838    return parts;
839}
840
841//
842// Docker registry functions
843//
844
845static std::string common_docker_get_token(const std::string & repo) {
846    std::string url = "https://auth.docker.io/token?service=registry.docker.io&scope=repository:" + repo + ":pull";
847
848    common_remote_params params;
849    auto                 res = common_remote_get_content(url, params);
850
851    if (res.first != 200) {
852        throw std::runtime_error("Failed to get Docker registry token, HTTP code: " + std::to_string(res.first));
853    }
854
855    std::string response_str(res.second.begin(), res.second.end());
856    common_json response = common_json::parse(response_str);
857
858    if (!response.contains("token")) {
859        throw std::runtime_error("Docker registry token response missing 'token' field");
860    }
861
862    return response["token"].get<std::string>();
863}
864
865std::string common_docker_resolve_model(const std::string & docker) {
866    // Parse ai/smollm2:135M-Q4_0
867    size_t      colon_pos = docker.find(':');
868    std::string repo, tag;
869    if (colon_pos != std::string::npos) {
870        repo = docker.substr(0, colon_pos);
871        tag  = docker.substr(colon_pos + 1);
872    } else {
873        repo = docker;
874        tag  = "latest";
875    }
876
877    // ai/ is the default
878    size_t      slash_pos = docker.find('/');
879    if (slash_pos == std::string::npos) {
880        repo.insert(0, "ai/");
881    }
882
883    LOG_INF("%s: Downloading Docker Model: %s:%s\n", __func__, repo.c_str(), tag.c_str());
884    try {
885        // --- helper: digest validation ---
886        auto validate_oci_digest = [](const std::string & digest) -> std::string {
887            // Expected: algo:hex ; start with sha256 (64 hex chars)
888            // You can extend this map if supporting other algorithms in future.
889            static const std::regex re("^sha256:([a-fA-F0-9]{64})$");
890            std::smatch m;
891            if (!std::regex_match(digest, m, re)) {
892                throw std::runtime_error("Invalid OCI digest format received in manifest: " + digest);
893            }
894            // normalize hex to lowercase
895            std::string normalized = digest;
896            std::transform(normalized.begin()+7, normalized.end(), normalized.begin()+7, [](unsigned char c){
897                return std::tolower(c);
898            });
899            return normalized;
900        };
901
902        std::string token = common_docker_get_token(repo);  // Get authentication token
903
904        // Get manifest
905        // TODO: cache the manifest response so that it appears in the model list
906        const std::string    url_prefix = "https://registry-1.docker.io/v2/" + repo;
907        std::string          manifest_url = url_prefix + "/manifests/" + tag;
908        common_remote_params manifest_params;
909        manifest_params.headers.push_back({"Authorization", "Bearer " + token});
910        manifest_params.headers.push_back({"Accept",
911            "application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json"
912        });
913        auto manifest_res = common_remote_get_content(manifest_url, manifest_params);
914        if (manifest_res.first != 200) {
915            throw std::runtime_error("Failed to get Docker manifest, HTTP code: " + std::to_string(manifest_res.first));
916        }
917
918        std::string manifest_str(manifest_res.second.begin(), manifest_res.second.end());
919        common_json manifest = common_json::parse(manifest_str);
920        std::string gguf_digest;  // Find the GGUF layer
921        if (manifest.contains("layers")) {
922            for (const auto & layer : manifest["layers"]) {
923                if (layer.contains("mediaType")) {
924                    std::string media_type = layer["mediaType"].get<std::string>();
925                    if (media_type == "application/vnd.docker.ai.gguf.v3" ||
926                        media_type.find("gguf") != std::string::npos) {
927                        gguf_digest = layer["digest"].get<std::string>();
928                        break;
929                    }
930                }
931            }
932        }
933
934        if (gguf_digest.empty()) {
935            throw std::runtime_error("No GGUF layer found in Docker manifest");
936        }
937
938        // Validate & normalize digest
939        gguf_digest = validate_oci_digest(gguf_digest);
940        LOG_DBG("%s: Using validated digest: %s\n", __func__, gguf_digest.c_str());
941
942        // Prepare local filename
943        std::string model_filename = repo;
944        std::replace(model_filename.begin(), model_filename.end(), '/', '_');
945        model_filename += "_" + tag + ".gguf";
946        std::string local_path = fs_get_cache_file(model_filename);
947
948        const std::string blob_url = url_prefix + "/blobs/" + gguf_digest;
949        common_download_opts opts;
950        opts.bearer_token = token;
951        const int http_status = common_download_file_single(blob_url, local_path, opts);
952        if (!is_http_status_ok(http_status)) {
953            throw std::runtime_error("Failed to download Docker Model");
954        }
955
956        LOG_INF("%s: Downloaded Docker Model to: %s\n", __func__, local_path.c_str());
957        return local_path;
958    } catch (const std::exception & e) {
959        LOG_ERR("%s: Docker Model download failed: %s\n", __func__, e.what());
960        throw;
961    }
962}
963
964std::vector<common_cached_model_info> common_list_cached_models() {
965    std::unordered_set<std::string> seen;
966    std::vector<common_cached_model_info> result;
967
968    auto files = hf_cache::get_cached_files();
969
970    for (const auto & f : files) {
971        auto split = get_gguf_split_info(f.path);
972        if (split.index != 1 || split.tag.empty() ||
973            split.prefix.find("mmproj")  != std::string::npos ||
974            split.prefix.find("mtp-")    != std::string::npos ||
975            split.prefix.find("eagle3-") != std::string::npos ||
976            split.prefix.find("dflash-") != std::string::npos ||
977            split.prefix.find("dspark-") != std::string::npos) {
978            continue;
979        }
980        if (seen.insert(f.repo_id + ":" + split.tag).second) {
981            result.push_back({f.repo_id, split.tag});
982        }
983    }
984
985    return result;
986}
987
988std::string common_download_resolve_path(const std::string & hf_repo_with_tag, const std::string & hf_file) {
989    auto [repo, tag] = common_download_split_repo_tag(hf_repo_with_tag);
990
991    auto files = hf_cache::get_cached_files(repo);
992    if (files.empty()) {
993        return "";
994    }
995
996    if (!hf_file.empty()) {
997        for (const auto & f : files) {
998            if (f.path == hf_file) {
999                return f.local_path;
1000            }
1001        }
1002        return "";
1003    }
1004
1005    return find_best_model(files, tag).local_path;
1006}
1007
1008bool common_download_remove(const std::string & hf_repo_with_tag) {
1009    namespace fs = std::filesystem;
1010
1011    auto [repo_id, tag] = common_download_split_repo_tag(hf_repo_with_tag);
1012
1013    if (tag.empty()) {
1014        return hf_cache::remove_cached_repo(repo_id);
1015    }
1016
1017    std::string tag_upper = tag;
1018    for (char & c : tag_upper) {
1019        c = (char) std::toupper((unsigned char) c);
1020    }
1021
1022    auto files = hf_cache::get_cached_files(repo_id);
1023    if (files.empty()) {
1024        return false;
1025    }
1026
1027    // collect snapshot entries whose tag matches
1028    std::vector<fs::path> to_remove;
1029    for (const auto & f : files) {
1030        auto split = get_gguf_split_info(f.path);
1031        if (split.tag == tag_upper) {
1032            to_remove.emplace_back(f.local_path);
1033        }
1034    }
1035
1036    if (to_remove.empty()) {
1037        return false;
1038    }
1039
1040    // resolve blob paths from symlinks before deleting snapshot entries
1041    std::vector<fs::path> blobs_to_check;
1042    for (const auto & p : to_remove) {
1043        std::error_code ec;
1044        if (fs::is_symlink(p, ec)) {
1045            auto target = fs::read_symlink(p, ec);
1046            if (!ec) {
1047                blobs_to_check.push_back((p.parent_path() / target).lexically_normal());
1048            }
1049        }
1050    }
1051
1052    // remove snapshot entries
1053    for (const auto & p : to_remove) {
1054        std::error_code ec;
1055        fs::remove(p, ec);
1056        if (ec) {
1057            LOG_WRN("%s: failed to remove %s: %s\n", __func__, p.string().c_str(), ec.message().c_str());
1058        }
1059    }
1060
1061    if (blobs_to_check.empty()) {
1062        return true;
1063    }
1064
1065    // collect blobs still referenced by remaining snapshot entries
1066    std::unordered_set<std::string> still_referenced;
1067    for (const auto & f : hf_cache::get_cached_files(repo_id)) {
1068        fs::path p(f.local_path);
1069        std::error_code ec;
1070        if (fs::is_symlink(p, ec)) {
1071            auto target = fs::read_symlink(p, ec);
1072            if (!ec) {
1073                still_referenced.insert((p.parent_path() / target).lexically_normal().string());
1074            }
1075        }
1076    }
1077
1078    // remove orphaned blobs
1079    for (const auto & blob : blobs_to_check) {
1080        if (still_referenced.find(blob.string()) == still_referenced.end()) {
1081            std::error_code ec;
1082            fs::remove(blob, ec);
1083            if (ec) {
1084                LOG_WRN("%s: failed to remove blob %s: %s\n", __func__, blob.string().c_str(), ec.message().c_str());
1085            }
1086        }
1087    }
1088
1089    return true;
1090}