diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index e0da5cba7..1db615b83 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -74,6 +74,15 @@ target_compile_features(${TARGET} PRIVATE cxx_std_17) # vendored cpp-httplib header lives under examples/server/ (used by http.h) target_include_directories(${TARGET} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/server) +# HTTPS support for the HF download path (cpp-httplib + OpenSSL). Off by default; +# when OFF an https:// attempt prints a rebuild hint (see http.h). +option(WHISPER_OPENSSL "whisper: enable OpenSSL for HTTPS HuggingFace downloads" OFF) +if (WHISPER_OPENSSL) + find_package(OpenSSL REQUIRED) + target_compile_definitions(${TARGET} PRIVATE CPPHTTPLIB_OPENSSL_SUPPORT) + target_link_libraries(${TARGET} PRIVATE OpenSSL::SSL OpenSSL::Crypto) +endif() + target_link_libraries(${TARGET} PRIVATE whisper json_cpp ${COMMON_EXTRA_LIBS} ${CMAKE_DL_LIBS}) set_target_properties(${TARGET} PROPERTIES POSITION_INDEPENDENT_CODE ON) diff --git a/examples/common-whisper.cpp b/examples/common-whisper.cpp index eccb123f3..8a624f52a 100644 --- a/examples/common-whisper.cpp +++ b/examples/common-whisper.cpp @@ -32,6 +32,7 @@ #include #endif +#include #include #include #include @@ -245,37 +246,50 @@ bool speak_with_file(const std::string & command, const std::string & text, cons return true; } -std::string whisper_hf_resolve_model(const std::string & hf_repo, const std::string & hf_file) { - // Phase 1: cache-only resolution. Scan the on-disk HF hub cache for the repo. - const hf_cache::hf_files files = hf_cache::get_cached_files(hf_repo); - if (files.empty()) { - return ""; - } - - const hf_cache::hf_file * chosen = nullptr; - +// pick the primary file from a listing: exact hf_file match, else the first ggml-*.bin +static const hf_cache::hf_file * whisper_hf_pick_primary(const hf_cache::hf_files & files, const std::string & hf_file) { for (const auto & file : files) { if (!hf_file.empty()) { if (file.path == hf_file) { - chosen = &file; - break; + return &file; } } else { - // no explicit file: pick the first ggml-*.bin in the snapshot const std::string name = std::filesystem::path(file.path).filename().string(); if (name.rfind("ggml-", 0) == 0 && name.size() >= 4 && name.compare(name.size() - 4, 4, ".bin") == 0) { - chosen = &file; - break; + return &file; + } + } + } + return nullptr; +} + +std::string whisper_hf_resolve_model(const std::string & hf_repo, const std::string & hf_file) { + const char * token_env = std::getenv("HF_TOKEN"); + const std::string token = token_env ? token_env : ""; + + // honor an HF offline mode (huggingface_hub convention): skip the network path entirely + const char * offline_env = std::getenv("HF_HUB_OFFLINE"); + const bool offline = offline_env && *offline_env && std::string(offline_env) != "0"; + + // 1. try download first: list the repo over the network and fetch the primary file. + // get_repo_files swallows network errors into an empty result (graceful degradation). + if (!offline) { + const hf_cache::hf_files remote = hf_cache::get_repo_files(hf_repo, token); + if (const hf_cache::hf_file * primary = whisper_hf_pick_primary(remote, hf_file)) { + if (hf_cache::download_file(*primary, token)) { + return hf_cache::finalize_file(*primary); } } } - if (chosen == nullptr) { - return ""; + // 2. fall back to the on-disk HF hub cache scan (Phase 1 behavior). + const hf_cache::hf_files cached = hf_cache::get_cached_files(hf_repo); + if (const hf_cache::hf_file * primary = whisper_hf_pick_primary(cached, hf_file)) { + return hf_cache::finalize_file(*primary); } - return hf_cache::finalize_file(*chosen); + return ""; } #undef STB_VORBIS_HEADER_ONLY diff --git a/examples/hf-cache.cpp b/examples/hf-cache.cpp index eb056903a..aa61c7230 100644 --- a/examples/hf-cache.cpp +++ b/examples/hf-cache.cpp @@ -487,6 +487,128 @@ hf_files get_cached_files(const std::string & repo_id) { return files; } +bool download_file(const hf_file & file, const std::string & token) { + if (file.url.empty() || file.local_path.empty()) { + return false; + } + + std::error_code ec; + fs::path local_path(file.local_path); + + // already downloaded (blob present) -> nothing to do + if (fs::exists(local_path, ec)) { + return true; + } + + try { + if (local_path.has_parent_path()) { + fs::create_directories(local_path.parent_path(), ec); + } + + fs::path path_tmp = local_path.string() + ".tmp"; + + std::ofstream ofs(path_tmp, std::ios::binary); + if (!ofs.is_open()) { + LOG_ERR("%s: failed to open '%s' for writing\n", __func__, path_tmp.string().c_str()); + return false; + } + + httplib::Headers headers = { + {"User-Agent", "whisper-cpp/" + std::string(whisper_version())} + }; + + const bool have_auth = is_valid_hf_token(token); + if (have_auth) { + headers.emplace("Authorization", "Bearer " + token); + } else if (!token.empty()) { + LOG_WRN("%s: invalid token, authentication disabled\n", __func__); + } + + const char * func = __func__; // avoid __func__ inside a lambda + const std::string origin_host = common_http_parse_url(file.url).host; + + // cpp-httplib 0.20 mishandles cross-host redirects to signed CDN URLs + // (the presigned query string is lost, yielding a 403), so follow them + // manually here, re-issuing the request against the exact Location URL. + std::string url = file.url; + bool status_ok = false; + bool got_response = false; + + for (int redirect = 0; redirect <= 10; ++redirect) { + auto [cli, parts] = common_http_client(url); + cli.set_follow_location(false); + // the signed CDN Location already carries a fully percent-encoded + // query string; httplib's default url-encoding would re-encode '+' + // (and ',', ';', ...) inside the signature and break it, so send the + // path verbatim (matching curl) to avoid a 403 from the CDN. + cli.set_url_encode(false); + + // never forward the HF bearer token to a different (CDN) host + httplib::Headers req_headers = headers; + if (have_auth && parts.host != origin_host) { + req_headers.erase("Authorization"); + } + + std::string location; + bool is_redirect = false; + status_ok = false; + got_response = false; + + auto res = cli.Get(parts.path, req_headers, + [&](const httplib::Response & response) { + got_response = true; + if (response.status >= 300 && response.status < 400 && + response.has_header("Location")) { + location = response.get_header_value("Location"); + is_redirect = true; + return false; // stop before streaming the redirect body + } + if (response.status != 200) { + LOG_WRN("%s: download failed (%d) for %s\n", func, response.status, url.c_str()); + return false; + } + status_ok = true; + return true; + }, + [&](const char * data, size_t len) { + ofs.write(data, len); + return (bool) ofs; + }); + + if (is_redirect && !location.empty()) { + url = location; + continue; + } + + if (!got_response) { + LOG_ERR("%s: HTTP error: %s\n", __func__, httplib::to_string(res.error()).c_str()); + } + break; + } + + ofs.close(); + + if (!status_ok || ofs.fail()) { + fs::remove(path_tmp, ec); + return false; + } + + fs::rename(path_tmp, local_path, ec); + if (ec) { + LOG_ERR("%s: failed to move '%s' to '%s': %s\n", __func__, + path_tmp.string().c_str(), local_path.string().c_str(), ec.message().c_str()); + fs::remove(path_tmp, ec); + return false; + } + + return true; + + } catch (const std::exception & e) { + LOG_ERR("%s: error: %s\n", __func__, e.what()); + } + return false; +} + std::string finalize_file(const hf_file & file) { static std::atomic symlinks_disabled{false}; diff --git a/examples/hf-cache.h b/examples/hf-cache.h index 42c9c6ce3..5ceb76f66 100644 --- a/examples/hf-cache.h +++ b/examples/hf-cache.h @@ -26,6 +26,10 @@ hf_files get_repo_files( hf_files get_cached_files(const std::string & repo_id = {}); +// Download file.url -> file.local_path (blobs/), skipping if already present. +// Returns false on network failure. HTTPS requires CPPHTTPLIB_OPENSSL_SUPPORT. +bool download_file(const hf_file & file, const std::string & token); + // Create snapshot path (link or move/copy) and return it std::string finalize_file(const hf_file & file); diff --git a/tests/test-hf-resolve.sh b/tests/test-hf-resolve.sh index efc308a53..898f74995 100755 --- a/tests/test-hf-resolve.sh +++ b/tests/test-hf-resolve.sh @@ -9,17 +9,23 @@ # 2. a missing --hf-file prints the "not found in HF cache" error and exits 3 # 3. `-m ` regression: an explicit model path still works unchanged # 4. bare invocation (no -hf/-m) still uses the models/ggml-base.en.bin default +# 5. (optional) a no-OpenSSL build attempting an https resolve with an empty +# cache prints the "rebuild with -DWHISPER_OPENSSL=ON" hint and exits non-zero # -# No network access required. +# HF_HUB_OFFLINE=1 forces the resolver to skip the network path (Phase 2), so the +# warm-cache cases resolve deterministically from the seeded cache with no network. # # Usage: # ./tests/test-hf-resolve.sh +# WHISPER_CLI=build-ssl/bin/whisper-cli \ +# WHISPER_CLI_NOSSL=build-nossl/bin/whisper-cli ./tests/test-hf-resolve.sh set -u cd "$(dirname "$0")/.." -main="./build/bin/whisper-cli" +main="${WHISPER_CLI:-./build/bin/whisper-cli}" +main_nossl="${WHISPER_CLI_NOSSL:-}" sample="samples/jfk.wav" seed_model="models/for-tests-ggml-base.en.bin" repo="ggerganov/whisper.cpp" @@ -45,19 +51,19 @@ cp "$seed_model" "$snapshot_dir/$hf_file" fail=0 -# 1. cache resolution succeeds -if HF_HUB_CACHE="$tmp_cache" "$main" -hf "$repo" --hf-file "$hf_file" -f "$sample" >/tmp/hf_resolve_ok.log 2>&1; then +# 1. cache resolution succeeds (offline: network path skipped, falls back to cache) +if HF_HUB_OFFLINE=1 HF_HUB_CACHE="$tmp_cache" "$main" -hf "$repo" --hf-file "$hf_file" -f "$sample" >/tmp/hf_resolve_ok.log 2>&1; then if grep -qi "failed to open" /tmp/hf_resolve_ok.log; then printf "FAIL: -hf resolved but model failed to open\n"; fail=1 else - printf "PASS: -hf %s --hf-file %s resolved from cache (exit 0)\n" "$repo" "$hf_file" + printf "PASS: -hf %s --hf-file %s resolved from cache (offline, exit 0)\n" "$repo" "$hf_file" fi else - printf "FAIL: -hf resolution exited non-zero\n"; cat /tmp/hf_resolve_ok.log; fail=1 + printf "FAIL: -hf offline resolution exited non-zero\n"; cat /tmp/hf_resolve_ok.log; fail=1 fi # 2. missing file -> exit 3 with clear error -HF_HUB_CACHE="$tmp_cache" "$main" -hf "$repo" --hf-file ggml-missing.bin -f "$sample" >/tmp/hf_resolve_miss.log 2>&1 +HF_HUB_OFFLINE=1 HF_HUB_CACHE="$tmp_cache" "$main" -hf "$repo" --hf-file ggml-missing.bin -f "$sample" >/tmp/hf_resolve_miss.log 2>&1 rc=$? if [ "$rc" -eq 3 ] && grep -qi "not found in HF cache" /tmp/hf_resolve_miss.log; then printf "PASS: missing --hf-file reports 'not found in HF cache' and exits 3\n" @@ -80,6 +86,23 @@ else printf "FAIL: bare default no longer references models/ggml-base.en.bin\n"; cat /tmp/hf_resolve_bare.log; fail=1 fi +# 5. no-OpenSSL build: an https resolve against an empty cache prints the rebuild +# hint and exits non-zero. Only runs if a no-OpenSSL binary is provided. +if [ -n "$main_nossl" ] && [ -e "$main_nossl" ]; then + empty_cache="$(mktemp -d)" + HF_HUB_CACHE="$empty_cache" "$main_nossl" -hf "$repo" --hf-file "$hf_file" -f "$sample" >/tmp/hf_resolve_nossl.log 2>&1 + rc=$? + rm -rf "$empty_cache" + if [ "$rc" -ne 0 ] && grep -qi "rebuild with -DWHISPER_OPENSSL=ON" /tmp/hf_resolve_nossl.log; then + printf "PASS: no-OpenSSL https attempt prints rebuild hint and exits non-zero\n" + else + printf "FAIL: no-OpenSSL https attempt expected rebuild hint + non-zero exit, got exit %s\n" "$rc" + cat /tmp/hf_resolve_nossl.log; fail=1 + fi +else + printf "SKIP: no-OpenSSL rebuild-hint check (set WHISPER_CLI_NOSSL to enable)\n" +fi + if [ "$fail" -ne 0 ]; then printf "\ntest-hf-resolve: FAILED\n" exit 1