examples: add HF HTTPS download with cache fall-back to whisper-cli

Enable the network half of the HF cache resolver. whisper_hf_resolve_model
now (unless HF_HUB_OFFLINE is set) lists the repo over the HF API, picks the
primary file (exact --hf-file, else first ggml-*.bin), downloads it into the
blobs/<oid> + snapshots/<commit> layout via a slim hf_cache::download_file,
and falls back to the Phase 1 on-disk cache scan on empty listing or network
failure. HF_TOKEN is honored for the Authorization: Bearer header.

HTTPS is gated behind a new WHISPER_OPENSSL CMake option (find_package OpenSSL,
CPPHTTPLIB_OPENSSL_SUPPORT, link OpenSSL::SSL/Crypto); an https attempt in a
non-SSL build prints the rebuild hint.

download_file follows redirects manually and disables httplib url-encoding:
cpp-httplib 0.20 (whisper's vendored version) both mishandles cross-host
redirects and re-encodes the already-encoded signed xet CDN URL, corrupting
the presigned query string into a 403. The bearer token is dropped on
cross-host (CDN) redirects.

tests/test-hf-resolve.sh gains an offline fall-back case (HF_HUB_OFFLINE=1),
a WHISPER_CLI override, and an optional no-OpenSSL rebuild-hint check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dominic Dones 2026-07-02 16:20:52 -04:00
parent f8500a9a12
commit 4d4aa7bc46
5 changed files with 196 additions and 24 deletions

View File

@ -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)

View File

@ -32,6 +32,7 @@
#include <io.h>
#endif
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <fstream>
@ -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

View File

@ -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<bool> symlinks_disabled{false};

View File

@ -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/<oid>), 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);

View File

@ -9,17 +9,23 @@
# 2. a missing --hf-file prints the "not found in HF cache" error and exits 3
# 3. `-m <path>` 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