The first no-redirect HEAD fix read X-Linked-Size on every 3xx, but non-LFS files on a Xet-backed repo answer 307 to HF's own /api/resolve-cache WITHOUT any X-Linked-Size header. The fallback to the stub's Content-Length recorded the redirect page size (254-264 bytes) instead of the file size, so hf-fetch.sh failed the size check on every aux file it verified against the regenerated manifest (README.md "65400 != 254", config.json "72514 != 258", 7 files on the FP8 repo). Distinguish the two redirect shapes: 3xx WITH X-Linked-Size = LFS/Xet, whose X-Linked-* headers are the truth and whose redirect must NOT be followed (the Xet bridge ETag is the CAS hash, not the sha256); 3xx WITHOUT = non-LFS, where the Location (followed once, via urljoin) answers 200 with the real Content-Length and a git-blob-sha ETag that the 64-hex gate correctly rejects (size-only check for those files). Verified against Qwen/Qwen3.8-Flash-Next-FP8: all 7 previously-failing aux files now record their on-disk byte counts exactly (README.md 65400, config.json 72514, merges.txt 3353259, ...), shard shas unchanged (model-00002 6841fe21...), total 172.82 GiB across 144 files.
141 lines
5.7 KiB
Python
Executable File
141 lines
5.7 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""hf-manifest.py — generate an hf-fetch.sh manifest from a HuggingFace repo.
|
|
|
|
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
|
|
the content sha256. LFS and Xet-backed files redirect the resolve endpoint to
|
|
a CDN (302), whose own headers carry the truth: X-Linked-Size is the byte
|
|
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:
|
|
<filename> <size_bytes> <sha256-or-blank>
|
|
|
|
Token: $HF_TOKEN, else ~/.cache/huggingface/token, else the llamaswap conf
|
|
(grep hf_...). Gated repos need a token that has been granted access.
|
|
"""
|
|
import http.client
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import urllib.request
|
|
from urllib.parse import urljoin, urlsplit
|
|
|
|
def get_token():
|
|
t = os.environ.get("HF_TOKEN", "")
|
|
if not t:
|
|
p = os.path.expanduser("~/.cache/huggingface/token")
|
|
if os.path.exists(p):
|
|
with open(p) as f:
|
|
t = f.read().strip()
|
|
if not t:
|
|
p = os.path.expanduser("~/.config/llamaswap.conf.yaml")
|
|
if os.path.exists(p):
|
|
with open(p) as f:
|
|
m = re.search(r"hf_[a-zA-Z0-9]+", f.read())
|
|
if m:
|
|
t = m.group(0)
|
|
return t
|
|
|
|
|
|
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"}
|
|
if token:
|
|
h["Authorization"] = f"Bearer {token}"
|
|
conn.request("HEAD", path, headers=h)
|
|
return conn.getresponse()
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) < 2:
|
|
print(f"usage: {sys.argv[0]} <owner/repo> [outfile]", file=sys.stderr)
|
|
sys.exit(2)
|
|
repo = sys.argv[1]
|
|
outfile = sys.argv[2] if len(sys.argv) > 2 else None
|
|
token = get_token()
|
|
# 1. List the repo tree (siblings[].rfilename). Sizes and content sha256s
|
|
# come from the resolve endpoint's HEAD (not followed — see head());
|
|
# the API's siblings carry only rfilename.
|
|
api = f"https://huggingface.co/api/models/{repo}"
|
|
req = urllib.request.Request(
|
|
api, headers={"User-Agent": "hf-manifest/1.0",
|
|
**({"Authorization": f"Bearer {token}"} if token else {})})
|
|
with urllib.request.urlopen(req, timeout=30) as r:
|
|
meta = json.load(r)
|
|
siblings = meta.get("siblings", [])
|
|
print(f"# {repo}: {len(siblings)} entries", file=sys.stderr)
|
|
|
|
lines = []
|
|
for s in siblings:
|
|
name = s.get("rfilename", "")
|
|
if not name or name.endswith("/"):
|
|
continue
|
|
# 2. Size + content sha256 from the resolve endpoint's HEAD. 200 =
|
|
# direct answer (Content-Length + ETag). 3xx WITH X-Linked-Size =
|
|
# LFS/Xet redirect: X-Linked-Size + X-Linked-ETag are the byte
|
|
# count and the sha256 of the file's bytes, and the redirect must
|
|
# NOT be followed — the Xet bridge's own ETag is the Xet CAS hash,
|
|
# not the content sha256. 3xx WITHOUT X-Linked-Size = non-LFS
|
|
# redirect (307 to HF's own /api/resolve-cache): the stub's
|
|
# Content-Length is NOT the file size, so follow ONCE; the final
|
|
# ETag there is the git blob sha (40 hex), so the 64-hex gate
|
|
# leaves the hash blank (hf-fetch.sh then checks size only).
|
|
size = 0
|
|
sha = ""
|
|
resolve = f"https://huggingface.co/{repo}/resolve/main/{name}"
|
|
try:
|
|
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)
|
|
etag = (r.headers.get("ETag") or "").strip('"')
|
|
else:
|
|
linked = r.headers.get("X-Linked-Size")
|
|
if linked is not None:
|
|
size = int(linked)
|
|
etag = (r.headers.get("X-Linked-ETag") or "").strip('"')
|
|
else:
|
|
loc = r.headers.get("Location", "")
|
|
with head(urljoin(resolve, loc), token) as r2:
|
|
if r2.status == 200:
|
|
size = int(r2.headers.get("Content-Length") or 0)
|
|
etag = (r2.headers.get("ETag") or "").strip('"')
|
|
else:
|
|
etag = ""
|
|
if re.fullmatch(r"[0-9a-f]{64}", etag):
|
|
sha = etag
|
|
except FileNotFoundError:
|
|
pass
|
|
lines.append(f"{name} {size} {sha}")
|
|
print(f" {name} {size:>12} {sha or '(size only)'}", file=sys.stderr)
|
|
|
|
out = "\n".join(lines) + "\n"
|
|
if outfile:
|
|
with open(outfile, "w") as f:
|
|
f.write(out)
|
|
print(f"wrote {outfile}", file=sys.stderr)
|
|
else:
|
|
sys.stdout.write(out)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|