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.
This commit is contained in:
Leonid Pershin
2026-08-21 07:34:15 +03:00
parent 7343fb0e83
commit 618e6e4806
12 changed files with 642 additions and 96 deletions
+1 -1
View File
@@ -129,7 +129,7 @@ Unit `gpu-rent-ollama` читает `/mnt/swarm_data/.gpu-rent-gpu.json`:
| 4 | empty | только runtime | | 4 | empty | только runtime |
| — | keep | не трогать yaml | | — | keep | не трогать yaml |
`-ngl` / `-c` — по GPU probe. Опционально `HF_TOKEN` для gated HF. Вручную: положи GGUF в models и `systemctl restart gpu-rent-llamacpp`. `-ngl` / `-c` — по GPU probe. Для recommended abliterate GGUF нужен **`HF_TOKEN`** в `.env` (иначе 401). Вручную: положи GGUF в models и `systemctl restart gpu-rent-llamacpp`.
--- ---
+8
View File
@@ -189,6 +189,14 @@ REST не раздвоился: те же `/api/v1/...` на обоих хост
```env ```env
CIVITAI_API_TOKEN= CIVITAI_API_TOKEN=
CIVITAI_API_HOST=civitai.red # полный каталог; civitai.com = только SFW CIVITAI_API_HOST=civitai.red # полный каталог; civitai.com = только SFW
HF_TOKEN= # или HUGGING_FACE_HUB_TOKEN — HF GGUF / gated / capture fallback
MODELS_MANIFEST= # пусто = <repo>/models.yaml MODELS_MANIFEST= # пусто = <repo>/models.yaml
LOCAL_MODELS_DIR= # пусто = <корень приложения>/Models LOCAL_MODELS_DIR= # пусто = <корень приложения>/Models
``` ```
## Hugging Face (резерв)
- **Скачивание:** `HF_TOKEN` в `.env` → GGUF (llama.cpp) и строки `models.yaml` с `huggingface.co/…/resolve/…`.
- **Capture:** если Civitai by-hash не нашёл файл → поиск на Hub по имени + LFS sha256 → URL в манифест.
- **SwarmUI:** тот же токен прокидывается как `huggingface_api` (Model Downloader).
- Abliterated / gated репозитории без токена почти всегда дают **401**.
+4 -1
View File
@@ -46,7 +46,10 @@ OLLAMA_LOCAL_PORT=17811
LLAMACPP_LOCAL_PORT=17812 LLAMACPP_LOCAL_PORT=17812
# OLLAMA_MODELS_MANIFEST= # OLLAMA_MODELS_MANIFEST=
# LLAMACPP_MODELS_MANIFEST= # LLAMACPP_MODELS_MANIFEST=
# HF_TOKEN= # optional, gated GGUF on Hugging Face # CIVITAI_API_TOKEN=
# CIVITAI_API_HOST=civitai.red
# Hugging Face (GGUF / gated HF URLs / capture fallback metadata):
# HF_TOKEN= # or HUGGING_FACE_HUB_TOKEN — https://huggingface.co/settings/tokens
# git pull SwarmUI + extensions on each up (default true). CLI: --no-update # git pull SwarmUI + extensions on each up (default true). CLI: --no-update
UPDATE_GIT=true UPDATE_GIT=true
+124 -29
View File
@@ -22,6 +22,7 @@ from gpu_rent.civitai import (
) )
from gpu_rent.config import Config from gpu_rent.config import Config
from gpu_rent.errors import CloudError, GpuRentError from gpu_rent.errors import CloudError, GpuRentError
from gpu_rent.huggingface import lookup_by_sha256
from gpu_rent.manifests import ( from gpu_rent.manifests import (
MODEL_TYPES, MODEL_TYPES,
extract_version_id, extract_version_id,
@@ -49,11 +50,12 @@ def strip_git_auth(url: str) -> str:
@dataclass @dataclass
class ModelCaptureItem: class ModelCaptureItem:
kind: str kind: str
version_id: int
model_id: int
url: str url: str
title: str = "" title: str = ""
rel: str = "" rel: str = ""
version_id: int | None = None
model_id: int | None = None
source: str = "civitai" # civitai | huggingface
@dataclass @dataclass
@@ -115,6 +117,7 @@ def resolve_model_item(
token: str, token: str,
api_host: str, api_host: str,
link_host: str, link_host: str,
hf_token: str | None = None,
) -> ResolveOutcome: ) -> ResolveOutcome:
kind = str(raw.get("kind") or "") kind = str(raw.get("kind") or "")
if kind not in MODEL_TYPES: if kind not in MODEL_TYPES:
@@ -133,21 +136,50 @@ def resolve_model_item(
except (TypeError, ValueError): except (TypeError, ValueError):
mid = None mid = None
def _ok(v: int, m: int, name: str = title) -> ResolveOutcome: def _ok_civitai(v: int, m: int, name: str = title) -> ResolveOutcome:
return ResolveOutcome( return ResolveOutcome(
item=ModelCaptureItem( item=ModelCaptureItem(
kind=kind, kind=kind,
version_id=v,
model_id=m,
url=civitai_model_url(m, v, link_host), url=civitai_model_url(m, v, link_host),
title=name, title=name,
rel=rel, rel=rel,
version_id=v,
model_id=m,
source="civitai",
),
status="ok",
)
def _try_hf(sha: str, detail_prefix: str) -> ResolveOutcome | None:
"""Civitai miss → Hugging Face search by filename + LFS sha."""
try:
hit = lookup_by_sha256(
hf_token,
sha,
filename=str(raw.get("name") or rel),
)
except CloudError as exc:
return ResolveOutcome(
status="api_error",
detail=f"{detail_prefix} HF: {exc}",
)
if hit is None:
return None
return ResolveOutcome(
item=ModelCaptureItem(
kind=kind,
url=hit.url,
title=hit.title or title,
rel=rel,
version_id=None,
model_id=None,
source="huggingface",
), ),
status="ok", status="ok",
) )
if vid is not None and mid is not None: if vid is not None and mid is not None:
return _ok(vid, mid) return _ok_civitai(vid, mid)
# Partial sidecar: have version id → GET /model-versions/{id} for modelId. # Partial sidecar: have version id → GET /model-versions/{id} for modelId.
if vid is not None and mid is None: if vid is not None and mid is None:
@@ -167,7 +199,7 @@ def resolve_model_item(
vid2, mid2 = version_ids_from_payload(version) vid2, mid2 = version_ids_from_payload(version)
if vid2 is not None and mid2 is not None: if vid2 is not None and mid2 is not None:
name = str(version.get("name") or title) name = str(version.get("name") or title)
return _ok(vid2, mid2, name) return _ok_civitai(vid2, mid2, name)
return ResolveOutcome( return ResolveOutcome(
status="unknown", status="unknown",
detail=f"{rel} version_id={vid} (нет modelId в ответе)", detail=f"{rel} version_id={vid} (нет modelId в ответе)",
@@ -183,11 +215,14 @@ def resolve_model_item(
_host, version = fetch_model_version_by_hash(token or None, api_host, str(sha)) _host, version = fetch_model_version_by_hash(token or None, api_host, str(sha))
except CloudError as exc: except CloudError as exc:
msg = str(exc) msg = str(exc)
# 404 = genuinely not on Civitai; other → api_error # 404 = genuinely not on Civitai → try HF
if "HTTP 404" in msg or msg.rstrip().endswith("404"): if "HTTP 404" in msg or msg.rstrip().endswith("404"):
hf_out = _try_hf(str(sha), f"{rel} sha={str(sha)[:12]}")
if hf_out is not None:
return hf_out
return ResolveOutcome( return ResolveOutcome(
status="unknown", status="unknown",
detail=f"{rel} sha={str(sha)[:12]}", detail=f"{rel} sha={str(sha)[:12]} (нет на Civitai/HF)",
) )
return ResolveOutcome( return ResolveOutcome(
status="api_error", status="api_error",
@@ -195,12 +230,15 @@ def resolve_model_item(
) )
vid2, mid2 = version_ids_from_payload(version) vid2, mid2 = version_ids_from_payload(version)
if vid2 is None or mid2 is None: if vid2 is None or mid2 is None:
hf_out = _try_hf(str(sha), f"{rel} sha={str(sha)[:12]}")
if hf_out is not None:
return hf_out
return ResolveOutcome( return ResolveOutcome(
status="unknown", status="unknown",
detail=f"{rel} sha={str(sha)[:12]}… (пустой payload)", detail=f"{rel} sha={str(sha)[:12]}… (пустой payload)",
) )
name = str(version.get("name") or title) name = str(version.get("name") or title)
return _ok(vid2, mid2, name) return _ok_civitai(vid2, mid2, name)
def _backup(path: Path) -> None: def _backup(path: Path) -> None:
@@ -218,29 +256,42 @@ def _keep_model_entry(it: dict) -> bool:
return vid not in (None, "", 0, "0") return vid not in (None, "", 0, "0")
def _model_dedupe_key(kind: str, *, version_id: int | None, url: str | None) -> tuple:
u = (url or "").rstrip("/").lower()
if u and ("huggingface.co" in u or "hf.co/" in u):
return ("hf", kind, u)
if version_id is not None:
return ("civitai", kind, int(version_id))
return ("url", kind, u or "?")
def merge_models_yaml( def merge_models_yaml(
path: Path, path: Path,
new_items: list[ModelCaptureItem], new_items: list[ModelCaptureItem],
*, *,
dry_run: bool, dry_run: bool,
) -> tuple[list[ModelCaptureItem], list[str]]: ) -> tuple[list[ModelCaptureItem], list[str]]:
"""Return (actually_new, skip_msgs). Dedupe by (kind, version_id).""" """Return (actually_new, skip_msgs). Dedupe by Civitai version_id or HF url."""
existing = parse_models(path) if path.is_file() else [] existing = parse_models(path) if path.is_file() else []
have: set[tuple[str, int]] = set() have: set[tuple] = set()
for e in existing: for e in existing:
vid = e.version_id vid = e.version_id
if vid is None and e.url: if vid is None and e.url:
vid = extract_version_id(e.url) vid = extract_version_id(e.url)
if vid is not None: have.add(_model_dedupe_key(e.kind, version_id=vid, url=e.url))
have.add((e.kind, vid))
added: list[ModelCaptureItem] = [] added: list[ModelCaptureItem] = []
skipped: list[str] = [] skipped: list[str] = []
seen_new: set[tuple[str, int]] = set() seen_new: set[tuple] = set()
for item in new_items: for item in new_items:
key = (item.kind, item.version_id) key = _model_dedupe_key(item.kind, version_id=item.version_id, url=item.url)
if key in have or key in seen_new: if key in have or key in seen_new:
skipped.append(f"{item.kind} {item.title} modelVersionId={item.version_id}") label = (
f"modelVersionId={item.version_id}"
if item.version_id is not None
else item.url
)
skipped.append(f"{item.kind} {item.title} {label}")
continue continue
seen_new.add(key) seen_new.add(key)
added.append(item) added.append(item)
@@ -374,6 +425,7 @@ def capture_models(
link_host = cfg.civitai_api_host or "civitai.red" link_host = cfg.civitai_api_host or "civitai.red"
token = cfg.civitai_api_token token = cfg.civitai_api_token
api_host = cfg.civitai_api_host api_host = cfg.civitai_api_host
hf_token = cfg.hf_token or None
for raw in raw_models: for raw in raw_models:
if not isinstance(raw, dict): if not isinstance(raw, dict):
@@ -395,18 +447,30 @@ def capture_models(
# Full sidecar / ids → no API. Partial vid → GET version. Else batch by-hash. # Full sidecar / ids → no API. Partial vid → GET version. Else batch by-hash.
if vid_i is not None and mid_i is not None: if vid_i is not None and mid_i is not None:
outcome = resolve_model_item( outcome = resolve_model_item(
raw, token=token, api_host=api_host, link_host=link_host raw,
token=token,
api_host=api_host,
link_host=link_host,
hf_token=hf_token,
) )
elif vid_i is not None: elif vid_i is not None:
outcome = resolve_model_item( outcome = resolve_model_item(
raw, token=token, api_host=api_host, link_host=link_host raw,
token=token,
api_host=api_host,
link_host=link_host,
hf_token=hf_token,
) )
elif sha: elif sha:
need_hash.append(raw) need_hash.append(raw)
continue continue
else: else:
outcome = resolve_model_item( outcome = resolve_model_item(
raw, token=token, api_host=api_host, link_host=link_host raw,
token=token,
api_host=api_host,
link_host=link_host,
hf_token=hf_token,
) )
if outcome.status == "ok" and outcome.item is not None: if outcome.status == "ok" and outcome.item is not None:
@@ -430,31 +494,62 @@ def capture_models(
report.models_api_errors.append(f"{rel} sha={sha}… (batch failed)") report.models_api_errors.append(f"{rel} sha={sha}… (batch failed)")
need_hash = [] need_hash = []
hf_fallback = 0
for raw in need_hash: for raw in need_hash:
sha = str(raw.get("sha256") or "").strip().lower() sha = str(raw.get("sha256") or "").strip().lower()
rel = str(raw.get("rel") or raw.get("name") or "?") rel = str(raw.get("rel") or raw.get("name") or "?")
version = by_hash.get(sha) version = by_hash.get(sha)
if not version: if version:
report.models_unknown.append(f"{rel} sha={sha[:12]}")
continue
vid2, mid2 = version_ids_from_payload(version) vid2, mid2 = version_ids_from_payload(version)
if vid2 is None or mid2 is None: if vid2 is not None and mid2 is not None:
report.models_unknown.append(f"{rel} sha={sha[:12]}… (пустой payload)")
continue
kind = str(raw.get("kind") or "") kind = str(raw.get("kind") or "")
if kind not in MODEL_TYPES: if kind not in MODEL_TYPES:
continue continue
title = str(version.get("name") or Path(str(raw.get("name") or rel)).stem) title = str(
version.get("name") or Path(str(raw.get("name") or rel)).stem
)
resolved.append( resolved.append(
ModelCaptureItem( ModelCaptureItem(
kind=kind, kind=kind,
version_id=vid2,
model_id=mid2,
url=civitai_model_url(mid2, vid2, link_host), url=civitai_model_url(mid2, vid2, link_host),
title=title, title=title,
rel=rel, rel=rel,
version_id=vid2,
model_id=mid2,
source="civitai",
) )
) )
continue
# Civitai miss → Hugging Face
try:
hit = lookup_by_sha256(
hf_token,
sha,
filename=str(raw.get("name") or rel),
)
except CloudError as exc:
report.models_api_errors.append(f"{rel} HF: {exc}")
report.models_unknown.append(f"{rel} sha={sha[:12]}")
continue
if hit:
kind = str(raw.get("kind") or "")
if kind not in MODEL_TYPES:
continue
hf_fallback += 1
resolved.append(
ModelCaptureItem(
kind=kind,
url=hit.url,
title=hit.title or Path(str(raw.get("name") or rel)).stem,
rel=rel,
source="huggingface",
)
)
else:
report.models_unknown.append(f"{rel} sha={sha[:12]}… (нет на Civitai/HF)")
if hf_fallback:
log(f"capture: Hugging Face fallback — {hf_fallback} файл(ов)")
added, skipped = merge_models_yaml( added, skipped = merge_models_yaml(
cfg.models_manifest, resolved, dry_run=dry_run cfg.models_manifest, resolved, dry_run=dry_run
+4
View File
@@ -83,6 +83,7 @@ class Config:
civitai_api_token: str civitai_api_token: str
civitai_api_host: str civitai_api_host: str
hf_token: str
models_manifest: Path models_manifest: Path
extensions_manifest: Path extensions_manifest: Path
git_token: str git_token: str
@@ -215,6 +216,9 @@ def load_config(*, require_auth: bool = True) -> Config:
boot_snapshot_name=(os.environ.get("BOOT_SNAPSHOT_NAME") or "gpu-rent-boot-ok").strip(), boot_snapshot_name=(os.environ.get("BOOT_SNAPSHOT_NAME") or "gpu-rent-boot-ok").strip(),
civitai_api_token=(os.environ.get("CIVITAI_API_TOKEN") or "").strip(), civitai_api_token=(os.environ.get("CIVITAI_API_TOKEN") or "").strip(),
civitai_api_host=(os.environ.get("CIVITAI_API_HOST") or "civitai.red").strip().lower(), civitai_api_host=(os.environ.get("CIVITAI_API_HOST") or "civitai.red").strip().lower(),
hf_token=(
os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN") or ""
).strip(),
models_manifest=models_manifest, models_manifest=models_manifest,
extensions_manifest=extensions_manifest, extensions_manifest=extensions_manifest,
git_token=(os.environ.get("GIT_TOKEN") or "").strip(), git_token=(os.environ.get("GIT_TOKEN") or "").strip(),
+53
View File
@@ -236,6 +236,7 @@ def run_doctor() -> list[Check]:
) )
_civitai(cfg, checks) _civitai(cfg, checks)
_huggingface(cfg, checks)
_local_manifests(cfg, checks) _local_manifests(cfg, checks)
_local_folders(cfg, checks) _local_folders(cfg, checks)
return checks return checks
@@ -279,6 +280,58 @@ def _civitai(cfg: Config, checks: list[Check]) -> None:
) )
def _huggingface(cfg: Config, checks: list[Check]) -> None:
from gpu_rent.huggingface import is_huggingface_url, probe_whoami
from gpu_rent.llm_runtime import normalize_runtime, parse_llamacpp_models
from gpu_rent.manifests import parse_models
needs_hf = False
try:
for e in parse_models(cfg.models_manifest):
if e.url and is_huggingface_url(e.url):
needs_hf = True
break
except Exception:
pass
try:
if normalize_runtime(cfg.llm_runtime) == "llamacpp":
for e in parse_llamacpp_models(cfg.llamacpp_models_manifest):
if e.url and is_huggingface_url(e.url):
needs_hf = True
break
except Exception:
pass
if not cfg.hf_token:
checks.append(
Check(
"Hugging Face",
True,
False,
(
"HF_TOKEN нет — gated GGUF / HF в models.yaml дадут 401. "
"https://huggingface.co/settings/tokens"
if needs_hf
else "токена нет (опционально для HF URL / capture fallback)"
),
)
)
return
probe = probe_whoami(cfg.hf_token)
if probe.ok:
who = f" ({probe.name})" if probe.name else ""
checks.append(Check("Hugging Face", True, True, f"токен ок{who}"))
else:
checks.append(
Check(
"Hugging Face",
True,
False,
f"токен не принят: {probe.detail}",
)
)
def _local_manifests(cfg: Config, checks: list[Check]) -> None: def _local_manifests(cfg: Config, checks: list[Check]) -> None:
try: try:
models = parse_models(cfg.models_manifest) models = parse_models(cfg.models_manifest)
+203
View File
@@ -0,0 +1,203 @@
"""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
+74 -24
View File
@@ -234,16 +234,10 @@ def seed_swarmui_api_keys(cfg: Config, host: str, log: Log) -> None:
Swarm stores them in Users.ldb GenericData — needed for Model Downloader in the UI. Swarm stores them in Users.ldb GenericData — needed for Model Downloader in the UI.
Call after SwarmUI HTTP is up (after wait_backend / verify). Call after SwarmUI HTTP is up (after wait_backend / verify).
""" """
import os
keys: dict[str, str] = {} keys: dict[str, str] = {}
if cfg.civitai_api_token: if cfg.civitai_api_token:
keys["civitai_api"] = cfg.civitai_api_token keys["civitai_api"] = cfg.civitai_api_token
hf = ( hf = (cfg.hf_token or "").strip()
os.environ.get("HF_TOKEN")
or os.environ.get("HUGGING_FACE_HUB_TOKEN")
or ""
).strip()
if hf: if hf:
keys["huggingface_api"] = hf keys["huggingface_api"] = hf
if not keys: if not keys:
@@ -273,21 +267,52 @@ def seed_swarmui_api_keys(cfg: Config, host: str, log: Log) -> None:
def seed_civitai(cfg: Config, host: str, log: Log) -> None: def seed_civitai(cfg: Config, host: str, log: Log) -> None:
from gpu_rent.huggingface import is_huggingface_url
entries = parse_models(cfg.models_manifest) entries = parse_models(cfg.models_manifest)
if not cfg.civitai_api_token:
log("Civitai-seed пропущен: нет CIVITAI_API_TOKEN — дефолт SwarmUI")
return
if not entries: if not entries:
log("Civitai-seed пропущен: манифест пуст — дефолт SwarmUI") log("model-seed пропущен: манифест пуст — дефолт SwarmUI")
return return
jobs = []
jobs: list[dict] = []
for entry in entries: for entry in entries:
url = (entry.url or "").strip()
if url and is_huggingface_url(url):
name = url.rstrip("/").rsplit("/", 1)[-1].split("?", 1)[0] or "model.safetensors"
folder = MODEL_DIRS.get(entry.kind, entry.kind)
dest = f"{DATA}/Models/{folder}/{name}"
stem = Path(name).stem
swarm = {
"name": stem,
"title": stem,
"description": f"Hugging Face: {url}",
"trigger_phrase": "",
"author": "",
"tags": ["huggingface"],
}
jobs.append(
{
"dest": dest,
"url": url,
"sha256": "",
"auth": "hf",
"sidecars": {
f"{stem}.swarm.json": json.dumps(swarm, ensure_ascii=False, indent=2),
},
}
)
continue
if not cfg.civitai_api_token:
continue
vid = entry.version_id or (extract_version_id(entry.url) if entry.url else None) vid = entry.version_id or (extract_version_id(entry.url) if entry.url else None)
if not vid: if not vid:
log(f"пропуск {entry.kind}: нет version_id") log(f"пропуск {entry.kind}: нет version_id / HF url")
continue continue
try: try:
api_host, version = fetch_model_version(cfg.civitai_api_token, cfg.civitai_api_host, vid) api_host, version = fetch_model_version(
cfg.civitai_api_token, cfg.civitai_api_host, vid
)
except CloudError as exc: except CloudError as exc:
log(str(exc)) log(str(exc))
continue continue
@@ -309,7 +334,11 @@ def seed_civitai(cfg: Config, host: str, log: Log) -> None:
"title": version.get("name") or stem, "title": version.get("name") or stem,
"description": (version.get("description") or "")[:2000], "description": (version.get("description") or "")[:2000],
"trigger_phrase": phrase, "trigger_phrase": phrase,
"author": ((version.get("model") or {}) if isinstance(version.get("model"), dict) else {}).get("name"), "author": (
((version.get("model") or {}) if isinstance(version.get("model"), dict) else {}).get(
"name"
)
),
"tags": version.get("tags") or [], "tags": version.get("tags") or [],
} }
jobs.append( jobs.append(
@@ -317,22 +346,43 @@ def seed_civitai(cfg: Config, host: str, log: Log) -> None:
"dest": dest, "dest": dest,
"url": _download_url(api_host, vid, info), "url": _download_url(api_host, vid, info),
"sha256": sha, "sha256": sha,
"auth": "civitai",
"sidecars": { "sidecars": {
f"{stem}.civitai.json": civitai_json, f"{stem}.civitai.json": civitai_json,
f"{stem}.swarm.json": json.dumps(swarm, ensure_ascii=False, indent=2), f"{stem}.swarm.json": json.dumps(swarm, ensure_ascii=False, indent=2),
}, },
} }
) )
if not jobs: if not jobs:
if not cfg.civitai_api_token:
log("Civitai-seed пропущен: нет CIVITAI_API_TOKEN — дефолт SwarmUI")
else:
log("Civitai-seed: ни одной скачиваемой строки") log("Civitai-seed: ни одной скачиваемой строки")
return return
hf_n = sum(1 for j in jobs if j.get("auth") == "hf")
civ_n = len(jobs) - hf_n
if hf_n and not cfg.hf_token:
log(
"⚠ в манифесте есть Hugging Face URL, но нет HF_TOKEN — "
"gated/abliterated файлы дадут 401. Токен: huggingface.co/settings/tokens"
)
if civ_n and not cfg.civitai_api_token:
log("⚠ Civitai-строки пропущены: нет CIVITAI_API_TOKEN")
if not any(e.kind == "checkpoint" for e in entries): if not any(e.kind == "checkpoint" for e in entries):
log("в манифесте нет checkpoint — генерация может не стартовать") log("в манифесте нет checkpoint — генерация может не стартовать")
put_text(cfg, host, "/tmp/gpu-rent-civitai-jobs.json", json.dumps(jobs, indent=2)) put_text(cfg, host, "/tmp/gpu-rent-civitai-jobs.json", json.dumps(jobs, indent=2))
put_text(cfg, host, "/tmp/gpu-rent-civitai.token", cfg.civitai_api_token + "\n", mode=0o600) if cfg.civitai_api_token:
put_text(
cfg, host, "/tmp/gpu-rent-civitai.token", cfg.civitai_api_token + "\n", mode=0o600
)
if cfg.hf_token:
put_text(cfg, host, "/tmp/gpu-rent-hf.token", cfg.hf_token + "\n", mode=0o600)
log( log(
f"Civitai: {len(jobs)} в манифесте — на VM качаю отсутствующие " f"model-seed: {len(jobs)} файл(ов) "
f"(уже есть + sha → skip; прогресс [N/{len(jobs)}])" f"(civitai={civ_n}, huggingface={hf_n}) — прогресс [N/{len(jobs)}]"
) )
run_python( run_python(
cfg, cfg,
@@ -419,7 +469,6 @@ def provision_llm(cfg: Config, host: str, log: Log) -> None:
gguf_filename_from_url, gguf_filename_from_url,
parse_llamacpp_models, parse_llamacpp_models,
) )
import os
entries = parse_llamacpp_models(cfg.llamacpp_models_manifest) entries = parse_llamacpp_models(cfg.llamacpp_models_manifest)
defaults = [e for e in entries if e.default] defaults = [e for e in entries if e.default]
@@ -439,13 +488,14 @@ def provision_llm(cfg: Config, host: str, log: Log) -> None:
put_text( put_text(
cfg, host, "/tmp/gpu-rent-llamacpp-models.json", json.dumps(jobs, indent=2) cfg, host, "/tmp/gpu-rent-llamacpp-models.json", json.dumps(jobs, indent=2)
) )
hf = ( hf = (cfg.hf_token or "").strip()
os.environ.get("HF_TOKEN")
or os.environ.get("HUGGING_FACE_HUB_TOKEN")
or ""
).strip()
if hf: if hf:
put_text(cfg, host, "/tmp/gpu-rent-hf.token", hf + "\n", mode=0o600) put_text(cfg, host, "/tmp/gpu-rent-hf.token", hf + "\n", mode=0o600)
else:
log(
"⚠ HF_TOKEN не задан — gated GGUF (abliterated и др.) часто дают 401. "
"Добавь HF_TOKEN=hf_… в .env → https://huggingface.co/settings/tokens"
)
log(f"llama.cpp: скачиваю {len(jobs)} GGUF из манифеста") log(f"llama.cpp: скачиваю {len(jobs)} GGUF из манифеста")
run_python( run_python(
cfg, cfg,
+51 -15
View File
@@ -11,6 +11,7 @@ import urllib.request
from pathlib import Path from pathlib import Path
TOKEN_PATH = Path("/tmp/gpu-rent-civitai.token") TOKEN_PATH = Path("/tmp/gpu-rent-civitai.token")
HF_TOKEN_PATH = Path("/tmp/gpu-rent-hf.token")
JOBS_PATH = Path("/tmp/gpu-rent-civitai-jobs.json") JOBS_PATH = Path("/tmp/gpu-rent-civitai-jobs.json")
MARKER = Path("/mnt/swarm_data/.gpu-rent-models-seeded") MARKER = Path("/mnt/swarm_data/.gpu-rent-models-seeded")
@@ -103,7 +104,7 @@ def should_skip(dest: Path, expect_sha: str) -> tuple[bool, str]:
return False, "sha не совпал — перекачиваю" return False, "sha не совпал — перекачиваю"
def download(url: str, dest: Path, token: str, *, label: str) -> None: def download(url: str, dest: Path, token: str, *, label: str, auth_host: str) -> None:
dest.parent.mkdir(parents=True, exist_ok=True) dest.parent.mkdir(parents=True, exist_ok=True)
partial = dest.with_suffix(dest.suffix + ".partial") partial = dest.with_suffix(dest.suffix + ".partial")
@@ -115,15 +116,26 @@ def download(url: str, dest: Path, token: str, *, label: str) -> None:
if new is None: if new is None:
return None return None
host = (urllib.parse.urlparse(new.full_url).hostname or "").lower() host = (urllib.parse.urlparse(new.full_url).hostname or "").lower()
if host.endswith("civitai.com") or host.endswith("civitai.red") or host.endswith("civitai.green"): if auth_host == "civitai":
if (
host.endswith("civitai.com")
or host.endswith("civitai.red")
or host.endswith("civitai.green")
):
return new return new
# presigned S3 / CDN: token must not leave civitai elif auth_host == "hf":
return urllib.request.Request(new.full_url, headers={"User-Agent": "gpu-rent/0.1"}) # Keep Bearer only on hub host; CDN (cdn-lfs.*) is signed — no auth.
if host in {"huggingface.co", "hf.co"}:
return new
return urllib.request.Request(
new.full_url, headers={"User-Agent": "gpu-rent/0.1"}
)
opener = urllib.request.build_opener(StripAuthRedirect) opener = urllib.request.build_opener(StripAuthRedirect)
req = urllib.request.Request( headers = {"User-Agent": "gpu-rent/0.1"}
url, headers={"Authorization": f"Bearer {token}", "User-Agent": "gpu-rent/0.1"} if token:
) headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(url, headers=headers)
with opener.open(req, timeout=600) as response, partial.open("wb") as out: with opener.open(req, timeout=600) as response, partial.open("wb") as out:
total = response.headers.get("Content-Length") total = response.headers.get("Content-Length")
try: try:
@@ -142,10 +154,14 @@ def download(url: str, dest: Path, token: str, *, label: str) -> None:
def main() -> int: def main() -> int:
if not TOKEN_PATH.is_file(): civitai_token = (
print("нет токена", file=sys.stderr) TOKEN_PATH.read_text(encoding="utf-8").strip() if TOKEN_PATH.is_file() else ""
return 1 )
token = TOKEN_PATH.read_text(encoding="utf-8").strip() hf_token = (
HF_TOKEN_PATH.read_text(encoding="utf-8").strip()
if HF_TOKEN_PATH.is_file()
else ""
)
jobs = json.loads(JOBS_PATH.read_text(encoding="utf-8")) jobs = json.loads(JOBS_PATH.read_text(encoding="utf-8"))
total = len(jobs) total = len(jobs)
failed = 0 failed = 0
@@ -155,12 +171,18 @@ def main() -> int:
dest = Path(job["dest"]) dest = Path(job["dest"])
expect = (job.get("sha256") or "").lower() expect = (job.get("sha256") or "").lower()
prefix = f"[{index}/{total}]" prefix = f"[{index}/{total}]"
auth = str(job.get("auth") or "civitai").lower()
if auth == "hf":
token = hf_token
auth_host = "hf"
else:
token = civitai_token
auth_host = "civitai"
skip, reason = should_skip(dest, expect) skip, reason = should_skip(dest, expect)
if skip: if skip:
skipped += 1 skipped += 1
size = dest.stat().st_size if dest.is_file() else 0 size = dest.stat().st_size if dest.is_file() else 0
print(f"{prefix} {reason}: {dest.name} ({fmt_bytes(size)})") print(f"{prefix} {reason}: {dest.name} ({fmt_bytes(size)})")
# refresh sidecars even on skip
for extra_name, extra_text in (job.get("sidecars") or {}).items(): for extra_name, extra_text in (job.get("sidecars") or {}).items():
extra = dest.parent / extra_name extra = dest.parent / extra_name
extra.parent.mkdir(parents=True, exist_ok=True) extra.parent.mkdir(parents=True, exist_ok=True)
@@ -168,9 +190,19 @@ def main() -> int:
continue continue
if reason: if reason:
print(f"{prefix} {reason}: {dest.name}") print(f"{prefix} {reason}: {dest.name}")
if auth_host == "civitai" and not token:
failed += 1
print(f"{prefix} FAIL {dest.name}: нет CIVITAI токена", file=sys.stderr)
continue
try: try:
print(f"{prefix} качаю: {dest.name}", flush=True) print(f"{prefix} качаю: {dest.name}", flush=True)
download(job["url"], dest, token, label=f"{prefix} {dest.name}") download(
job["url"],
dest,
token,
label=f"{prefix} {dest.name}",
auth_host=auth_host,
)
downloaded += 1 downloaded += 1
if expect: if expect:
got = sha256_path(dest).lower() got = sha256_path(dest).lower()
@@ -183,9 +215,13 @@ def main() -> int:
print(f"{prefix} ok {dest.name} ({fmt_bytes(dest.stat().st_size)})") print(f"{prefix} ok {dest.name} ({fmt_bytes(dest.stat().st_size)})")
except Exception as exc: except Exception as exc:
failed += 1 failed += 1
print(f"{prefix} FAIL {dest.name}: {exc}", file=sys.stderr) msg = str(exc)
if "401" in msg and auth_host == "hf":
msg += " — нужен HF_TOKEN в .env (huggingface.co/settings/tokens)"
print(f"{prefix} FAIL {dest.name}: {msg}", file=sys.stderr)
TOKEN_PATH.unlink(missing_ok=True) TOKEN_PATH.unlink(missing_ok=True)
print(f"Civitai итог: скачано {downloaded}, пропущено {skipped}, ошибок {failed} (из {total})") HF_TOKEN_PATH.unlink(missing_ok=True)
print(f"seed итог: скачано {downloaded}, пропущено {skipped}, ошибок {failed} (из {total})")
if failed: if failed:
return 1 return 1
MARKER.parent.mkdir(parents=True, exist_ok=True) MARKER.parent.mkdir(parents=True, exist_ok=True)
+32 -2
View File
@@ -7,6 +7,7 @@ import os
import sys import sys
import time import time
import urllib.error import urllib.error
import urllib.parse
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
@@ -77,8 +78,25 @@ class DownloadProgress:
def download(url: str, dest: Path, headers: dict[str, str], *, label: str) -> None: def download(url: str, dest: Path, headers: dict[str, str], *, label: str) -> None:
partial = dest.with_suffix(dest.suffix + ".partial") partial = dest.with_suffix(dest.suffix + ".partial")
class StripAuthRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers_resp, newurl):
new = urllib.request.HTTPRedirectHandler.redirect_request(
self, req, fp, code, msg, headers_resp, newurl
)
if new is None:
return None
host = (urllib.parse.urlparse(new.full_url).hostname or "").lower()
# Hub needs Bearer; CDN (cdn-lfs.*) is pre-signed — drop Authorization.
if host in {"huggingface.co", "hf.co"}:
return new
return urllib.request.Request(
new.full_url, headers={"User-Agent": headers.get("User-Agent", "gpu-rent/1")}
)
opener = urllib.request.build_opener(StripAuthRedirect)
req = urllib.request.Request(url, headers=headers) req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=600) as resp, partial.open("wb") as out: with opener.open(req, timeout=600) as resp, partial.open("wb") as out:
cl = resp.headers.get("Content-Length") cl = resp.headers.get("Content-Length")
try: try:
total_n = int(cl) if cl else None total_n = int(cl) if cl else None
@@ -137,7 +155,19 @@ def main() -> int:
print(f"{prefix} ok {name} ({fmt_bytes(dest.stat().st_size)})") print(f"{prefix} ok {name} ({fmt_bytes(dest.stat().st_size)})")
except (urllib.error.URLError, urllib.error.HTTPError, OSError, TimeoutError) as exc: except (urllib.error.URLError, urllib.error.HTTPError, OSError, TimeoutError) as exc:
failed += 1 failed += 1
print(f"FAIL {name}: {exc}", file=sys.stderr) msg = str(exc)
if "401" in msg or "403" in msg:
if not token:
msg += (
" — нет HF_TOKEN: добавь в .env "
"(https://huggingface.co/settings/tokens) и прими условия репо"
)
else:
msg += (
" — токен есть, но отказано: проверь scopes / "
"Accept license на странице модели"
)
print(f"FAIL {name}: {msg}", file=sys.stderr)
try: try:
dest.with_suffix(dest.suffix + ".partial").unlink(missing_ok=True) dest.with_suffix(dest.suffix + ".partial").unlink(missing_ok=True)
except OSError: except OSError:
+62 -13
View File
@@ -79,6 +79,9 @@ def test_resolve_by_hash_404_is_unknown():
with patch( with patch(
"gpu_rent.capture.fetch_model_version_by_hash", "gpu_rent.capture.fetch_model_version_by_hash",
side_effect=CloudError("Civitai by-hash abc: HTTP 404 (хосты civitai.red)"), side_effect=CloudError("Civitai by-hash abc: HTTP 404 (хосты civitai.red)"),
), patch(
"gpu_rent.capture.lookup_by_sha256",
return_value=None,
): ):
out = resolve_model_item( out = resolve_model_item(
{ {
@@ -95,6 +98,40 @@ def test_resolve_by_hash_404_is_unknown():
assert out.item is None assert out.item is None
def test_resolve_by_hash_404_hf_fallback():
from gpu_rent.huggingface import HfFileHit
with patch(
"gpu_rent.capture.fetch_model_version_by_hash",
side_effect=CloudError("Civitai by-hash abc: HTTP 404 (хосты civitai.red)"),
), patch(
"gpu_rent.capture.lookup_by_sha256",
return_value=HfFileHit(
repo_id="org/model",
filename="m.safetensors",
url="https://huggingface.co/org/model/resolve/main/m.safetensors",
sha256="a" * 64,
title="org/model",
),
):
out = resolve_model_item(
{
"kind": "lora",
"rel": "Lora/m.safetensors",
"name": "m.safetensors",
"sha256": "a" * 64,
},
token="",
api_host="civitai.red",
link_host="civitai.red",
hf_token="hf_x",
)
assert out.status == "ok"
assert out.item is not None
assert out.item.source == "huggingface"
assert "huggingface.co" in out.item.url
def test_resolve_by_hash_network_is_api_error(): def test_resolve_by_hash_network_is_api_error():
with patch( with patch(
"gpu_rent.capture.fetch_model_version_by_hash", "gpu_rent.capture.fetch_model_version_by_hash",
@@ -125,17 +162,25 @@ def test_merge_models_dedupe(tmp_path: Path):
) )
items = [ items = [
ModelCaptureItem( ModelCaptureItem(
"lora", 100, 1, "https://civitai.red/models/1?modelVersionId=100", "old" kind="lora",
version_id=100,
model_id=1,
url="https://civitai.red/models/1?modelVersionId=100",
title="old",
), ),
ModelCaptureItem( ModelCaptureItem(
"lora", 200, 2, "https://civitai.red/models/2?modelVersionId=200", "new" kind="lora",
version_id=200,
model_id=2,
url="https://civitai.red/models/2?modelVersionId=200",
title="new",
), ),
ModelCaptureItem( ModelCaptureItem(
"checkpoint", kind="checkpoint",
300, version_id=300,
3, model_id=3,
"https://civitai.red/models/3?modelVersionId=300", url="https://civitai.red/models/3?modelVersionId=300",
"ckpt", title="ckpt",
), ),
] ]
added, skipped = merge_models_yaml(path, items, dry_run=False) added, skipped = merge_models_yaml(path, items, dry_run=False)
@@ -153,7 +198,11 @@ def test_merge_models_dry_run(tmp_path: Path):
before = path.read_text(encoding="utf-8") before = path.read_text(encoding="utf-8")
items = [ items = [
ModelCaptureItem( ModelCaptureItem(
"lora", 1, 1, "https://civitai.red/models/1?modelVersionId=1", "x" kind="lora",
version_id=1,
model_id=1,
url="https://civitai.red/models/1?modelVersionId=1",
title="x",
), ),
] ]
added, _ = merge_models_yaml(path, items, dry_run=True) added, _ = merge_models_yaml(path, items, dry_run=True)
@@ -170,11 +219,11 @@ def test_merge_models_kind_scoped_dedupe(tmp_path: Path):
) )
items = [ items = [
ModelCaptureItem( ModelCaptureItem(
"checkpoint", kind="checkpoint",
100, version_id=100,
1, model_id=1,
"https://civitai.red/models/1?modelVersionId=100", url="https://civitai.red/models/1?modelVersionId=100",
"as-ckpt", title="as-ckpt",
), ),
] ]
added, skipped = merge_models_yaml(path, items, dry_run=False) added, skipped = merge_models_yaml(path, items, dry_run=False)
+15
View File
@@ -0,0 +1,15 @@
from gpu_rent.huggingface import hf_resolve_url, is_huggingface_url
def test_is_huggingface_url():
assert is_huggingface_url(
"https://huggingface.co/org/model/resolve/main/a.gguf"
)
assert not is_huggingface_url("https://civitai.red/models/1")
def test_hf_resolve_url():
assert (
hf_resolve_url("bartowski/foo", "bar.gguf")
== "https://huggingface.co/bartowski/foo/resolve/main/bar.gguf"
)