hf-manifest.py: read X-Linked-* headers, never follow the resolve redirect
On Xet-backed repos (Qwen/Qwen3.8-Flash-Next-FP8), the resolve endpoint 302s to a CDN bridge whose final ETag is the Xet CAS chunk-dag hash, NOT the content sha256. urllib follows redirects, so the manifest recorded the Xet hash and every downloaded shard failed sha256 verification against a hash no download could ever match (8/8 shards FAIL, all sizes correct, on-disk bytes matching the Tree API's lfs.oid). Classic LFS CDNs serve ETag=sha256, which is why this was invisible on prior fetches. Fix: HEAD via http.client, which does not follow redirects. 200 = direct answer (Content-Length + ETag, non-LFS); 302 = LFS/Xet redirect (X-Linked-Size + X-Linked-ETag = content sha256). Verified against the FP8 repo: shard model-00002-of-00131 now records 6841fe21..., the sha256 of the bytes already on disk (cross-checked against the Tree API lfs.oid).
This commit is contained in:
1 parent
f41e0384dd
commit
5bd12d3193
1 file changed
+41
-16
+41
-16
@@ -4,9 +4,15 @@
|
|||||||
Usage: python3 scripts/hf-manifest.py <owner/repo> [outfile]
|
Usage: python3 scripts/hf-manifest.py <owner/repo> [outfile]
|
||||||
|
|
||||||
Lists the repo tree via the HF API and, for each file, records the size and
|
Lists the repo tree via the HF API and, for each file, records the size and
|
||||||
the LFS sha256 (the X-Linked-ETag header on the resolve endpoint). Non-LFS
|
the content sha256. LFS and Xet-backed files redirect the resolve endpoint to
|
||||||
files (config.json, tokenizer, ...) have no LFS sha256, so they get a blank
|
a CDN (302), whose own headers carry the truth: X-Linked-Size is the byte
|
||||||
hash field — hf-fetch.sh then checks size only for them.
|
count and X-Linked-ETag is the sha256 of the file's bytes. The redirect must
|
||||||
|
NOT be followed: on Xet-backed repos the bridge answers with ETag = the Xet
|
||||||
|
CAS chunk-dag hash, which is NOT the content sha256 (seen on
|
||||||
|
Qwen/Qwen3.8-Flash-Next-FP8, 2026-09-26: every shard "failed" verification
|
||||||
|
against a hash no download could ever match). Non-LFS files (config.json,
|
||||||
|
tokenizer, ...) are answered directly (200) and carry no 64-hex etag, so they
|
||||||
|
get a blank hash field — hf-fetch.sh then checks size only for them.
|
||||||
|
|
||||||
Output: one line per file, three space-separated fields:
|
Output: one line per file, three space-separated fields:
|
||||||
<filename> <size_bytes> <sha256-or-blank>
|
<filename> <size_bytes> <sha256-or-blank>
|
||||||
@@ -14,12 +20,13 @@ Output: one line per file, three space-separated fields:
|
|||||||
Token: $HF_TOKEN, else ~/.cache/huggingface/token, else the llamaswap conf
|
Token: $HF_TOKEN, else ~/.cache/huggingface/token, else the llamaswap conf
|
||||||
(grep hf_...). Gated repos need a token that has been granted access.
|
(grep hf_...). Gated repos need a token that has been granted access.
|
||||||
"""
|
"""
|
||||||
|
import http.client
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
|
||||||
def get_token():
|
def get_token():
|
||||||
@@ -40,11 +47,21 @@ def get_token():
|
|||||||
|
|
||||||
|
|
||||||
def head(url, token):
|
def head(url, token):
|
||||||
|
"""HEAD via http.client, which does not follow redirects.
|
||||||
|
|
||||||
|
urllib's default opener follows the resolve endpoint's 302, and the Xet
|
||||||
|
bridge's ETag is the Xet CAS hash, not the content sha256 — so the 302's
|
||||||
|
own X-Linked-* headers must be read instead. Returns an http.client
|
||||||
|
response; the caller reads r.status and r.headers.
|
||||||
|
"""
|
||||||
|
parts = urlsplit(url)
|
||||||
|
conn = http.client.HTTPSConnection(parts.netloc, timeout=30)
|
||||||
|
path = parts.path + (f"?{parts.query}" if parts.query else "")
|
||||||
h = {"User-Agent": "hf-manifest/1.0"}
|
h = {"User-Agent": "hf-manifest/1.0"}
|
||||||
if token:
|
if token:
|
||||||
h["Authorization"] = f"Bearer {token}"
|
h["Authorization"] = f"Bearer {token}"
|
||||||
req = urllib.request.Request(url, method="HEAD", headers=h)
|
conn.request("HEAD", path, headers=h)
|
||||||
return urllib.request.urlopen(req, timeout=30)
|
return conn.getresponse()
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -54,8 +71,8 @@ def main():
|
|||||||
repo = sys.argv[1]
|
repo = sys.argv[1]
|
||||||
outfile = sys.argv[2] if len(sys.argv) > 2 else None
|
outfile = sys.argv[2] if len(sys.argv) > 2 else None
|
||||||
token = get_token()
|
token = get_token()
|
||||||
# 1. List the repo tree (siblings[].rfilename). Sizes and LFS sha256s
|
# 1. List the repo tree (siblings[].rfilename). Sizes and content sha256s
|
||||||
# come from the resolve endpoint's HEAD (Content-Length + X-Linked-ETag);
|
# come from the resolve endpoint's HEAD (not followed — see head());
|
||||||
# the API's siblings carry only rfilename.
|
# the API's siblings carry only rfilename.
|
||||||
api = f"https://huggingface.co/api/models/{repo}"
|
api = f"https://huggingface.co/api/models/{repo}"
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
@@ -71,22 +88,30 @@ def main():
|
|||||||
name = s.get("rfilename", "")
|
name = s.get("rfilename", "")
|
||||||
if not name or name.endswith("/"):
|
if not name or name.endswith("/"):
|
||||||
continue
|
continue
|
||||||
# 2. Size (Content-Length) + LFS sha256 (X-Linked-ETag) from the
|
# 2. Size + content sha256 from the resolve endpoint's HEAD, not
|
||||||
# resolve endpoint's HEAD. Non-LFS files have no 64-hex etag, so the
|
# followed (see head()). 200 = direct answer (Content-Length +
|
||||||
# hash field stays blank (hf-fetch.sh checks size only for them).
|
# ETag); 302 = LFS/Xet redirect (X-Linked-Size + X-Linked-ETag =
|
||||||
|
# the sha256 of the file's bytes). Non-LFS files carry no 64-hex
|
||||||
|
# etag, so the hash field stays blank (hf-fetch.sh then checks
|
||||||
|
# size only for them).
|
||||||
size = 0
|
size = 0
|
||||||
sha = ""
|
sha = ""
|
||||||
resolve = f"https://huggingface.co/{repo}/resolve/main/{name}"
|
resolve = f"https://huggingface.co/{repo}/resolve/main/{name}"
|
||||||
try:
|
try:
|
||||||
with head(resolve, token) as r:
|
with head(resolve, token) as r:
|
||||||
|
if r.status == 404:
|
||||||
|
raise FileNotFoundError(name)
|
||||||
|
if r.status == 200:
|
||||||
size = int(r.headers.get("Content-Length") or 0)
|
size = int(r.headers.get("Content-Length") or 0)
|
||||||
etag = (r.headers.get("X-Linked-ETag")
|
etag = (r.headers.get("ETag") or "").strip('"')
|
||||||
or r.headers.get("ETag") or "").strip('"')
|
else:
|
||||||
|
size = int(r.headers.get("X-Linked-Size")
|
||||||
|
or r.headers.get("Content-Length") or 0)
|
||||||
|
etag = (r.headers.get("X-Linked-ETag") or "").strip('"')
|
||||||
if re.fullmatch(r"[0-9a-f]{64}", etag):
|
if re.fullmatch(r"[0-9a-f]{64}", etag):
|
||||||
sha = etag
|
sha = etag
|
||||||
except urllib.error.HTTPError as e:
|
except FileNotFoundError:
|
||||||
if e.code != 404:
|
pass
|
||||||
raise
|
|
||||||
lines.append(f"{name} {size} {sha}")
|
lines.append(f"{name} {size} {sha}")
|
||||||
print(f" {name} {size:>12} {sha or '(size only)'}", file=sys.stderr)
|
print(f" {name} {size:>12} {sha or '(size only)'}", file=sys.stderr)
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user