File size: 11,519 Bytes
5cf6791
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b7c2357
5cf6791
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b7c2357
 
5cf6791
 
 
 
 
 
 
 
b7c2357
5cf6791
 
 
 
 
 
 
 
 
 
 
b7c2357
 
5cf6791
 
 
 
 
b7c2357
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5cf6791
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
"""Where moth's weights come from, and fetching them on first run of a pip install.

Three ways moth can be installed (mode()):
  hf   a Hugging Face download: model.safetensors next to code/ (code/moth/weights.py), or one whose weights
       haven't finished downloading (hf_layout; serve then says how to finish it)
  dev  this project's checkout: runs/ and data/ next to the package
  pip  `pip install moth-companion` (or uv tool / pipx): only the code is installed. The model files
       (model.safetensors, config.json, tokenizer.json) are downloaded from the Hub on first run into
       ~/.cache/moth/<repo>/  ($MOTH_CACHE, else $XDG_CACHE_HOME/moth, overrides the base folder).

$MOTH_REPO picks the Hub repo (default below), $MOTH_REVISION pins the first download to a branch, tag or commit.
After that the folder is used as it is (no network); `moth update` refreshes it in place, with a backup.
Stdlib only at import time: huggingface_hub is imported when a download is actually needed.
"""
from __future__ import annotations

import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)   # code/ (Hub download), the project (dev checkout) or site-packages (pip)
REPO_ID = "darioooooo0o/moth-112m"
FILES = ("model.safetensors", "config.json", "tokenizer.json")
OPTIONAL = ("moth.json",)   # a model's manifest (moth/backends/manifest.py), when the repo has one
DEV_MODEL = os.path.join(ROOT, "runs", "sft1", "model")
DEV_TOKENIZER = os.path.join(ROOT, "data", "tiny-agent-112m", "tokenizer.json")
REVISION_FILE = ".moth-version"   # same file and format as moth/update.py
SIZE_HINT = "about 860 MB"


# what a Hugging Face download of moth has next to code/ besides the weights: an interrupted `hf download` leaves
# these behind (huggingface_hub moves model.safetensors into place only once it is whole; the small files land first)
HF_MARKS = ("model.safetensors", REVISION_FILE, ".cache/huggingface/download/code/moth/VERSION.metadata",
            ("config.json", "tokenizer.json"))


def hf_layout(repo: str) -> bool:
    """repo is a Hugging Face download of moth: code/moth/ plus model.safetensors next to code/, or the marks of a
    download whose weights never finished (HF_MARKS; a tuple: all of those). moth/update.py's is_download is this
    test. A dev checkout or the home folder has no code/moth/."""
    if not os.path.isdir(os.path.join(repo, "code", "moth")):
        return False
    return any(all(os.path.isfile(os.path.join(repo, *f.split("/"))) for f in (m if isinstance(m, tuple) else (m,)))
               for m in HF_MARKS)


def complete(folder: str) -> bool:
    """config.json plus a model.safetensors whose size matches its header (a synced folder may be mid-copy, an
    interrupted download cut off)."""
    import struct
    st = os.path.join(folder, "model.safetensors")
    if not (os.path.isfile(os.path.join(folder, "config.json")) and os.path.isfile(st)):
        return False
    try:
        with open(st, "rb") as f:
            n = struct.unpack("<Q", f.read(8))[0]
            if not 2 <= n <= 100 << 20:   # a header, not a garbage length
                return False
            head = json.loads(f.read(n))
        end = max(v["data_offsets"][1] for k, v in head.items() if k != "__metadata__")
        return os.path.getsize(st) == 8 + n + end
    except Exception:  # noqa: BLE001  short, garbage, no tensors: not complete
        return False


