225 lines
7.5 KiB
Python
225 lines
7.5 KiB
Python
from __future__ import annotations
|
|
|
|
import fnmatch
|
|
import json
|
|
import platform
|
|
import time
|
|
import urllib.request
|
|
import zipfile
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from tempfile import TemporaryDirectory
|
|
|
|
|
|
LLAMA_CPP_RELEASE_TAG = "b10472"
|
|
RELEASE_API_URL = f"https://api.github.com/repos/ggml-org/llama.cpp/releases/tags/{LLAMA_CPP_RELEASE_TAG}"
|
|
PACKAGE_ROOT = Path(__file__).resolve().parent
|
|
VENDOR_ROOT = PACKAGE_ROOT / "vendor" / "llama.cpp"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PlatformSpec:
|
|
key: str
|
|
cli_executable: str
|
|
asset_patterns: tuple[str, ...]
|
|
required_files: tuple[str, ...]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LlamaCliPaths:
|
|
cli: Path
|
|
|
|
|
|
WINDOWS_CUDA_13 = PlatformSpec(
|
|
key="win-x64-cuda13",
|
|
cli_executable="llama-cli.exe",
|
|
asset_patterns=(
|
|
"llama-*-bin-win-cuda-13*-x64.zip",
|
|
"cudart-llama-bin-win-cuda-13*-x64.zip",
|
|
),
|
|
required_files=(
|
|
"llama-cli.exe",
|
|
"ggml-cuda.dll",
|
|
"cudart64_13.dll",
|
|
),
|
|
)
|
|
|
|
|
|
def _platform_spec() -> PlatformSpec:
|
|
system = platform.system().lower()
|
|
machine = platform.machine().lower()
|
|
if system == "windows" and machine in {"amd64", "x86_64"}:
|
|
return WINDOWS_CUDA_13
|
|
raise RuntimeError(
|
|
"Automatic llama.cpp binary download currently supports Windows x64 CUDA 13 only. "
|
|
"Other platforms are intentionally isolated behind the platform mapping for future support."
|
|
)
|
|
|
|
|
|
def _json_get(url: str) -> dict:
|
|
request = urllib.request.Request(url, headers={"User-Agent": "ComfyUI-LLM-text-processor"})
|
|
with urllib.request.urlopen(request, timeout=30) as response:
|
|
return json.loads(response.read().decode("utf-8"))
|
|
|
|
|
|
def _format_size(num_bytes: float) -> str:
|
|
units = ("B", "KB", "MB", "GB")
|
|
value = float(num_bytes)
|
|
for unit in units:
|
|
if value < 1024 or unit == units[-1]:
|
|
if unit == "B":
|
|
return f"{int(value)} {unit}"
|
|
return f"{value:.1f} {unit}"
|
|
value /= 1024
|
|
return f"{value:.1f} GB"
|
|
|
|
|
|
def _download(url: str, destination: Path) -> None:
|
|
request = urllib.request.Request(url, headers={"User-Agent": "ComfyUI-LLM-text-processor"})
|
|
with urllib.request.urlopen(request, timeout=120) as response:
|
|
total_size = response.headers.get("Content-Length")
|
|
total_size = int(total_size) if total_size is not None else None
|
|
downloaded = 0
|
|
chunk_size = 1024 * 256
|
|
started_at = time.monotonic()
|
|
last_reported_at = started_at
|
|
|
|
with destination.open("wb") as handle:
|
|
while True:
|
|
chunk = response.read(chunk_size)
|
|
if not chunk:
|
|
break
|
|
handle.write(chunk)
|
|
downloaded += len(chunk)
|
|
|
|
now = time.monotonic()
|
|
if now - last_reported_at < 1.0:
|
|
continue
|
|
|
|
elapsed = max(now - started_at, 0.001)
|
|
speed = downloaded / elapsed
|
|
if total_size:
|
|
percent = (downloaded / total_size) * 100
|
|
print(
|
|
"[LLM Text Processor] "
|
|
f"Downloaded {_format_size(downloaded)} / {_format_size(total_size)} "
|
|
f"({percent:.1f}%) at {_format_size(speed)}/s"
|
|
)
|
|
else:
|
|
print(
|
|
"[LLM Text Processor] "
|
|
f"Downloaded {_format_size(downloaded)} at {_format_size(speed)}/s"
|
|
)
|
|
last_reported_at = now
|
|
|
|
elapsed = max(time.monotonic() - started_at, 0.001)
|
|
speed = downloaded / elapsed
|
|
if total_size:
|
|
print(
|
|
"[LLM Text Processor] "
|
|
f"Finished download: {_format_size(downloaded)} / {_format_size(total_size)} "
|
|
f"(100.0%) at {_format_size(speed)}/s"
|
|
)
|
|
else:
|
|
print(
|
|
"[LLM Text Processor] "
|
|
f"Finished download: {_format_size(downloaded)} at {_format_size(speed)}/s"
|
|
)
|
|
|
|
|
|
def _select_assets(release: dict, spec: PlatformSpec) -> list[dict]:
|
|
assets = release.get("assets", [])
|
|
selected = []
|
|
used_names = set()
|
|
|
|
# Match explicit release asset names so a future platform can add patterns
|
|
# without changing the download/extract pipeline.
|
|
for pattern in spec.asset_patterns:
|
|
matches = [
|
|
asset for asset in assets
|
|
if fnmatch.fnmatch(asset.get("name", "").lower(), pattern.lower())
|
|
]
|
|
if not matches:
|
|
raise RuntimeError(f"Could not find llama.cpp release asset matching: {pattern}")
|
|
asset = sorted(matches, key=lambda item: item.get("name", ""))[0]
|
|
if asset["name"] not in used_names:
|
|
selected.append(asset)
|
|
used_names.add(asset["name"])
|
|
return selected
|
|
|
|
|
|
def _find_file(install_dir: Path, name: str) -> Path | None:
|
|
for path in install_dir.rglob(name):
|
|
if path.is_file():
|
|
return path
|
|
return None
|
|
|
|
|
|
def _find_cli_paths(install_dir: Path, spec: PlatformSpec) -> LlamaCliPaths | None:
|
|
cli = _find_file(install_dir, spec.cli_executable)
|
|
if cli is None:
|
|
return None
|
|
return LlamaCliPaths(cli=cli)
|
|
|
|
|
|
def _has_required_files(install_dir: Path, spec: PlatformSpec) -> bool:
|
|
for name in spec.required_files:
|
|
if not any(path.is_file() for path in install_dir.rglob(name)):
|
|
return False
|
|
return True
|
|
|
|
|
|
def _is_complete_install(install_dir: Path, spec: PlatformSpec) -> bool:
|
|
return _find_cli_paths(install_dir, spec) is not None and _has_required_files(install_dir, spec)
|
|
|
|
|
|
def _existing_install(spec: PlatformSpec) -> LlamaCliPaths | None:
|
|
install_dir = VENDOR_ROOT / LLAMA_CPP_RELEASE_TAG / spec.key
|
|
return _find_cli_paths(install_dir, spec) if _is_complete_install(install_dir, spec) else None
|
|
|
|
|
|
def _extract_assets(assets: list[dict], install_dir: Path) -> None:
|
|
with TemporaryDirectory(prefix="llm-text-processor-llama-download-") as temp:
|
|
temp_dir = Path(temp)
|
|
for asset in assets:
|
|
archive_path = temp_dir / asset["name"]
|
|
print(f"[LLM Text Processor] Downloading {asset['name']}...")
|
|
_download(asset["browser_download_url"], archive_path)
|
|
with zipfile.ZipFile(archive_path) as archive:
|
|
archive.extractall(install_dir)
|
|
|
|
|
|
def ensure_llama_cli_paths() -> LlamaCliPaths:
|
|
spec = _platform_spec()
|
|
existing = _existing_install(spec)
|
|
if existing is not None:
|
|
return existing
|
|
|
|
release = _json_get(RELEASE_API_URL)
|
|
tag = release.get("tag_name") or LLAMA_CPP_RELEASE_TAG
|
|
install_dir = VENDOR_ROOT / tag / spec.key
|
|
|
|
if _is_complete_install(install_dir, spec):
|
|
paths = _find_cli_paths(install_dir, spec)
|
|
if paths is None:
|
|
raise RuntimeError(f"Completed install has incomplete CLI executables: {install_dir}")
|
|
return paths
|
|
|
|
assets = _select_assets(release, spec)
|
|
install_dir.mkdir(parents=True, exist_ok=True)
|
|
_extract_assets(assets, install_dir)
|
|
|
|
paths = _find_cli_paths(install_dir, spec)
|
|
if paths is None:
|
|
raise RuntimeError(
|
|
f"Downloaded llama.cpp assets but could not find CLI executables in {install_dir}"
|
|
)
|
|
if not _has_required_files(install_dir, spec):
|
|
missing = [
|
|
name for name in spec.required_files
|
|
if not any(path.is_file() for path in install_dir.rglob(name))
|
|
]
|
|
raise RuntimeError(f"Downloaded llama.cpp assets are incomplete; missing: {', '.join(missing)}")
|
|
|
|
return paths
|