feat: download Hugging Face GGUF models
This commit is contained in:
+173
-5
@@ -71,6 +71,8 @@ def _discover_ollama_endpoint() -> None:
|
||||
LOCAL_OLLAMA = _ollama_base_url()
|
||||
REMOTE_OLLAMA = "https://ollama.com"
|
||||
HUGGINGFACE_API = "https://huggingface.co/api"
|
||||
HUGGINGFACE_DOWNLOAD_ROOT = "huggingface"
|
||||
HF_REPO_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,95}/[A-Za-z0-9][A-Za-z0-9._-]{0,95}$")
|
||||
CATALOG_FILE = "catalog.json"
|
||||
MODEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,190}$")
|
||||
MELBOURNE = ZoneInfo("Australia/Melbourne")
|
||||
@@ -749,7 +751,7 @@ def _json_request(url: str, method: str = "GET", payload: Any = None, timeout: i
|
||||
|
||||
|
||||
def _json_list_request(url: str, timeout: int = 30) -> list[dict[str, Any]]:
|
||||
request = Request(url, headers={"Accept": "application/json", "User-Agent": "Hermes-Ollama-Models/1.7.3"})
|
||||
request = Request(url, headers={"Accept": "application/json", "User-Agent": "Hermes-Ollama-Models/1.7.4"})
|
||||
with urlopen(request, timeout=timeout) as response:
|
||||
raw = response.read()
|
||||
value = json.loads(raw.decode("utf-8")) if raw else []
|
||||
@@ -1437,6 +1439,147 @@ def _search_huggingface(query: str, limit: int = 30) -> list[dict[str, Any]]:
|
||||
return [_huggingface_model_view(row) for row in rows if (row.get("id") or row.get("modelId"))]
|
||||
|
||||
|
||||
def _valid_huggingface_repo(repo_id: str) -> str:
|
||||
repo_id = str(repo_id or "").strip()
|
||||
if not HF_REPO_RE.fullmatch(repo_id):
|
||||
raise HTTPException(400, "Invalid Hugging Face repository id")
|
||||
return repo_id
|
||||
|
||||
|
||||
def _valid_huggingface_filename(filename: str) -> str:
|
||||
filename = unquote(str(filename or "")).strip().replace("\\", "/")
|
||||
path = Path(filename)
|
||||
if not filename or path.is_absolute() or any(part in {"", ".", ".."} for part in path.parts) or not filename.lower().endswith(".gguf"):
|
||||
raise HTTPException(400, "Only safe Hugging Face GGUF filenames are supported")
|
||||
return filename
|
||||
|
||||
|
||||
def _format_bytes(value: int) -> str:
|
||||
amount = float(max(0, int(value or 0)))
|
||||
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
|
||||
if amount < 1024 or unit == "TiB":
|
||||
return f"{amount:.1f} {unit}" if unit != "B" else f"{int(amount)} B"
|
||||
amount /= 1024
|
||||
return "Unknown"
|
||||
|
||||
|
||||
def _huggingface_repo_files(repo_id: str) -> list[dict[str, Any]]:
|
||||
repo_id = _valid_huggingface_repo(repo_id)
|
||||
url = f"{HUGGINGFACE_API}/models/{repo_id}?full=true"
|
||||
try:
|
||||
metadata = _json_request(url, timeout=30)
|
||||
except (HTTPError, URLError, OSError, ValueError) as exc:
|
||||
raise HTTPException(502, "Hugging Face repository metadata is unavailable") from exc
|
||||
raw_files: list[dict[str, Any]] = []
|
||||
siblings = metadata.get("siblings", []) if isinstance(metadata, dict) else []
|
||||
for item in siblings if isinstance(siblings, list) else []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
filename = str(item.get("rfilename") or item.get("path") or "").strip()
|
||||
if not filename.lower().endswith(".gguf"):
|
||||
continue
|
||||
try:
|
||||
filename = _valid_huggingface_filename(filename)
|
||||
except HTTPException:
|
||||
continue
|
||||
lfs: dict[str, Any] = {}
|
||||
raw_lfs = item.get("lfs")
|
||||
if isinstance(raw_lfs, dict):
|
||||
lfs = raw_lfs
|
||||
size = int(lfs.get("size") or item.get("size") or 0)
|
||||
raw_files.append({"filename": filename, "size": size, "download_url": f"https://huggingface.co/{repo_id}/resolve/main/{filename}?download=true"})
|
||||
groups: dict[str, list[dict[str, Any]]] = {}
|
||||
split_pattern = re.compile(r"^(.*)-\d{5}-of-\d{5}(\.gguf)$", re.I)
|
||||
for item in raw_files:
|
||||
match = split_pattern.match(item["filename"])
|
||||
group_key = f"{match.group(1)}{match.group(2)}" if match else item["filename"]
|
||||
groups.setdefault(group_key, []).append(item)
|
||||
files: list[dict[str, Any]] = []
|
||||
for group_key, group in groups.items():
|
||||
group.sort(key=lambda item: item["filename"])
|
||||
size = sum(int(item.get("size") or 0) for item in group)
|
||||
files.append({
|
||||
"filename": group[0]["filename"],
|
||||
"filenames": [item["filename"] for item in group],
|
||||
"size": size,
|
||||
"size_label": _format_bytes(size) if size else (f"{len(group)} shards · size unavailable" if len(group) > 1 else "Unknown"),
|
||||
"file_count": len(group),
|
||||
"split": len(group) > 1,
|
||||
"download_url": group[0]["download_url"],
|
||||
})
|
||||
return sorted(files, key=lambda item: (item.get("size") or 0, item["filename"]))
|
||||
|
||||
|
||||
def _hf_ollama_import_available() -> bool:
|
||||
parsed = urlparse(LOCAL_OLLAMA)
|
||||
return parsed.hostname in {"localhost", "127.0.0.1", "::1"} and not _running_in_container() and bool(shutil.which("ollama"))
|
||||
|
||||
|
||||
def _ollama_model_name_for_hf(repo_id: str, filename: str) -> str:
|
||||
owner, repo = repo_id.split("/", 1)
|
||||
stem = re.sub(r"-\d{5}-of-\d{5}$", "", Path(filename).stem.lower())
|
||||
value = re.sub(r"[^a-z0-9._-]+", "-", f"hf-{owner}-{repo}-{stem}").strip("-._")
|
||||
return value[:190] or "hf-imported-model"
|
||||
|
||||
|
||||
def _run_huggingface_download(job_id: str, repo_id: str, filename: str) -> None:
|
||||
temporary: Path | None = None
|
||||
try:
|
||||
repo_id = _valid_huggingface_repo(repo_id)
|
||||
filename = _valid_huggingface_filename(filename)
|
||||
file_info = next((item for item in _huggingface_repo_files(repo_id) if filename in item.get("filenames", [item["filename"]])), None)
|
||||
if not file_info:
|
||||
raise RuntimeError("Requested GGUF file was not found in the public Hugging Face repository")
|
||||
expected = int(file_info.get("size") or 0)
|
||||
free_bytes = shutil.disk_usage(_home()).free
|
||||
if expected and free_bytes < expected + 1024 ** 3:
|
||||
raise RuntimeError("Insufficient free disk space for the Hugging Face GGUF download")
|
||||
destination_root = _home() / HUGGINGFACE_DOWNLOAD_ROOT / repo_id
|
||||
destination_root.mkdir(parents=True, exist_ok=True)
|
||||
download_files = list(file_info.get("filenames") or [filename])
|
||||
completed = 0
|
||||
total = expected
|
||||
for remote_filename in download_files:
|
||||
destination = destination_root / remote_filename
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = destination.with_name(f".{destination.name}.{job_id}.part")
|
||||
download_url = f"https://huggingface.co/{repo_id}/resolve/main/{remote_filename}?download=true"
|
||||
request = Request(download_url, headers={"Accept": "application/octet-stream", "User-Agent": "Hermes-Ollama-Models/1.7.4"})
|
||||
with urlopen(request, timeout=60) as response, temporary.open("wb") as output:
|
||||
total = max(total, completed + int(response.headers.get("Content-Length") or 0))
|
||||
for chunk in iter(lambda: response.read(8 * 1024 * 1024), b""):
|
||||
output.write(chunk)
|
||||
completed += len(chunk)
|
||||
_set_job(job_id, status="downloading", completed=completed, total=total, percent=round(completed * 100 / total, 1) if total else None)
|
||||
os.replace(temporary, destination)
|
||||
destination = destination_root / download_files[0]
|
||||
model_name = _ollama_model_name_for_hf(repo_id, filename)
|
||||
imported = False
|
||||
if _hf_ollama_import_available():
|
||||
modelfile = destination.with_name(f".{destination.name}.Modelfile")
|
||||
modelfile.write_text(f"FROM {destination}\n", encoding="utf-8")
|
||||
try:
|
||||
env = dict(os.environ)
|
||||
env["OLLAMA_HOST"] = LOCAL_OLLAMA
|
||||
result = subprocess.run(["ollama", "create", model_name, "-f", str(modelfile)], capture_output=True, text=True, timeout=3600, check=False, env=env)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError((result.stderr or result.stdout or "ollama create failed")[-1000:])
|
||||
imported = True
|
||||
finally:
|
||||
try:
|
||||
modelfile.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
_set_job(job_id, state="completed", status="success", percent=100, path=str(destination), model_name=model_name, imported=imported, message="Downloaded and imported into local Ollama" if imported else "Downloaded GGUF; Ollama import was not available on this filesystem")
|
||||
except Exception as exc:
|
||||
if temporary is not None:
|
||||
try:
|
||||
temporary.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
_set_job(job_id, state="failed", status="error", error=str(exc))
|
||||
|
||||
|
||||
def _search_ollama_catalog(query: str, limit: int = 50) -> list[dict[str, Any]]:
|
||||
needle = str(query or "").strip().lower()
|
||||
if len(needle) < 2:
|
||||
@@ -1740,6 +1883,11 @@ class ModelRequest(BaseModel):
|
||||
placement: str = "gpu_ram"
|
||||
|
||||
|
||||
class HuggingFaceDownloadRequest(BaseModel):
|
||||
repo_id: str
|
||||
filename: str
|
||||
|
||||
|
||||
class ConnectionRequest(BaseModel):
|
||||
url: str
|
||||
role: str = "local"
|
||||
@@ -2781,22 +2929,42 @@ def catalog_refresh() -> dict[str, Any]:
|
||||
|
||||
|
||||
@router.get("/catalog/search")
|
||||
def catalog_search(q: str = "", limit: int = 30) -> dict[str, Any]:
|
||||
def catalog_search(q: str = "", limit: int = 30, include_huggingface: bool = False) -> dict[str, Any]:
|
||||
query = str(q or "").strip()[:120]
|
||||
if len(query) < 2:
|
||||
return {"query": query, "results": [], "sources": ["ollama", "huggingface"]}
|
||||
return {"query": query, "results": [], "sources": ["ollama", "huggingface"] if include_huggingface else ["ollama"]}
|
||||
ollama = _search_ollama_catalog(query, limit=limit)
|
||||
huggingface = _search_huggingface(query, limit=limit)
|
||||
huggingface = _search_huggingface(query, limit=limit) if include_huggingface else []
|
||||
combined = ollama + huggingface
|
||||
return {
|
||||
"query": query,
|
||||
"results": combined,
|
||||
"ollama_results": ollama,
|
||||
"huggingface_results": huggingface,
|
||||
"sources": ["ollama", "huggingface"],
|
||||
"sources": ["ollama", "huggingface"] if include_huggingface else ["ollama"],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/huggingface/files")
|
||||
def huggingface_files(repo_id: str) -> dict[str, Any]:
|
||||
repo_id = _valid_huggingface_repo(repo_id)
|
||||
files = _huggingface_repo_files(repo_id)
|
||||
return {"repo_id": repo_id, "files": [{key: value for key, value in item.items() if key != "download_url"} for item in files], "import_supported": _hf_ollama_import_available()}
|
||||
|
||||
|
||||
@router.post("/huggingface/download")
|
||||
def huggingface_download(body: HuggingFaceDownloadRequest) -> dict[str, Any]:
|
||||
repo_id = _valid_huggingface_repo(body.repo_id)
|
||||
filename = _valid_huggingface_filename(body.filename)
|
||||
if not any(item["filename"] == filename for item in _huggingface_repo_files(repo_id)):
|
||||
raise HTTPException(400, "Requested GGUF file was not found in the public Hugging Face repository")
|
||||
job_id = uuid.uuid4().hex
|
||||
with _jobs_lock:
|
||||
_jobs[job_id] = {"id": job_id, "name": f"{repo_id}/{filename}", "action": "huggingface-download", "target": "local", "state": "running", "status": "starting", "percent": 0, "created_at": time.time(), "updated_at": time.time()}
|
||||
threading.Thread(target=_run_huggingface_download, args=(job_id, repo_id, filename), daemon=True, name=f"huggingface-download-{job_id[:8]}").start()
|
||||
return {"ok": True, "job_id": job_id, "repo_id": repo_id, "filename": filename, "message": "Hugging Face GGUF download started"}
|
||||
|
||||
|
||||
@router.post("/pull")
|
||||
def pull_model(body: ModelRequest) -> dict[str, Any]:
|
||||
name = _valid_name(body.name)
|
||||
|
||||
Reference in New Issue
Block a user