def incomplete_message(folder: str) -> str:
    """What to tell a user whose Hugging Face download of moth (folder) has no usable weights yet."""
    gone = missing(folder)
    what = (f"{', '.join(gone)} missing" if gone else "model.safetensors is cut off")
    return (f"{folder} is a Hugging Face download of moth whose weights didn't finish downloading ({what}). Finish "
            f"it (hf download {repo()} --local-dir \"{folder}\" resumes it), or run `python -m moth update` from its "
            "code/ folder, which completes it in place. Nothing was downloaded anywhere else.")


def mode() -> str:
    repo_dir = os.path.dirname(ROOT)
    if os.path.exists(os.path.join(repo_dir, "model.safetensors")) or hf_layout(repo_dir):
        return "hf"   # (also a download still missing its weights: never "pip", which would fetch them elsewhere)
    if os.path.isfile(DEV_TOKENIZER):   # a positive marker: an editable install of the checkout stays dev
        return "dev"
    return "pip"


def repo() -> str:
    return os.environ.get("MOTH_REPO") or REPO_ID


def pinned_revision() -> str | None:
    return os.environ.get("MOTH_REVISION") or None


def cache_dir(repo_id: str | None = None) -> str:
    base = os.environ.get("MOTH_CACHE") or os.path.join(
        os.environ.get("XDG_CACHE_HOME") or os.path.join(os.path.expanduser("~"), ".cache"), "moth")
    return os.path.join(base, *(repo_id or repo()).split("/"))


def missing(folder: str) -> list[str]:
    return [f for f in FILES if not os.path.isfile(os.path.join(folder, f))]


def present(folder: str) -> bool:
    return not missing(folder)


def ensure(folder: str | None = None, repo_id: str | None = None, revision: str | None = None) -> str:
    """The weights folder, downloading the model files into it first if any is missing. Exits with a message on
    failure (no huggingface_hub, offline, repo not found)."""
    repo_id = repo_id or repo()
    folder = folder or cache_dir(repo_id)
    if present(folder):
        return folder
    revision = revision or pinned_revision()
    from moth import config   # stdlib only
    if config.offline():   # MOTH_OFFLINE / HF_HUB_OFFLINE: nothing goes to the network, not even the first download
        raise SystemExit(f"moth: the model isn't downloaded yet ({', '.join(missing(folder))} missing in {folder}) and "
                         "offline mode is on (HF_HUB_OFFLINE / MOTH_OFFLINE). Unset it for the first run, or pass "
                         "--model and --tokenizer.")
    try:
        import huggingface_hub as hub
    except ImportError:
        raise SystemExit("moth needs huggingface_hub to download its weights:  pip install -U huggingface_hub")
    try:
        info = hub.HfApi().model_info(repo_id, revision=revision)
    except Exception as e:
        raise SystemExit(f"moth: can't get {repo_id}{'@' + revision if revision else ''} from the Hugging Face Hub "
                         f"({type(e).__name__}: {_first_line(e)}). Check your connection, or set MOTH_REPO / "
                         "MOTH_REVISION, or pass --model and --tokenizer.")
    info = compatible(hub, repo_id, info, pinned=bool(revision))
    sha = info.sha   # pin the download to one commit
    size = f" ({SIZE_HINT})" if repo_id == REPO_ID else ""
    endpoint = (os.environ.get("HF_ENDPOINT") or "https://huggingface.co").rstrip("/")
    print(f"moth: first run, downloading the model{size} from {endpoint}/{repo_id} "
          f"(revision {sha[:12]})\n      into {folder}  (only once; `moth update` refreshes it)", file=sys.stderr,
          flush=True)
    os.makedirs(folder, exist_ok=True)
    drop_orphans(folder)   # what killed first runs left
    try:
        hub.snapshot_download(repo_id=repo_id, revision=sha, local_dir=folder, allow_patterns=list(FILES + OPTIONAL))
    except Exception as e:
        raise SystemExit(f"moth: download failed ({type(e).__name__}: {_first_line(e)}); run moth again to resume")
    gone = missing(folder)
    if gone:   # huggingface_hub can return quietly when the Hub stops answering halfway
        on_hub = {s.rfilename for s in (info.siblings or [])}
        if any(f not in on_hub for f in gone) and on_hub:
            raise SystemExit(f"moth: {repo_id}@{sha[:12]} has no {', '.join(f for f in gone if f not in on_hub)}; "
                             "is MOTH_REPO a moth repo?")
        raise SystemExit(f"moth: download incomplete ({', '.join(gone)} missing; the Hub stopped answering?); "
                         "run moth again to resume")
    with open(os.path.join(folder, REVISION_FILE), "w") as f:
        json.dump({"revision": sha, "files": sorted(FILES + tuple(x for x in OPTIONAL if os.path.isfile(
            os.path.join(folder, x))))}, f, indent=1)
        f.write("\n")
    print("moth: model downloaded", file=sys.stderr, flush=True)
    return folder


