File size: 6,079 Bytes
f17edea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
#!/usr/bin/env python3
"""Pull the base Gemma model from the Hugging Face Hub into a local directory.

The base model is NOT part of the training repo — it is fetched separately
from the official ``google/gemma-4-E2B`` repository. This downloads a
self-contained snapshot (weights + tokenizer + config) into
``<repo_root>/models/<name>`` so the adapter-merge and serving steps load from
disk with no network dependency.

Why not plain ``snapshot_download``: on multi-GB shards its connection
routinely stalls part-way through — throughput drops to zero while the socket
stays open, so no timeout fires and the download hangs indefinitely. The CDN
itself is fine (raw range requests sustain ~10 MB/s). So large files are pulled
with ``curl``, which can both *detect* a stall (``--speed-limit``/
``--speed-time``) and *resume* from the partial file (``-C -``); small metadata
files still go through ``huggingface_hub``.

The model is public, so no token is normally required; set ``HF_TOKEN`` if you
hit rate limits or the repo later becomes gated.

Usage:
    python fetch_base_model.py                 # pull config.BASE_MODEL_ID
    python fetch_base_model.py --repo google/gemma-4-E2B
"""
from __future__ import annotations

import argparse
import json
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path

import config

# Files at or above this size are pulled with curl instead of huggingface_hub.
LARGE_FILE_BYTES = 200 * 1024 * 1024


def curl_download(url: str, dest: Path, token: str = "",
                  retries: int = 50, min_bytes_per_s: int = 65536,
                  stall_seconds: int = 20) -> bool:
    """Download ``url`` -> ``dest`` with resume and stall detection.

    ``--speed-limit``/``--speed-time`` make curl abort when throughput stays
    below ``min_bytes_per_s`` for ``stall_seconds`` — turning the silent hang
    into a non-zero exit that the retry loop resumes from with ``-C -``.
    """
    dest.parent.mkdir(parents=True, exist_ok=True)
    for attempt in range(1, retries + 1):
        cmd = [
            "curl", "-L", "--fail", "-C", "-",
            "--speed-limit", str(min_bytes_per_s),
            "--speed-time", str(stall_seconds),
            "--retry", "5", "--retry-delay", "3", "--retry-all-errors",
            "-o", str(dest), url,
        ]
        if token:
            cmd[1:1] = ["-H", f"Authorization: Bearer {token}"]
        have = dest.stat().st_size if dest.exists() else 0
        print(f"[fetch] curl attempt {attempt}/{retries} "
              f"(have {have/1e9:.2f} GB) {dest.name}", flush=True)
        rc = subprocess.run(cmd).returncode
        if rc == 0:
            return True
        # curl exits 33 when the server can't do a ranged resume and 416 when
        # the range is already satisfied — both mean "nothing more to fetch".
        if rc == 33 or rc == 36:
            print(f"[fetch] curl rc={rc}; treating partial as complete-check",
                  flush=True)
        time.sleep(min(15, 2 * attempt))
    return False


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--repo", default=config.BASE_MODEL_ID)
    ap.add_argument("--dest", default="", help="target dir (default models/<name>)")
    ap.add_argument("--token", default="", help="HF token (else env / anonymous)")
    ap.add_argument("--retries", type=int, default=50)
    args = ap.parse_args()

    from huggingface_hub import HfApi, hf_hub_url, snapshot_download

    token = args.token or os.environ.get("HF_TOKEN", "")
    dest = Path(args.dest) if args.dest else config.local_model_dir(args.repo)
    dest.mkdir(parents=True, exist_ok=True)
    print(f"[fetch] pulling {args.repo} -> {dest}", flush=True)
    t0 = time.time()

    api = HfApi()
    info = api.model_info(args.repo, files_metadata=True, token=token or None)
    sizes = {s.rfilename: (s.size or 0) for s in info.siblings}
    skip_ext = (".gguf", ".onnx", ".tflite", ".task")

    large = [f for f, sz in sizes.items()
             if sz >= LARGE_FILE_BYTES and not f.endswith(skip_ext)]
    small = [f for f, sz in sizes.items()
             if sz < LARGE_FILE_BYTES and not f.endswith(skip_ext)]

    # 1. small metadata files via huggingface_hub (fast, no stall risk)
    if small:
        snapshot_download(
            repo_id=args.repo, local_dir=str(dest), token=token or None,
            allow_patterns=small,
        )
        print(f"[fetch] {len(small)} metadata files ok", flush=True)

    # 2. large shards via curl (resumable, stall-detecting)
    for fname in large:
        target = dest / fname
        want = sizes[fname]
        if target.exists() and target.stat().st_size == want:
            print(f"[fetch] {fname} already complete ({want/1e9:.2f} GB)", flush=True)
            continue
        url = hf_hub_url(repo_id=args.repo, filename=fname)
        ok = curl_download(url, target, token=token, retries=args.retries)
        got = target.stat().st_size if target.exists() else 0
        if not ok or got != want:
            print(f"[fetch] FAILED {fname}: got {got} of {want} bytes",
                  file=sys.stderr)
            sys.exit(1)
        print(f"[fetch] {fname} complete ({got/1e9:.2f} GB)", flush=True)

    # Drop huggingface_hub's staging dir; the snapshot is self-contained now.
    shutil.rmtree(dest / ".cache", ignore_errors=True)

    has_config = (dest / "config.json").exists()
    weights = list(dest.glob("*.safetensors")) + list(dest.glob("*.bin"))
    print(json.dumps({
        "ok": has_config and bool(weights),
        "repo": args.repo,
        "dest": str(dest),
        "config_json": has_config,
        "weight_files": len(weights),
        "total_gb": round(sum(p.stat().st_size for p in dest.rglob("*")
                              if p.is_file()) / 1e9, 2),
        "seconds": round(time.time() - t0, 1),
    }))
    if not (has_config and weights):
        print("[fetch] WARNING: snapshot missing config.json or weights",
              file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()