File size: 11,816 Bytes
69ff5a5
 
 
 
 
 
 
 
 
8884863
 
69ff5a5
 
 
 
 
 
 
 
 
 
8884863
 
 
 
 
69ff5a5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8884863
69ff5a5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8884863
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69ff5a5
 
 
 
8884863
 
 
 
 
 
 
 
 
 
 
 
 
 
69ff5a5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
"""Hugging Face discovery and pinned downloads for the embedding viewer."""
from dataclasses import dataclass
from hashlib import sha256
from pathlib import Path, PurePosixPath
from urllib.parse import unquote, urlsplit
import io
import json
import os
import re
import shutil
from threading import Lock
import time

CACHE_ROOT = Path(__file__).resolve().parent / '.model-cache'
os.environ.setdefault('HF_XET_CACHE', str(CACHE_ROOT / 'xet'))

from huggingface_hub import HfApi, hf_hub_download
from huggingface_hub.utils import validate_repo_id
from tqdm.auto import tqdm

from embeddings import embedding_name
from app_environment import hosted_mode, hosted_download_limit


HOSTED_DOWNLOAD_LOCK = Lock()
DISK_RESERVE_BYTES = 2_000_000_000


@dataclass(frozen=True)
class HubLocation:
    repo_id: str
    revision: str = 'main'
    filename: str | None = None


@dataclass(frozen=True)
class HubCatalog:
    repo_id: str
    commit: str
    files: dict[str, int | None]
    choices: tuple[str, ...]
    preferred: str | None = None


@dataclass(frozen=True)
class DownloadPlan:
    repo_id: str
    commit: str
    selection: str
    files: tuple[str, ...]
    weight_files: tuple[str, ...]
    model_path: Path
    tokenizer_path: Path | None
    directory: Path
    size: int | None
    embeddings_only: bool = False
    file_sizes: tuple[int | None, ...] = ()


def safe_filename(name):
    if not isinstance(name, str) or not name or '\\' in name or ':' in name or '\x00' in name:
        raise ValueError('Invalid repository file path.')
    path = PurePosixPath(name)
    if path.is_absolute() or any(part in ('', '.', '..') for part in name.split('/')):
        raise ValueError('Invalid repository file path.')
    return name


def parse_location(value, revision=''):
    value = value.strip()
    filename, url_revision = None, None
    if '://' in value:
        url = urlsplit(value)
        if url.scheme != 'https' or url.netloc.lower() != 'huggingface.co':
            raise ValueError('Use a huggingface.co model URL or a repository ID such as owner/model.')
        pieces = url.path.strip('/').split('/')
        if len(pieces) >= 2 and pieces[0] in ('datasets', 'spaces'):
            raise ValueError('Choose a model repository, rather than a dataset or Space.')
        repo_parts = 1 if len(pieces) < 2 or pieces[1] in ('tree', 'blob', 'resolve') else 2
        repo_id = '/'.join(pieces[:repo_parts])
        if len(pieces) > repo_parts:
            tail = pieces[repo_parts:]
            if tail[0] not in ('tree', 'blob', 'resolve') or len(tail) < 2:
                raise ValueError('Use the model repository URL, or a tree/blob/resolve file URL.')
            url_revision = unquote(tail[1])
            if tail[0] in ('blob', 'resolve') and len(tail) > 2:
                filename = safe_filename(unquote('/'.join(tail[2:])))
    else:
        repo_id = value
    validate_repo_id(repo_id)
    return HubLocation(repo_id, revision.strip() or url_revision or 'main', filename)


def discover_models(value, revision='', token=None):
    location = parse_location(value, revision)
    info = HfApi().model_info(location.repo_id, revision=location.revision,
                              files_metadata=True, token=token or False, timeout=20)
    if not info.sha:
        raise ValueError('The repository did not return a commit revision.')
    files = {s.rfilename: s.size for s in info.siblings or []}
    choices = []
    for name in files:
        if name.endswith('.safetensors.index.json'):
            choices.append(name)
        elif name.endswith('.safetensors') and not re.search(r'-\d{5}-of-\d{5}\.safetensors$', name):
            choices.append(name)
        elif name.lower().endswith('.gguf') and not re.search(r'-\d{5}-of-\d{5}\.gguf$', name, re.I):
            choices.append(name)
    choices.sort(key=lambda n: (not n.endswith('.index.json'), n.lower()))
    if location.filename is not None and location.filename not in choices:
        raise ValueError('The linked file is not supported here. Use the repository URL to choose GGUF or safetensors weights.')
    return HubCatalog(location.repo_id, info.sha, files, tuple(choices),
                      location.filename if location.filename in choices else None)


def selection_directory(catalog, selection, cache_root=CACHE_ROOT):
    identity = f'{catalog.repo_id}@{catalog.commit}/{selection}'
    directory = Path(cache_root).resolve() / sha256(identity.encode()).hexdigest()[:24]
    return directory


def build_plan(catalog, selection, token=None, cache_root=CACHE_ROOT):
    if selection not in catalog.choices:
        raise ValueError('Choose one of the compatible files from this repository.')
    safe_filename(selection)
    directory = selection_directory(catalog, selection, cache_root)
    files = [selection]
    weights = [selection]
    embeddings_only = False
    if selection.endswith('.index.json'):
        # The small index is fetched before large files so the plan can show its size.
        index_path = hf_hub_download(catalog.repo_id, selection, revision=catalog.commit,
                                     local_dir=directory, token=token or False)
        with Path(index_path).open('r', encoding='utf-8') as source:
            index = json.load(source)
        mapping = index.get('weight_map')
        if not isinstance(mapping, dict) or not mapping:
            raise ValueError('The safetensors index has no weight map.')
        relevant = [value for key, value in mapping.items() if embedding_name(key)]
        selected_shards = relevant or list(mapping.values())
        embeddings_only = bool(relevant)
        parent = PurePosixPath(selection).parent
        weights = []
        for value in selected_shards:
            safe_filename(value)
            name = str(parent / value)
            safe_filename(name)
            if not name.endswith('.safetensors') or name not in catalog.files:
                raise ValueError('The model index refers to a missing or unsupported weights file.')
            if name not in weights:
                weights.append(name)
        files.extend(weights)
    parent = PurePosixPath(selection).parent
    tokenizer = next((name for name in (str(parent / 'tokenizer.json'), 'tokenizer.json') if name in catalog.files), None)
    if tokenizer:
        files.append(tokenizer)
    files = tuple(dict.fromkeys(files))
    sizes = [catalog.files.get(name) for name in files]
    size = sum(sizes) if all(value is not None for value in sizes) else None
    model_path = directory if selection.endswith('.index.json') else directory / selection
    return DownloadPlan(catalog.repo_id, catalog.commit, selection, files, tuple(weights), model_path,
                         directory / tokenizer if tokenizer else None, directory, size, embeddings_only, tuple(sizes))


def check_hosted_download(plan):
    """Enforce the per-model limit and leave room for the running Space.

    Complete cached files need no additional disk space. Incomplete downloads
    are counted at their full size, making this estimate conservative.
    """
    limit = hosted_download_limit()
    if limit is None:
        return
    if plan.size is None or plan.size > limit:
        raise ValueError(f'This Space supports models up to {format_size(limit)} with a known download size. '
                         'Choose smaller weights or embedding shards, or run the app locally.')
    remaining = plan.size
    if len(plan.file_sizes) == len(plan.files):
        for name, size in zip(plan.files, plan.file_sizes):
            safe_filename(name)
            path = plan.directory / name
            if size is not None and path.is_file() and path.stat().st_size == size:
                remaining -= size
    ancestor = plan.directory.resolve()
    while not ancestor.exists():
        ancestor = ancestor.parent
    free = shutil.disk_usage(ancestor).free
    if remaining and remaining + DISK_RESERVE_BYTES > free:
        raise ValueError(f'Not enough free storage on this Space: {format_size(remaining)} still needs downloading, '
                         f'but only {format_size(free)} is free. We reserve 2 GB for the app. '
                         'Choose smaller weights or try again after the Space cache is cleared.')


def download_model(plan, token=None, progress=None):
    """Progress callbacks may run on SDK threads; callers should use a queue."""
    locked = hosted_mode()
    if locked and not HOSTED_DOWNLOAD_LOCK.acquire(blocking=False):
        raise ValueError('Another model is downloading on this Space. Please try again when it finishes.')
    try:
        # Recheck inside the lock: another visitor may have consumed disk space
        # since this session displayed the download plan.
        check_hosted_download(plan)
        return _download_model(plan, token, progress)
    finally:
        if locked:
            HOSTED_DOWNLOAD_LOCK.release()


def _download_model(plan, token=None, progress=None):
    total_files = len(plan.files)
    for index, filename in enumerate(plan.files):
        safe_filename(filename)

        def report(fraction, detail='', file_index=index, name=filename):
            if progress:
                progress((file_index + max(0., min(1., fraction))) / total_files,
                          f'{file_index + 1}/{total_files} · {name}' + (f' · {detail}' if detail else ''))

        class DownloadProgress(tqdm):
            def __init__(self, *args, **kwargs):
                kwargs.pop('name', None)
                kwargs['file'] = io.StringIO()
                kwargs['disable'] = False
                self._last_report = 0.
                self._progress_callback = report
                super().__init__(*args, **kwargs)

            def update(self, n=1):
                changed = super().update(n)
                now = time.monotonic()
                if now - self._last_report > .15 and self.total:
                    self._last_report = now
                    self._progress_callback(self.n / self.total, f'{format_size(self.n)} / {format_size(self.total)}')
                return changed

            def close(self):
                if getattr(self, 'total', None):
                    self._progress_callback(self.n / self.total, f'{format_size(self.n)} / {format_size(self.total)}')
                super().close()

        report(0.)
        hf_hub_download(plan.repo_id, filename, revision=plan.commit, token=token or False,
                        local_dir=plan.directory, tqdm_class=DownloadProgress)
        report(1.)
    if not plan.model_path.exists() or not all((plan.directory / name).is_file() for name in plan.weight_files):
        raise ValueError('The download finished without a usable model file.')
    return plan.model_path, plan.tokenizer_path


def format_size(value):
    if value is None:
        return 'size unavailable'
    for unit in ('B', 'KB', 'MB', 'GB', 'TB'):
        if value < 1000 or unit == 'TB':
            return f'{value:.1f} {unit}'
        value /= 1000


def friendly_hub_error(error, token=None):
    from huggingface_hub.errors import GatedRepoError, RepositoryNotFoundError, RevisionNotFoundError
    if isinstance(error, GatedRepoError):
        return 'This model is gated. Request or accept access on its Hugging Face page, then use a read token from the approved account.'
    if isinstance(error, RevisionNotFoundError):
        return 'That revision was not found. Check the branch, tag or commit.'
    if isinstance(error, RepositoryNotFoundError):
        return 'The repository was not found or is private. Check its ID and, if needed, provide a read token.'
    message = str(error)
    if token:
        message = message.replace(token, '[redacted]')
    return re.sub(r'hf_[A-Za-z0-9]+', '[redacted]', message)