Files
gpu-rent/src/gpu_rent/huggingface.py
T
Leonid Pershin 618e6e4806 Add Hugging Face support and enhance model resolution logic
- Introduced support for Hugging Face API integration, allowing fallback model resolution when Civitai fails.
- Updated configuration to include `HF_TOKEN` and `HF_TOKEN_PATH` for authentication.
- Enhanced model capture logic to differentiate between Civitai and Hugging Face sources.
- Improved error handling for model downloads, providing clearer messages for authentication issues.
- Updated documentation to reflect new environment variables and usage instructions for Hugging Face integration.
- Added tests to validate the new fallback mechanism and ensure robust model resolution.
2026-08-21 07:34:15 +03:00

204 lines
6.7 KiB
Python

"""Hugging Face Hub: auth probe, metadata lookup (capture fallback), URL helpers."""
from __future__ import annotations
from dataclasses import dataclass
from urllib.parse import quote, urlparse
import httpx
from gpu_rent.errors import CloudError
HF_API = "https://huggingface.co/api"
HF_HOST = "huggingface.co"
@dataclass
class HfProbe:
ok: bool
name: str = ""
detail: str = ""
@dataclass
class HfFileHit:
repo_id: str
filename: str
url: str
sha256: str = ""
title: str = ""
def is_huggingface_url(url: str) -> bool:
host = (urlparse(url).hostname or "").lower()
return host == HF_HOST or host.endswith(".huggingface.co")
def hf_resolve_url(repo_id: str, filename: str, *, revision: str = "main") -> str:
repo = repo_id.strip().strip("/")
name = filename.lstrip("/")
rev = revision.strip() or "main"
return f"https://{HF_HOST}/{repo}/resolve/{quote(rev, safe='')}/{quote(name, safe='/')}"
def probe_whoami(token: str, timeout: float = 15.0) -> HfProbe:
token = (token or "").strip()
if not token:
return HfProbe(ok=False, detail="токена нет")
url = f"{HF_API}/whoami-v2"
try:
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
response = client.get(url, headers={"Authorization": f"Bearer {token}"})
except httpx.HTTPError as exc:
return HfProbe(ok=False, detail=str(exc)[:160])
if response.status_code == 200:
data = response.json() if response.content else {}
name = ""
if isinstance(data, dict):
name = str(data.get("name") or data.get("fullname") or "")
return HfProbe(ok=True, name=name, detail="токен принят")
if response.status_code in {401, 403}:
return HfProbe(
ok=False,
detail="токен отвергнут — https://huggingface.co/settings/tokens",
)
return HfProbe(
ok=False,
detail=f"HTTP {response.status_code}: {(response.text or '')[:120]}",
)
def _headers(token: str | None) -> dict[str, str]:
h = {"User-Agent": "gpu-rent/1"}
if token:
h["Authorization"] = f"Bearer {token}"
return h
def _sha_from_entry(entry: dict) -> str:
lfs = entry.get("lfs")
if isinstance(lfs, dict):
for key in ("sha256", "oid", "sha"):
val = lfs.get(key)
if val:
return str(val).strip().lower()
for key in ("sha256", "oid", "blob_id"):
val = entry.get(key)
if val and len(str(val)) >= 40:
return str(val).strip().lower()
return ""
def _tree_match(
client: httpx.Client,
repo_id: str,
want_sha: str,
*,
filename_hint: str | None,
token: str | None,
) -> HfFileHit | None:
url = f"{HF_API}/models/{repo_id}/tree/main"
response = client.get(
url,
params={"recursive": "true"},
headers=_headers(token),
)
if response.status_code != 200:
return None
data = response.json()
if not isinstance(data, list):
return None
hint = (filename_hint or "").replace("\\", "/").split("/")[-1].lower()
for entry in data:
if not isinstance(entry, dict):
continue
if entry.get("type") and entry.get("type") != "file":
continue
path = str(entry.get("path") or entry.get("rfilename") or "")
if not path:
continue
sha = _sha_from_entry(entry)
if sha and sha == want_sha:
return HfFileHit(
repo_id=repo_id,
filename=path,
url=hf_resolve_url(repo_id, path),
sha256=sha,
title=repo_id,
)
# Filename fallback when tree has no sha (rare) — only exact name match
if hint and path.replace("\\", "/").split("/")[-1].lower() == hint and not sha:
return HfFileHit(
repo_id=repo_id,
filename=path,
url=hf_resolve_url(repo_id, path),
sha256="",
title=repo_id,
)
return None
def lookup_by_sha256(
token: str | None,
sha256: str,
*,
filename: str | None = None,
limit_repos: int = 12,
timeout: float = 45.0,
) -> HfFileHit | None:
"""Best-effort: search Hub by filename, match LFS sha256 in repo tree.
Hugging Face has no public global by-hash index; this is the practical fallback
after Civitai miss. Token optional for public repos; gated need HF_TOKEN.
"""
digest = sha256.strip().lower()
if len(digest) != 64 or any(c not in "0123456789abcdef" for c in digest):
raise CloudError(f"HF by-hash: неверный SHA256 ({sha256[:16]}…)")
hint = (filename or "").replace("\\", "/").split("/")[-1]
stem = hint.rsplit(".", 1)[0] if hint else ""
queries: list[str] = []
if hint:
queries.append(hint)
if stem and stem.lower() != hint.lower():
queries.append(stem)
if not queries:
return None
try:
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
seen: set[str] = set()
for q in queries:
response = client.get(
f"{HF_API}/models",
params={"search": q, "limit": limit_repos},
headers=_headers(token),
)
if response.status_code in {401, 403}:
raise CloudError(
"HF search: 401/403 — нужен валидный HF_TOKEN "
"(https://huggingface.co/settings/tokens)"
)
if response.status_code != 200:
continue
models = response.json()
if not isinstance(models, list):
continue
for model in models:
if not isinstance(model, dict):
continue
repo_id = str(model.get("modelId") or model.get("id") or "").strip()
if not repo_id or repo_id in seen:
continue
seen.add(repo_id)
hit = _tree_match(
client, repo_id, digest, filename_hint=hint, token=token
)
if hit:
return hit
if len(seen) >= limit_repos:
return None
except httpx.HTTPError as exc:
raise CloudError(f"HF lookup: {exc}") from exc
return None