def compatible(hub, repo_id: str, info, pinned: bool = False):
    """info, unless the model at that revision is too new for this code (moth/backends/compat.py): then, when no
    revision was asked for, the one tagged v<this moth's version> if the repo has it; else exit saying how to get
    newer code. Never downloads more than the model's config.json / moth.json to decide."""
    from moth.backends import compat
    names = {s.rfilename for s in (info.siblings or [])}
    why = compat.remote_problem(hub, repo_id, info.sha, names)
    if not why:
        return info
    tag = "v" + compat.version()
    if not pinned:
        try:
            old = hub.HfApi().model_info(repo_id, revision=tag)
        except Exception:  # noqa: BLE001  no such tag (or no network): nothing to fall back to
            old = None
        if old is not None and not compat.remote_problem(hub, repo_id, old.sha, {s.rfilename for s in
                                                                              (old.siblings or [])}):
            print(f"moth: the newest model on the Hub is too new for this moth ({why}); using the one made for moth "
                  f"{compat.version()} ({tag}). For the new one: {compat.upgrade_hint()}", file=sys.stderr, flush=True)
            return old
    raise SystemExit(compat.message(f"{repo_id}@{info.sha[:12]}", why))


def drop_orphans(folder: str, min_age: float = 300.0) -> int:
    """Delete huggingface_hub's leftover partial downloads under folder/.cache/huggingface/download/: since hub 1.x
    names them <file>.<etag>.<random>.incomplete, a killed download is never resumed and its part (up to the whole
    model) would stay forever. Only ones untouched for min_age seconds (a live download writes all the time); the
    older <file>.<etag>.incomplete form is left alone (huggingface_hub resumes it). Returns how many went."""
    import re
    import time
    base, n = os.path.join(folder, ".cache", "huggingface", "download"), 0
    for d, _, files in os.walk(base):
        for f in files:
            if re.fullmatch(r"[\w=-]+\.[0-9a-f]+\.[0-9a-f]{8}\.incomplete", f):
                p = os.path.join(d, f)
                try:
                    if time.time() - os.path.getmtime(p) >= min_age:
                        os.remove(p)
                        n += 1
                except OSError:
                    pass
    return n


def ensure_default(model_dir: str) -> None:
    """serve / cli after parsing: fetch the weights only in pip mode and only when --model is the default folder.
    A Hugging Face download whose weights didn't finish (--model left at the download folder) exits with how to
    finish it, instead of failing to load or fetching a second copy elsewhere."""
    m = mode()
    if m == "hf":
        repo_dir = os.path.dirname(ROOT)
        if os.path.realpath(model_dir) == os.path.realpath(repo_dir) and not complete(repo_dir):
            raise SystemExit("moth: " + incomplete_message(repo_dir))
    elif m == "pip" and os.path.realpath(model_dir) == os.path.realpath(cache_dir()):
        ensure(model_dir)


def _first_line(e: Exception) -> str:
    return str(e).splitlines()[0][:160] if str(e) else ""