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:
parent
f8500a9a12
commit
4d4aa7bc46
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue