diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index 516dc4b6a4..f09955c9e3 100755 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -7,6 +7,7 @@ from __future__ import annotations import argparse +import errno import fnmatch import hashlib import json @@ -41,9 +42,25 @@ from typing import Any, Iterable, Iterator EXIT_SUCCESS = 0 EXIT_FALLBACK = 2 EXIT_ERROR = 1 +EXIT_BUSY = 3 + + +def env_int(name: str, default: int, *, minimum: int | None = None) -> int: + raw = os.environ.get(name) + if raw is None: + value = default + else: + try: + value = int(str(raw).strip()) + except (TypeError, ValueError): + value = default + if minimum is not None: + value = max(minimum, value) + return value + APPROVED_PREBUILT_LLAMA_TAG = "b8508" -DEFAULT_LLAMA_TAG = os.environ.get("UNSLOTH_LLAMA_TAG", APPROVED_PREBUILT_LLAMA_TAG) +DEFAULT_LLAMA_TAG = os.environ.get("UNSLOTH_LLAMA_TAG", "latest") DEFAULT_PUBLISHED_REPO = os.environ.get( "UNSLOTH_LLAMA_RELEASE_REPO", "unslothai/llama.cpp" ) @@ -71,6 +88,11 @@ HTTP_FETCH_BASE_DELAY_SECONDS = 0.75 SERVER_PORT_BIND_ATTEMPTS = 3 SERVER_BIND_RETRY_WINDOW_SECONDS = 5.0 TTY_PROGRESS_START_DELAY_SECONDS = 0.5 +DEFAULT_MAX_PREBUILT_RELEASE_FALLBACKS = env_int( + "UNSLOTH_LLAMA_MAX_PREBUILT_RELEASE_FALLBACKS", + 2, + minimum = 1, +) @dataclass @@ -170,10 +192,83 @@ class ApprovedReleaseChecksums: artifacts: dict[str, ApprovedArtifactHash] +@dataclass(frozen = True) +class ResolvedPublishedRelease: + bundle: PublishedReleaseBundle + checksums: ApprovedReleaseChecksums + + +@dataclass(frozen = True) +class InstallReleasePlan: + requested_tag: str + llama_tag: str + release_tag: str + attempts: list[AssetChoice] + approved_checksums: ApprovedReleaseChecksums + + class PrebuiltFallback(RuntimeError): pass +class BusyInstallConflict(RuntimeError): + pass + + +class ExistingInstallSatisfied(RuntimeError): + def __init__(self, choice: AssetChoice, used_fallback: bool): + super().__init__(f"existing install already matches candidate {choice.name}") + self.choice = choice + self.used_fallback = used_fallback + + +def _os_error_messages(exc: BaseException) -> list[str]: + messages: list[str] = [] + if isinstance(exc, OSError): + for value in ( + getattr(exc, "strerror", None), + getattr(exc, "filename", None), + getattr(exc, "filename2", None), + ): + if isinstance(value, str) and value: + messages.append(value) + text = str(exc) + if text: + messages.append(text) + return [message.lower() for message in messages if message] + + +def is_busy_lock_error(exc: BaseException) -> bool: + if isinstance(exc, BusyInstallConflict): + return True + if isinstance(exc, OSError): + if exc.errno in { + errno.EACCES, + errno.EBUSY, + errno.EPERM, + errno.ETXTBSY, + }: + return True + if getattr(exc, "winerror", None) in {5, 32, 145}: + return True + for message in _os_error_messages(exc): + if any( + needle in message + for needle in ( + "access is denied", + "being used by another process", + "device or resource busy", + "permission denied", + "text file busy", + "file is in use", + "process cannot access the file", + "cannot create a file when that file already exists", + ) + ): + return True + return False + + def log(message: str) -> None: print(f"[llama-prebuilt] {message}") @@ -598,6 +693,14 @@ def latest_upstream_release_tag() -> str: return tag +def normalized_requested_llama_tag(requested_tag: str | None) -> str: + if isinstance(requested_tag, str): + normalized = requested_tag.strip() + if normalized: + return normalized + return "latest" + + def normalize_compute_cap(value: Any) -> str | None: raw = str(value).strip() if not raw: @@ -1283,6 +1386,137 @@ def pinned_published_release_bundle( return bundle +def validated_checksums_for_bundle( + repo: str, bundle: PublishedReleaseBundle +) -> ApprovedReleaseChecksums: + checksums = load_approved_release_checksums(repo, bundle.release_tag) + require_approved_source_hash(checksums, bundle.upstream_tag) + return checksums + + +def resolve_published_release( + requested_tag: str | None, + published_repo: str, + published_release_tag: str = "", +) -> ResolvedPublishedRelease: + repo = published_repo or DEFAULT_PUBLISHED_REPO + normalized_requested = normalized_requested_llama_tag(requested_tag) + + if published_release_tag: + bundle = pinned_published_release_bundle(repo, published_release_tag) + if ( + normalized_requested != "latest" + and bundle.upstream_tag != normalized_requested + ): + raise PrebuiltFallback( + "published release " + f"{repo}@{published_release_tag} targeted upstream tag {bundle.upstream_tag}, " + f"but requested {normalized_requested}" + ) + return ResolvedPublishedRelease( + bundle = bundle, + checksums = validated_checksums_for_bundle(repo, bundle), + ) + + skipped_invalid = 0 + for bundle in iter_published_release_bundles(repo): + if ( + normalized_requested != "latest" + and bundle.upstream_tag != normalized_requested + ): + continue + try: + checksums = validated_checksums_for_bundle(repo, bundle) + except PrebuiltFallback as exc: + skipped_invalid += 1 + log( + "published release ignored for install resolution: " + f"{repo}@{bundle.release_tag} ({exc})" + ) + continue + return ResolvedPublishedRelease(bundle = bundle, checksums = checksums) + + if normalized_requested == "latest": + if skipped_invalid: + raise PrebuiltFallback( + f"no usable published llama.cpp releases were available in {repo}" + ) + raise PrebuiltFallback( + f"no published llama.cpp releases were available in {repo}" + ) + + raise PrebuiltFallback( + f"no published prebuilt release in {repo} matched upstream tag {normalized_requested}" + ) + + +def iter_resolved_published_releases( + requested_tag: str | None, + published_repo: str, + published_release_tag: str = "", +) -> Iterable[ResolvedPublishedRelease]: + repo = published_repo or DEFAULT_PUBLISHED_REPO + normalized_requested = normalized_requested_llama_tag(requested_tag) + + if published_release_tag: + bundle = pinned_published_release_bundle(repo, published_release_tag) + if ( + normalized_requested != "latest" + and bundle.upstream_tag != normalized_requested + ): + raise PrebuiltFallback( + "published release " + f"{repo}@{published_release_tag} targeted upstream tag {bundle.upstream_tag}, " + f"but requested {normalized_requested}" + ) + yield ResolvedPublishedRelease( + bundle = bundle, + checksums = validated_checksums_for_bundle(repo, bundle), + ) + return + + matched_any = False + skipped_invalid = 0 + yielded_valid = False + for bundle in iter_published_release_bundles(repo): + if ( + normalized_requested != "latest" + and bundle.upstream_tag != normalized_requested + ): + continue + matched_any = True + try: + checksums = validated_checksums_for_bundle(repo, bundle) + except PrebuiltFallback as exc: + skipped_invalid += 1 + log( + "published release ignored for install resolution: " + f"{repo}@{bundle.release_tag} ({exc})" + ) + continue + yielded_valid = True + yield ResolvedPublishedRelease(bundle = bundle, checksums = checksums) + + if yielded_valid: + return + + if matched_any: + if skipped_invalid: + raise PrebuiltFallback( + f"no usable published llama.cpp releases were available in {repo}" + ) + return + + if normalized_requested == "latest": + raise PrebuiltFallback( + f"no published llama.cpp releases were available in {repo}" + ) + + raise PrebuiltFallback( + f"no published prebuilt release in {repo} matched upstream tag {normalized_requested}" + ) + + def resolve_requested_llama_tag( requested_tag: str | None, published_repo: str = "", @@ -1291,9 +1525,9 @@ def resolve_requested_llama_tag( Resolution order: 1. Concrete tag (e.g. "b8508") -- returned as-is. - 2. "latest" with published_repo -- query the Unsloth release repo - (e.g. unslothai/llama.cpp) for its latest release tag. This is the - tested/approved version that matches the prebuilt binaries. + 2. "latest" with published_repo -- resolve the latest usable Unsloth + published release bundle and return its upstream_tag. This is the + preferred version that matches the published prebuilt metadata. 3. "latest" without published_repo or if (2) fails -- query the upstream ggml-org/llama.cpp repo. This may return a newer, untested tag. @@ -1301,20 +1535,19 @@ def resolve_requested_llama_tag( upstream tags that have been validated with Unsloth Studio. Using the upstream bleeding-edge tag risks API/ABI incompatibilities. """ - if requested_tag and requested_tag != "latest": - return requested_tag + normalized_requested = normalized_requested_llama_tag(requested_tag) + if normalized_requested != "latest": + return normalized_requested # Prefer the Unsloth release repo tag (tested/approved) over bleeding-edge # upstream. For example, unslothai/llama.cpp may publish b8508 while # ggml-org/llama.cpp latest is b8514. The source-build fallback should # compile the same version the prebuilt path would have installed. if published_repo: try: - payload = fetch_json( - f"https://api.github.com/repos/{published_repo}/releases/latest" - ) - tag = payload.get("tag_name") - if isinstance(tag, str) and tag: - return tag + return resolve_published_release( + "latest", + published_repo, + ).bundle.upstream_tag except Exception: pass # Fall back to upstream ggml-org latest release tag @@ -1324,18 +1557,13 @@ def resolve_requested_llama_tag( def resolve_requested_install_tag( requested_tag: str | None, published_release_tag: str = "", + published_repo: str = DEFAULT_PUBLISHED_REPO, ) -> str: - approved_tag = APPROVED_PREBUILT_LLAMA_TAG - normalized_requested = requested_tag or "latest" - if normalized_requested not in {"latest", approved_tag}: - raise PrebuiltFallback( - f"prebuilt installs are pinned to approved release {approved_tag}; requested {normalized_requested}" - ) - if published_release_tag and published_release_tag != approved_tag: - raise PrebuiltFallback( - f"prebuilt installs require published release tag {approved_tag}; requested {published_release_tag}" - ) - return approved_tag + return resolve_published_release( + requested_tag, + published_repo, + published_release_tag, + ).bundle.upstream_tag def run_capture( @@ -1680,6 +1908,68 @@ def windows_cuda_attempts( return attempts +def published_windows_cuda_attempts( + host: HostInfo, + release: PublishedReleaseBundle, + preferred_runtime_line: str | None, + selection_preamble: Iterable[str] = (), +) -> list[AssetChoice]: + selection_log = list(release.selection_log) + list(selection_preamble) + runtime_by_line = {"cuda12": "12.4", "cuda13": "13.1"} + runtime_order = windows_cuda_attempts( + host, + release.upstream_tag, + { + f"llama-{release.upstream_tag}-bin-win-cuda-{runtime}-x64.zip": "published" + for runtime in runtime_by_line.values() + }, + preferred_runtime_line, + selection_log, + ) + published_artifacts = [ + artifact + for artifact in release.artifacts + if artifact.install_kind == "windows-cuda" + ] + artifacts_by_runtime: dict[str, list[PublishedLlamaArtifact]] = {} + for artifact in published_artifacts: + if not artifact.runtime_line: + continue + artifacts_by_runtime.setdefault(artifact.runtime_line, []).append(artifact) + + attempts: list[AssetChoice] = [] + for ordered_attempt in runtime_order: + runtime_line = ordered_attempt.runtime_line + if not runtime_line: + continue + candidates = sorted( + artifacts_by_runtime.get(runtime_line, []), + key = lambda artifact: (artifact.rank, artifact.asset_name), + ) + for artifact in candidates: + asset_url = release.assets.get(artifact.asset_name) + if not asset_url: + continue + attempts.append( + AssetChoice( + repo = release.repo, + tag = release.release_tag, + name = artifact.asset_name, + url = asset_url, + source_label = "published", + install_kind = "windows-cuda", + runtime_line = runtime_line, + selection_log = list(ordered_attempt.selection_log or []) + + [ + "windows_cuda_selection: selected published asset " + f"{artifact.asset_name} for runtime_line={runtime_line}" + ], + ) + ) + break + return attempts + + def resolve_windows_cuda_choices( host: HostInfo, llama_tag: str, upstream_assets: dict[str, str] ) -> list[AssetChoice]: @@ -1695,32 +1985,52 @@ def resolve_windows_cuda_choices( def resolve_linux_cuda_choice( - host: HostInfo, llama_tag: str, published_repo: str, published_release_tag: str + host: HostInfo, release: PublishedReleaseBundle ) -> LinuxCudaSelection: torch_preference = detect_torch_cuda_runtime_preference(host) - skipped_tag_mismatches = 0 - for release in iter_published_release_bundles( - published_repo, published_release_tag - ): - if release.upstream_tag != llama_tag: - skipped_tag_mismatches += 1 - continue - selection = linux_cuda_choice_from_release( - host, - release, - preferred_runtime_line = torch_preference.runtime_line, - selection_preamble = torch_preference.selection_log, - ) - if selection is not None: - return selection - if skipped_tag_mismatches: - log( - "published Linux CUDA selection skipped " - f"{skipped_tag_mismatches} release(s) with upstream_tag != {llama_tag}" - ) + selection = linux_cuda_choice_from_release( + host, + release, + preferred_runtime_line = torch_preference.runtime_line, + selection_preamble = torch_preference.selection_log, + ) + if selection is not None: + return selection raise PrebuiltFallback("no compatible published Linux CUDA bundle was found") +def published_asset_choice_for_kind( + release: PublishedReleaseBundle, + install_kind: str, +) -> AssetChoice | None: + candidates = sorted( + ( + artifact + for artifact in release.artifacts + if artifact.install_kind == install_kind + ), + key = lambda artifact: (artifact.rank, artifact.asset_name), + ) + for artifact in candidates: + asset_url = release.assets.get(artifact.asset_name) + if not asset_url: + continue + return AssetChoice( + repo = release.repo, + tag = release.release_tag, + name = artifact.asset_name, + url = asset_url, + source_label = "published", + install_kind = install_kind, + runtime_line = artifact.runtime_line, + selection_log = list(release.selection_log) + + [ + f"published_selection: selected {artifact.asset_name} install_kind={install_kind}" + ], + ) + return None + + def resolve_upstream_asset_choice(host: HostInfo, llama_tag: str) -> AssetChoice: upstream_assets = github_release_assets(UPSTREAM_REPO, llama_tag) if host.is_linux and host.is_x86_64: @@ -1786,16 +2096,62 @@ def resolve_upstream_asset_choice(host: HostInfo, llama_tag: str) -> AssetChoice ) -def resolve_asset_choice( - host: HostInfo, llama_tag: str, published_repo: str, published_release_tag: str -) -> AssetChoice: +def resolve_asset_choice(host: HostInfo, llama_tag: str) -> AssetChoice: if host.is_linux and host.is_x86_64 and host.has_usable_nvidia: - return resolve_linux_cuda_choice( - host, llama_tag, published_repo, published_release_tag - ).primary + raise PrebuiltFallback( + "Linux CUDA installs require a compatible published bundle; upstream fallback is not available" + ) return resolve_upstream_asset_choice(host, llama_tag) +def resolve_release_asset_choice( + host: HostInfo, + llama_tag: str, + release: PublishedReleaseBundle, + checksums: ApprovedReleaseChecksums, +) -> list[AssetChoice]: + if host.is_windows and host.is_x86_64 and host.has_usable_nvidia: + torch_preference = detect_torch_cuda_runtime_preference(host) + published_attempts = published_windows_cuda_attempts( + host, + release, + torch_preference.runtime_line, + torch_preference.selection_log, + ) + if published_attempts: + try: + return apply_approved_hashes(published_attempts, checksums) + except PrebuiltFallback as exc: + log( + "published Windows CUDA assets ignored for install planning: " + f"{release.repo}@{release.release_tag} ({exc})" + ) + upstream_assets = github_release_assets(UPSTREAM_REPO, llama_tag) + return apply_approved_hashes( + resolve_windows_cuda_choices(host, llama_tag, upstream_assets), + checksums, + ) + + published_choice: AssetChoice | None = None + if host.is_windows and host.is_x86_64: + published_choice = published_asset_choice_for_kind(release, "windows-cpu") + elif host.is_macos and host.is_arm64: + published_choice = published_asset_choice_for_kind(release, "macos-arm64") + elif host.is_macos and host.is_x86_64: + published_choice = published_asset_choice_for_kind(release, "macos-x64") + + if published_choice is not None: + try: + return apply_approved_hashes([published_choice], checksums) + except PrebuiltFallback as exc: + log( + "published platform asset ignored for install planning: " + f"{release.repo}@{release.release_tag} {published_choice.name} ({exc})" + ) + + return apply_approved_hashes([resolve_asset_choice(host, llama_tag)], checksums) + + def extract_archive(archive_path: Path, destination: Path) -> None: def safe_extract_path(base: Path, member_name: str) -> Path: normalized = member_name.replace("\\", "/") @@ -2163,8 +2519,14 @@ def install_lock(lock_path: Path) -> Iterator[None]: while True: try: fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_RDWR) - os.write(fd, f"{os.getpid()}\n".encode()) - os.fsync(fd) + try: + os.write(fd, f"{os.getpid()}\n".encode()) + os.fsync(fd) + except Exception: + os.close(fd) + fd = None + lock_path.unlink(missing_ok = True) + raise break except FileExistsError: # Check if the holder process is still alive @@ -2177,6 +2539,10 @@ def install_lock(lock_path: Path) -> Iterator[None]: if not raw: # File exists but PID not yet written -- another process # just created it. Wait briefly for the write to land. + if time.monotonic() >= deadline: + raise BusyInstallConflict( + f"timed out after {INSTALL_LOCK_TIMEOUT_SECONDS}s waiting for concurrent install lock: {lock_path}" + ) time.sleep(0.1) continue try: @@ -2195,7 +2561,7 @@ def install_lock(lock_path: Path) -> Iterator[None]: lock_path.unlink(missing_ok = True) continue if time.monotonic() >= deadline: - raise RuntimeError( + raise BusyInstallConflict( f"timed out after {INSTALL_LOCK_TIMEOUT_SECONDS}s waiting for concurrent install lock: {lock_path}" ) time.sleep(0.5) @@ -2211,7 +2577,7 @@ def install_lock(lock_path: Path) -> Iterator[None]: with FileLock(lock_path, timeout = INSTALL_LOCK_TIMEOUT_SECONDS): yield except FileLockTimeout as exc: - raise RuntimeError( + raise BusyInstallConflict( f"timed out after {INSTALL_LOCK_TIMEOUT_SECONDS}s waiting for concurrent install lock: {lock_path}" ) from exc @@ -2359,11 +2725,17 @@ def activate_install_tree(staging_dir: Path, install_dir: Path, host: HostInfo) log(f"restoring rollback path {rollback_dir} -> {install_dir}") os.replace(rollback_dir, install_dir) log(f"restored previous install from rollback path {rollback_dir.name}") + if is_busy_lock_error(exc): + raise BusyInstallConflict( + "staged prebuilt validation passed but the existing install could not be replaced " + "because llama.cpp appears to still be in use; restored previous install " + f"({textwrap.shorten(str(exc), width = 200, placeholder = '...')})" + ) from exc raise PrebuiltFallback( "staged prebuilt validation passed but activation failed; restored previous install " f"({textwrap.shorten(str(exc), width = 200, placeholder = '...')})" ) from exc - except PrebuiltFallback: + except (BusyInstallConflict, PrebuiltFallback): raise except Exception as rollback_exc: log(f"rollback after failed activation also failed: {rollback_exc}") @@ -2395,7 +2767,12 @@ def activate_install_tree(staging_dir: Path, install_dir: Path, host: HostInfo) ) from exc else: if rollback_dir: - remove_tree_logged(rollback_dir, "rollback path") + try: + remove_tree_logged(rollback_dir, "rollback path") + except Exception as cleanup_exc: + log( + f"non-fatal: rollback cleanup failed after successful activation: {cleanup_exc}" + ) finally: remove_tree(failed_dir) remove_tree(staging_dir) @@ -3110,39 +3487,90 @@ def resolve_install_attempts( published_repo: str, published_release_tag: str, ) -> tuple[str, str, list[AssetChoice], ApprovedReleaseChecksums]: - requested_tag = llama_tag - resolved_tag = resolve_requested_install_tag(llama_tag, published_release_tag) - checksums = load_approved_release_checksums(published_repo, resolved_tag) - require_approved_source_hash(checksums, resolved_tag) - - if host.is_linux and host.is_x86_64 and host.has_usable_nvidia: - linux_cuda_selection = resolve_linux_cuda_choice( - host, resolved_tag, published_repo, published_release_tag - ) - attempts = apply_approved_hashes(linux_cuda_selection.attempts, checksums) - if not attempts: - raise PrebuiltFallback("no compatible Linux CUDA asset was found") - log_lines(linux_cuda_selection.selection_log) - return requested_tag, resolved_tag, attempts, checksums - - if host.is_windows and host.is_x86_64 and host.has_usable_nvidia: - upstream_assets = github_release_assets(UPSTREAM_REPO, resolved_tag) - attempts = apply_approved_hashes( - resolve_windows_cuda_choices(host, resolved_tag, upstream_assets), checksums - ) - if not attempts: - raise PrebuiltFallback("no compatible Windows CUDA asset was found") - if attempts[0].selection_log: - log_lines(attempts[0].selection_log) - return requested_tag, resolved_tag, attempts, checksums - - choice = resolve_asset_choice( - host, resolved_tag, published_repo, published_release_tag + requested_tag, plans = resolve_install_release_plans( + llama_tag, + host, + published_repo, + published_release_tag, ) - approved_attempts = apply_approved_hashes([choice], checksums) - if choice.selection_log: - log_lines(choice.selection_log) - return requested_tag, resolved_tag, approved_attempts, checksums + if not plans: + raise PrebuiltFallback("no prebuilt release plans were available") + plan = plans[0] + return requested_tag, plan.llama_tag, plan.attempts, plan.approved_checksums + + +def resolve_install_release_plans( + llama_tag: str, + host: HostInfo, + published_repo: str, + published_release_tag: str, + *, + max_release_fallbacks: int = DEFAULT_MAX_PREBUILT_RELEASE_FALLBACKS, +) -> tuple[str, list[InstallReleasePlan]]: + requested_tag = normalized_requested_llama_tag(llama_tag) + allow_older_release_fallback = ( + requested_tag == "latest" and not published_release_tag + ) + release_limit = max(1, max_release_fallbacks) + plans: list[InstallReleasePlan] = [] + last_error: PrebuiltFallback | None = None + + for resolved_release in iter_resolved_published_releases( + llama_tag, + published_repo, + published_release_tag, + ): + bundle = resolved_release.bundle + checksums = resolved_release.checksums + resolved_tag = bundle.upstream_tag + try: + if host.is_linux and host.is_x86_64 and host.has_usable_nvidia: + linux_cuda_selection = resolve_linux_cuda_choice(host, bundle) + attempts = apply_approved_hashes( + linux_cuda_selection.attempts, checksums + ) + if not attempts: + raise PrebuiltFallback("no compatible Linux CUDA asset was found") + log_lines(linux_cuda_selection.selection_log) + else: + attempts = resolve_release_asset_choice( + host, + resolved_tag, + bundle, + checksums, + ) + if not attempts: + raise PrebuiltFallback("no compatible prebuilt asset was found") + if attempts[0].selection_log: + log_lines(attempts[0].selection_log) + except PrebuiltFallback as exc: + last_error = exc + if not allow_older_release_fallback: + raise + log( + "published release skipped for install planning: " + f"{bundle.repo}@{bundle.release_tag} upstream_tag={resolved_tag} ({exc})" + ) + continue + + plans.append( + InstallReleasePlan( + requested_tag = requested_tag, + llama_tag = resolved_tag, + release_tag = bundle.release_tag, + attempts = attempts, + approved_checksums = checksums, + ) + ) + + if not allow_older_release_fallback or len(plans) >= release_limit: + break + + if plans: + return requested_tag, plans + if last_error is not None: + raise last_error + raise PrebuiltFallback("no installable published llama.cpp releases were found") def write_prebuilt_metadata( @@ -3150,17 +3578,46 @@ def write_prebuilt_metadata( *, requested_tag: str, llama_tag: str, + release_tag: str, choice: AssetChoice, + approved_checksums: ApprovedReleaseChecksums, prebuilt_fallback_used: bool, ) -> None: + source_archive = approved_checksums.artifacts.get( + source_archive_logical_name(llama_tag) + ) + source_sha256 = source_archive.sha256 if source_archive is not None else None + fingerprint_payload = { + "published_repo": approved_checksums.repo, + "release_tag": release_tag, + "upstream_tag": llama_tag, + "asset": choice.name, + "asset_sha256": choice.expected_sha256, + "source": choice.source_label, + "source_sha256": source_sha256, + "runtime_line": choice.runtime_line, + "bundle_profile": choice.bundle_profile, + "coverage_class": choice.coverage_class, + } + fingerprint = hashlib.sha256( + json.dumps(fingerprint_payload, sort_keys = True, separators = (",", ":")).encode( + "utf-8" + ) + ).hexdigest() metadata = { "requested_tag": requested_tag, "tag": llama_tag, + "release_tag": release_tag, + "published_repo": approved_checksums.repo, "asset": choice.name, + "asset_sha256": choice.expected_sha256, "source": choice.source_label, + "source_sha256": source_sha256, + "source_commit": approved_checksums.source_commit, "bundle_profile": choice.bundle_profile, "runtime_line": choice.runtime_line, "coverage_class": choice.coverage_class, + "install_fingerprint": fingerprint, "prebuilt_fallback_used": prebuilt_fallback_used, "installed_at_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), } @@ -3169,6 +3626,178 @@ def write_prebuilt_metadata( ) +def expected_install_fingerprint( + *, + llama_tag: str, + release_tag: str, + choice: AssetChoice, + approved_checksums: ApprovedReleaseChecksums, +) -> str | None: + if not choice.expected_sha256: + return None + source_archive = approved_checksums.artifacts.get( + source_archive_logical_name(llama_tag) + ) + source_sha256 = source_archive.sha256 if source_archive is not None else None + payload = { + "published_repo": approved_checksums.repo, + "release_tag": release_tag, + "upstream_tag": llama_tag, + "asset": choice.name, + "asset_sha256": choice.expected_sha256, + "source": choice.source_label, + "source_sha256": source_sha256, + "runtime_line": choice.runtime_line, + "bundle_profile": choice.bundle_profile, + "coverage_class": choice.coverage_class, + } + return hashlib.sha256( + json.dumps(payload, sort_keys = True, separators = (",", ":")).encode("utf-8") + ).hexdigest() + + +def load_prebuilt_metadata(install_dir: Path) -> dict[str, Any] | None: + metadata_path = install_dir / "UNSLOTH_PREBUILT_INFO.json" + if not metadata_path.is_file(): + return None + try: + payload = json.loads(metadata_path.read_text(encoding = "utf-8")) + except Exception: + return None + if not isinstance(payload, dict): + return None + return payload + + +def runtime_payload_health_groups(choice: AssetChoice) -> list[list[str]]: + if choice.install_kind == "linux-cpu": + return [ + ["libllama.so*"], + ["libggml.so*"], + ["libggml-base.so*"], + ["libggml-cpu-*.so*"], + ["libmtmd.so*"], + ] + if choice.install_kind == "linux-cuda": + return [ + ["libllama.so*"], + ["libggml.so*"], + ["libggml-base.so*"], + ["libggml-cpu-*.so*"], + ["libmtmd.so*"], + ["libggml-cuda.so*"], + ] + if choice.install_kind in {"macos-arm64", "macos-x64"}: + return [ + ["libllama*.dylib"], + ["libggml*.dylib"], + ["libmtmd*.dylib"], + ] + if choice.install_kind == "windows-cpu": + return [["llama.dll"]] + if choice.install_kind == "windows-cuda": + return [["llama.dll"], ["ggml-cuda.dll"]] + return [] + + +def install_runtime_dir(install_dir: Path, host: HostInfo) -> Path: + if host.is_windows: + return install_dir / "build" / "bin" / "Release" + return install_dir / "build" / "bin" + + +def runtime_payload_is_healthy( + install_dir: Path, host: HostInfo, choice: AssetChoice +) -> bool: + runtime_dir = install_runtime_dir(install_dir, host) + if not runtime_dir.exists(): + return False + for pattern_group in runtime_payload_health_groups(choice): + matched = False + for pattern in pattern_group: + if any(runtime_dir.glob(pattern)): + matched = True + break + if not matched: + return False + return True + + +def existing_install_matches_choice( + install_dir: Path, + host: HostInfo, + *, + llama_tag: str, + release_tag: str, + choice: AssetChoice, + approved_checksums: ApprovedReleaseChecksums, +) -> bool: + if not install_dir.exists(): + return False + + metadata = load_prebuilt_metadata(install_dir) + if metadata is None: + return False + + if not runtime_payload_is_healthy(install_dir, host, choice): + return False + + # Verify primary executables still exist (catches partial deletion) + runtime_dir = install_runtime_dir(install_dir, host) + ext = ".exe" if host.is_windows else "" + for binary in ("llama-server", "llama-quantize"): + if not (runtime_dir / f"{binary}{ext}").exists(): + return False + expected_fingerprint = expected_install_fingerprint( + llama_tag = llama_tag, + release_tag = release_tag, + choice = choice, + approved_checksums = approved_checksums, + ) + if not expected_fingerprint: + return False + + recorded_fingerprint = metadata.get("install_fingerprint") + if not isinstance(recorded_fingerprint, str) or not recorded_fingerprint: + return False + + if recorded_fingerprint != expected_fingerprint: + return False + + expected_pairs = { + "release_tag": release_tag, + "published_repo": approved_checksums.repo, + "tag": llama_tag, + "asset": choice.name, + "asset_sha256": choice.expected_sha256, + "source": choice.source_label, + "runtime_line": choice.runtime_line, + "bundle_profile": choice.bundle_profile, + "coverage_class": choice.coverage_class, + } + for key, expected in expected_pairs.items(): + if metadata.get(key) != expected: + return False + return True + + +def existing_install_matches_plan( + install_dir: Path, + host: HostInfo, + plan: InstallReleasePlan, +) -> bool: + if not plan.attempts: + return False + return existing_install_matches_choice( + install_dir, + host, + llama_tag = plan.llama_tag, + release_tag = plan.release_tag, + choice = plan.attempts[0], + approved_checksums = plan.approved_checksums, + ) + + def validate_prebuilt_choice( choice: AssetChoice, host: HostInfo, @@ -3178,6 +3807,7 @@ def validate_prebuilt_choice( *, requested_tag: str, llama_tag: str, + release_tag: str, approved_checksums: ApprovedReleaseChecksums, prebuilt_fallback_used: bool, quantized_path: Path, @@ -3206,7 +3836,9 @@ def validate_prebuilt_choice( install_dir, requested_tag = requested_tag, llama_tag = llama_tag, + release_tag = release_tag, choice = choice, + approved_checksums = approved_checksums, prebuilt_fallback_used = prebuilt_fallback_used, ) validate_quantize( @@ -3237,13 +3869,16 @@ def validate_prebuilt_attempts( *, requested_tag: str, llama_tag: str, + release_tag: str, approved_checksums: ApprovedReleaseChecksums, + initial_fallback_used: bool = False, + existing_install_dir: Path | None = None, ) -> tuple[AssetChoice, Path, bool]: attempt_list = list(attempts) if not attempt_list: raise PrebuiltFallback("no prebuilt bundle attempts were available") - tried_fallback = False + tried_fallback = initial_fallback_used for index, attempt in enumerate(attempt_list): if index > 0: tried_fallback = True @@ -3253,6 +3888,20 @@ def validate_prebuilt_attempts( f"runtime_line={attempt.runtime_line} coverage_class={attempt.coverage_class}" ) + if existing_install_dir is not None and existing_install_matches_choice( + existing_install_dir, + host, + llama_tag = llama_tag, + release_tag = release_tag, + choice = attempt, + approved_checksums = approved_checksums, + ): + log( + "existing llama.cpp install already matches fallback candidate " + f"{attempt.name}; skipping reinstall" + ) + raise ExistingInstallSatisfied(attempt, tried_fallback) + staging_dir = create_install_staging_dir(install_dir) quantized_path = work_dir / f"stories260K-q4-{index}.gguf" if quantized_path.exists(): @@ -3266,6 +3915,7 @@ def validate_prebuilt_attempts( probe_path, requested_tag = requested_tag, llama_tag = llama_tag, + release_tag = release_tag, approved_checksums = approved_checksums, prebuilt_fallback_used = tried_fallback, quantized_path = quantized_path, @@ -3307,42 +3957,81 @@ def install_prebuilt( log( f"no existing llama.cpp install detected at {install_dir}; performing fresh prebuilt install" ) - requested_tag, llama_tag, attempts, approved_checksums = ( - resolve_install_attempts( - llama_tag, - host, - published_repo, - published_release_tag, + requested_tag, release_plans = resolve_install_release_plans( + llama_tag, + host, + published_repo, + published_release_tag, + ) + if release_plans and existing_install_matches_plan( + install_dir, host, release_plans[0] + ): + current = release_plans[0] + log( + "existing llama.cpp install already matches selected release " + f"{current.release_tag} upstream_tag={current.llama_tag}; skipping download and install" ) - ) - choice = attempts[0] - log( - f"selected {choice.name} ({choice.source_label}) for {host.system} {host.machine}" - ) + return with tempfile.TemporaryDirectory(prefix = "unsloth-llama-prebuilt-") as tmp: work_dir = Path(tmp) probe_path = work_dir / "stories260K.gguf" download_validation_model( probe_path, validation_model_cache_path(install_dir) ) - choice, selected_staging_dir, _ = validate_prebuilt_attempts( - attempts, - host, - install_dir, - work_dir, - probe_path, - requested_tag = requested_tag, - llama_tag = llama_tag, - approved_checksums = approved_checksums, - ) - activate_install_tree(selected_staging_dir, install_dir, host) - try: - ensure_converter_scripts(install_dir, llama_tag) - except Exception as exc: + release_count = len(release_plans) + for release_index, plan in enumerate(release_plans): + choice = plan.attempts[0] + if existing_install_matches_plan(install_dir, host, plan): + log( + "existing llama.cpp install already matches fallback release " + f"{plan.release_tag} upstream_tag={plan.llama_tag}; skipping reinstall" + ) + return log( - "converter script fetch failed after activation; install remains valid " - f"({textwrap.shorten(str(exc), width = 200, placeholder = '...')})" + "selected " + f"{choice.name} ({choice.source_label}) from published release " + f"{plan.release_tag} for {host.system} {host.machine}" ) + try: + choice, selected_staging_dir, _ = validate_prebuilt_attempts( + plan.attempts, + host, + install_dir, + work_dir, + probe_path, + requested_tag = requested_tag, + llama_tag = plan.llama_tag, + release_tag = plan.release_tag, + approved_checksums = plan.approved_checksums, + initial_fallback_used = release_index > 0, + existing_install_dir = install_dir, + ) + except ExistingInstallSatisfied: + return + except PrebuiltFallback as exc: + if release_index == release_count - 1: + raise + log( + "published release " + f"{plan.release_tag} upstream_tag={plan.llama_tag} failed; " + "trying the next older published prebuilt " + f"({textwrap.shorten(str(exc), width = 200, placeholder = '...')})" + ) + continue + + activate_install_tree(selected_staging_dir, install_dir, host) + try: + ensure_converter_scripts(install_dir, plan.llama_tag) + except Exception as exc: + log( + "converter script fetch failed after activation; install remains valid " + f"({textwrap.shorten(str(exc), width = 200, placeholder = '...')})" + ) + return + except BusyInstallConflict as exc: + log("prebuilt install path is blocked by an in-use llama.cpp install") + log(f"prebuilt busy reason: {exc}") + raise SystemExit(EXIT_BUSY) from exc except PrebuiltFallback as exc: log("prebuilt install path failed; falling back to source build") log(f"prebuilt fallback reason: {exc}") @@ -3359,7 +4048,10 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "--llama-tag", default = DEFAULT_LLAMA_TAG, - help = f"llama.cpp release tag. Prebuilt installs are pinned to the approved tag {APPROVED_PREBUILT_LLAMA_TAG}.", + help = ( + "llama.cpp release tag. Defaults to the latest usable published Unsloth " + "release unless UNSLOTH_LLAMA_TAG overrides it." + ), ) parser.add_argument( "--published-repo", @@ -3369,7 +4061,10 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "--published-release-tag", default = DEFAULT_PUBLISHED_TAG, - help = "Published GitHub release tag to pin. By default, scan releases until a compatible llama.cpp bundle is found.", + help = ( + "Published GitHub release tag to pin. By default, scan releases " + "until a usable published llama.cpp release bundle is found." + ), ) resolve_group = parser.add_mutually_exclusive_group() resolve_group.add_argument( @@ -3382,7 +4077,10 @@ def parse_args() -> argparse.Namespace: "--resolve-install-tag", nargs = "?", const = "latest", - help = "Resolve a llama.cpp tag such as 'latest' to the concrete tag installable on the current host.", + help = ( + "Resolve a llama.cpp tag such as 'latest' to the concrete upstream tag " + "selected by the current published-release policy." + ), ) return parser.parse_args() @@ -3398,7 +4096,9 @@ def main() -> int: if args.resolve_install_tag is not None: print( resolve_requested_install_tag( - args.resolve_install_tag, args.published_release_tag or "" + args.resolve_install_tag, + args.published_release_tag or "", + args.published_repo, ) ) return EXIT_SUCCESS @@ -3421,6 +4121,11 @@ if __name__ == "__main__": raise SystemExit(main()) except SystemExit: raise + except BusyInstallConflict as exc: + log( + f"fatal helper busy conflict: {textwrap.shorten(str(exc), width = 400, placeholder = '...')}" + ) + raise SystemExit(EXIT_BUSY) except Exception as exc: message = textwrap.shorten(str(exc), width = 400, placeholder = "...") log(f"fatal helper error: {message}") diff --git a/studio/setup.ps1 b/studio/setup.ps1 index aa3c11a594..f4ad42d615 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -22,6 +22,14 @@ $ErrorActionPreference = "Stop" $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $PackageDir = Split-Path -Parent $ScriptDir +# -------------------------------------------------------------------------- +# Maintainer-editable defaults +# Change these in the GitHub-hosted script so users get updated defaults. +# User env vars always override these baked-in values. +# -------------------------------------------------------------------------- +$DefaultLlamaPrForce = "" +$DefaultLlamaSource = "https://github.com/ggml-org/llama.cpp" + # Verbose can be enabled either by CLI flag or by UNSLOTH_VERBOSE=1. $script:UnslothVerbose = ($env:UNSLOTH_VERBOSE -eq '1') foreach ($a in $args) { @@ -1591,41 +1599,76 @@ $NeedLlamaSourceBuild = $false $SkipPrebuiltInstall = $false $RequestedLlamaTag = if ($env:UNSLOTH_LLAMA_TAG) { $env:UNSLOTH_LLAMA_TAG } else { "latest" } $HelperReleaseRepo = if ($env:UNSLOTH_LLAMA_RELEASE_REPO) { $env:UNSLOTH_LLAMA_RELEASE_REPO } else { "unslothai/llama.cpp" } -$resolveOutput = & python "$PSScriptRoot\install_llama_prebuilt.py" --resolve-install-tag $RequestedLlamaTag --published-repo $HelperReleaseRepo 2>&1 -$resolveExit = $LASTEXITCODE -$ResolvedLlamaTag = if ($resolveOutput) { ($resolveOutput | Select-Object -Last 1).ToString().Trim() } else { "" } -if ($resolveExit -ne 0 -or [string]::IsNullOrWhiteSpace($ResolvedLlamaTag)) { - Write-Host "" - substep "Failed to resolve an installable prebuilt llama.cpp tag via $HelperReleaseRepo" "Yellow" - Write-LlamaFailureLog -Output ($resolveOutput | Out-String) - # Resolve the llama.cpp tag for source-build fallback. Pass --published-repo - # so the resolver prefers Unsloth's tested tag (e.g. b8508) over the upstream - # bleeding-edge tag (e.g. b8514) from ggml-org/llama.cpp. - $fallbackOutput = & python "$PSScriptRoot\install_llama_prebuilt.py" --resolve-llama-tag $RequestedLlamaTag --published-repo $HelperReleaseRepo 2>$null - $fallbackExit = $LASTEXITCODE - $ResolvedLlamaTag = if ($fallbackExit -eq 0 -and $fallbackOutput) { - ($fallbackOutput | Select-Object -Last 1).ToString().Trim() - } elseif ($RequestedLlamaTag -eq "latest") { - # Try Unsloth release repo first, then fall back to ggml-org upstream - $resolvedLatest = $null - try { - $latestRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/$HelperReleaseRepo/releases/latest" -ErrorAction Stop - $resolvedLatest = $latestRelease.tag_name - } catch {} - if (-not $resolvedLatest) { - try { - $latestRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/ggml-org/llama.cpp/releases/latest" -ErrorAction Stop - $resolvedLatest = $latestRelease.tag_name - } catch {} - } - if ($resolvedLatest) { $resolvedLatest } else { $RequestedLlamaTag } - } else { - $RequestedLlamaTag - } +$LlamaPr = if ($env:UNSLOTH_LLAMA_PR) { $env:UNSLOTH_LLAMA_PR.Trim() } else { "" } + +$LlamaPrForce = if ($env:UNSLOTH_LLAMA_PR_FORCE) { $env:UNSLOTH_LLAMA_PR_FORCE.Trim() } else { $DefaultLlamaPrForce } +$LlamaSource = if ($env:UNSLOTH_LLAMA_SOURCE) { $env:UNSLOTH_LLAMA_SOURCE.Trim() } else { $DefaultLlamaSource } +if ($LlamaSource.EndsWith('.git')) { $LlamaSource = $LlamaSource.Substring(0, $LlamaSource.Length - 4) } + +if ($LlamaSource -ne "https://github.com/ggml-org/llama.cpp") { + step "llama.cpp" "custom source: $LlamaSource -- forcing source build" "Yellow" $NeedLlamaSourceBuild = $true $SkipPrebuiltInstall = $true } +if (-not $LlamaPr -and $LlamaPrForce -and $LlamaPrForce -match '^\d+$' -and [int]$LlamaPrForce -gt 0) { + $LlamaPr = $LlamaPrForce + step "llama.cpp" "baked-in PR_FORCE=$LlamaPrForce" "Yellow" +} + +if ($LlamaPr) { + if ($LlamaPr -notmatch '^\d+$' -or [int]$LlamaPr -le 0) { + Write-Host "[ERROR] UNSLOTH_LLAMA_PR=$LlamaPr is not a valid PR number" -ForegroundColor Red + exit 1 + } + step "llama.cpp" "UNSLOTH_LLAMA_PR=$LlamaPr -- will build from PR head" "Yellow" + $ResolvedLlamaTag = "pr-$LlamaPr" + $NeedLlamaSourceBuild = $true + $SkipPrebuiltInstall = $true +} elseif ($SkipPrebuiltInstall) { + # Custom source or other override already forced source build; skip the + # prebuilt release resolution. When building from a custom fork, the fork + # may not carry upstream bNNNN tags. + if ($LlamaSource -eq "https://github.com/ggml-org/llama.cpp") { + $resolveTagArgs = @("--resolve-llama-tag", $RequestedLlamaTag, "--published-repo", $HelperReleaseRepo) + if ($env:UNSLOTH_LLAMA_RELEASE_TAG) { $resolveTagArgs += @("--published-release-tag", $env:UNSLOTH_LLAMA_RELEASE_TAG) } + $fallbackOutput = & python "$PSScriptRoot\install_llama_prebuilt.py" @resolveTagArgs 2>$null + $fallbackExit = $LASTEXITCODE + $ResolvedLlamaTag = if ($fallbackExit -eq 0 -and $fallbackOutput) { + ($fallbackOutput | Select-Object -Last 1).ToString().Trim() + } else { + $RequestedLlamaTag + } + } else { + $ResolvedLlamaTag = $RequestedLlamaTag + } +} else { + $resolveInstallArgs = @("--resolve-install-tag", $RequestedLlamaTag, "--published-repo", $HelperReleaseRepo) + if ($env:UNSLOTH_LLAMA_RELEASE_TAG) { $resolveInstallArgs += @("--published-release-tag", $env:UNSLOTH_LLAMA_RELEASE_TAG) } + $resolveOutput = & python "$PSScriptRoot\install_llama_prebuilt.py" @resolveInstallArgs 2>&1 + $resolveExit = $LASTEXITCODE + $ResolvedLlamaTag = if ($resolveOutput) { ($resolveOutput | Select-Object -Last 1).ToString().Trim() } else { "" } + if ($resolveExit -ne 0 -or [string]::IsNullOrWhiteSpace($ResolvedLlamaTag)) { + Write-Host "" + substep "Failed to resolve a published llama.cpp release via $HelperReleaseRepo" "Yellow" + Write-LlamaFailureLog -Output ($resolveOutput | Out-String) + # Resolve the llama.cpp tag for source-build fallback. Pass --published-repo + # so the resolver prefers the latest usable Unsloth-published upstream tag + # before falling back to the bleeding-edge ggml-org/llama.cpp tag. + $resolveFallbackArgs = @("--resolve-llama-tag", $RequestedLlamaTag, "--published-repo", $HelperReleaseRepo) + if ($env:UNSLOTH_LLAMA_RELEASE_TAG) { $resolveFallbackArgs += @("--published-release-tag", $env:UNSLOTH_LLAMA_RELEASE_TAG) } + $fallbackOutput = & python "$PSScriptRoot\install_llama_prebuilt.py" @resolveFallbackArgs 2>$null + $fallbackExit = $LASTEXITCODE + $ResolvedLlamaTag = if ($fallbackExit -eq 0 -and $fallbackOutput) { + ($fallbackOutput | Select-Object -Last 1).ToString().Trim() + } else { + $RequestedLlamaTag + } + $NeedLlamaSourceBuild = $true + $SkipPrebuiltInstall = $true + } +} + Write-Host "" substep "Resolved llama.cpp release tag: $ResolvedLlamaTag" @@ -1645,7 +1688,7 @@ if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") { $prebuiltArgs = @( "$PSScriptRoot\install_llama_prebuilt.py", "--install-dir", $LlamaCppDir, - "--llama-tag", $ResolvedLlamaTag, + "--llama-tag", $RequestedLlamaTag, "--published-repo", $HelperReleaseRepo ) if ($env:UNSLOTH_LLAMA_RELEASE_TAG) { @@ -1667,7 +1710,19 @@ if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") { $ErrorActionPreference = $prevEAPPrebuilt if ($prebuiltExit -eq 0) { - step "llama.cpp" "prebuilt installed and validated" + if ($prebuiltOutput -match "already matches") { + step "llama.cpp" "prebuilt up to date and validated" + } else { + step "llama.cpp" "prebuilt installed and validated" + } + } elseif ($prebuiltExit -eq 3) { + step "llama.cpp" "install blocked by active llama.cpp process" "Yellow" + Write-LlamaFailureLog -Output $prebuiltOutput + if (Test-Path $LlamaCppDir) { + substep "Existing install was restored" "Yellow" + } + substep "Close Studio or other llama.cpp users and retry" "Yellow" + exit 3 } else { step "llama.cpp" "prebuilt install failed (continuing)" "Yellow" Write-LlamaFailureLog -Output $prebuiltOutput @@ -1826,36 +1881,88 @@ if (-not $NeedLlamaSourceBuild) { if (Test-Path (Join-Path $LlamaCppDir ".git")) { Write-Host " Syncing llama.cpp to $ResolvedLlamaTag..." -ForegroundColor Gray - if ($UseConcreteRef) { + # Always sync the remote URL so switching between default/fork sources works + Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir remote set-url origin "$LlamaSource.git" } | Out-Null + if ($LlamaPr) { + $gitFetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir fetch --depth 1 origin "pull/$LlamaPr/head" } + if ($gitFetchExit -ne 0) { + $BuildOk = $false + $FailedStep = "git fetch PR #$LlamaPr" + } else { + $gitCheckoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir checkout -B "pr-$LlamaPr" FETCH_HEAD } + if ($gitCheckoutExit -ne 0) { + $BuildOk = $false + $FailedStep = "git checkout PR #$LlamaPr" + } else { + Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir clean -fdx } | Out-Null + } + } + } elseif ($UseConcreteRef) { $gitFetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir fetch --depth 1 origin $ResolvedLlamaTag } + if ($gitFetchExit -ne 0) { + substep "git fetch failed -- using existing source" "Yellow" + } else { + $gitCheckoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir checkout -B unsloth-llama-build FETCH_HEAD } + if ($gitCheckoutExit -ne 0) { + $BuildOk = $false + $FailedStep = "git checkout" + } else { + Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir clean -fdx } | Out-Null + } + } } else { $gitFetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir fetch --depth 1 origin } - } - if ($gitFetchExit -ne 0) { - substep "git fetch failed -- using existing source" "Yellow" - } else { - $gitCheckoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir checkout -B unsloth-llama-build FETCH_HEAD } - if ($gitCheckoutExit -ne 0) { - $BuildOk = $false - $FailedStep = "git checkout" + if ($gitFetchExit -ne 0) { + substep "git fetch failed -- using existing source" "Yellow" } else { - Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir clean -fdx } | Out-Null + $gitCheckoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir checkout -B unsloth-llama-build FETCH_HEAD } + if ($gitCheckoutExit -ne 0) { + $BuildOk = $false + $FailedStep = "git checkout" + } else { + Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir clean -fdx } | Out-Null + } } } } else { Write-Host " Cloning llama.cpp @ $ResolvedLlamaTag..." -ForegroundColor Gray $buildTmp = "$LlamaCppDir.build.$PID" if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp } - $cloneArgs = @("clone", "--depth", "1") - if ($UseConcreteRef) { - $cloneArgs += @("--branch", $ResolvedLlamaTag) - } - $cloneArgs += @("https://github.com/ggml-org/llama.cpp.git", $buildTmp) - $cloneExit = Invoke-SetupCommand -AlwaysQuiet { git @cloneArgs } - if ($cloneExit -ne 0) { - $BuildOk = $false - $FailedStep = "git clone" - if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp } + if ($LlamaPr) { + $cloneExit = Invoke-SetupCommand -AlwaysQuiet { git clone --depth 1 "$LlamaSource.git" $buildTmp } + if ($cloneExit -ne 0) { + $BuildOk = $false + $FailedStep = "git clone" + if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp } + } + if ($BuildOk) { + $fetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $buildTmp fetch --depth 1 origin "pull/$LlamaPr/head:pr-$LlamaPr" } + if ($fetchExit -ne 0) { + $BuildOk = $false + $FailedStep = "git fetch PR #$LlamaPr" + if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp } + } + } + if ($BuildOk) { + $checkoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $buildTmp checkout "pr-$LlamaPr" } + if ($checkoutExit -ne 0) { + $BuildOk = $false + $FailedStep = "git checkout PR #$LlamaPr" + if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp } + } + } + } else { + $cloneArgs = @("clone", "--depth", "1") + if ($UseConcreteRef) { + $cloneArgs += @("--branch", $ResolvedLlamaTag) + } + $cloneArgs += @("$LlamaSource.git", $buildTmp) + $cloneExit = Invoke-SetupCommand -AlwaysQuiet { git @cloneArgs } + if ($cloneExit -ne 0) { + $BuildOk = $false + $FailedStep = "git clone" + if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp } + } } # Use temp dir for build; swap into $LlamaCppDir only after build succeeds if ($BuildOk) { diff --git a/studio/setup.sh b/studio/setup.sh index 3715f536f6..4a3e23b1a4 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -8,6 +8,16 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" RULE=$(printf '\342\224\200%.0s' {1..52}) +# ── Maintainer-editable defaults ────────────────────────────────────────── +# Change these in the GitHub-hosted script so all users get updated defaults. +# User environment variables always override these baked-in values. +# +# _DEFAULT_LLAMA_PR_FORCE : PR number to build by default ("" = normal path) +# _DEFAULT_LLAMA_SOURCE : git clone URL for source builds +# ────────────────────────────────────────────────────────────────────────── +_DEFAULT_LLAMA_PR_FORCE="" +_DEFAULT_LLAMA_SOURCE="https://github.com/ggml-org/llama.cpp" + # ── Colors (same palette as startup_banner / install_python_stack) ── if [ -n "${NO_COLOR:-}" ]; then C_TITLE= C_DIM= C_OK= C_WARN= C_ERR= C_RST= @@ -108,6 +118,10 @@ echo "" printf " ${C_TITLE}%s${C_RST}\n" "🦥 Unsloth Studio Setup" printf " ${C_DIM}%s${C_RST}\n" "$RULE" verbose_substep "verbose diagnostics enabled" +_LLAMA_ONLY="${UNSLOTH_STUDIO_LLAMA_ONLY:-0}" +if [ "$_LLAMA_ONLY" = "1" ]; then + substep "llama.cpp only mode" +fi # ── Clean up stale caches ── rm -rf "$REPO_ROOT/unsloth_compiled_cache" rm -rf "$SCRIPT_DIR/backend/unsloth_compiled_cache" @@ -120,6 +134,7 @@ if [[ "$keynames" == *$'\nCOLAB_'* ]]; then IS_COLAB=true fi +if [ "$_LLAMA_ONLY" != "1" ]; then # ── Frontend ── _NEED_FRONTEND_BUILD=true if [ -d "$SCRIPT_DIR/frontend/dist" ]; then @@ -453,6 +468,7 @@ else step "python" "dependencies up to date" verbose_substep "python deps check: installed=$_PKG_NAME@${INSTALLED_VER:-unknown} latest=${LATEST_VER:-unknown}" fi +fi # ── 7. Prefer prebuilt llama.cpp bundles before any source build path ── UNSLOTH_HOME="$HOME/.unsloth" @@ -464,44 +480,93 @@ _LLAMA_CPP_DEGRADED=false _LLAMA_FORCE_COMPILE="${UNSLOTH_LLAMA_FORCE_COMPILE:-0}" _REQUESTED_LLAMA_TAG="${UNSLOTH_LLAMA_TAG:-latest}" _HELPER_RELEASE_REPO="${UNSLOTH_LLAMA_RELEASE_REPO:-unslothai/llama.cpp}" -_RESOLVE_LLAMA_LOG="$(mktemp)" -set +e -python "$SCRIPT_DIR/install_llama_prebuilt.py" \ - --resolve-install-tag "$_REQUESTED_LLAMA_TAG" \ - --published-repo "$_HELPER_RELEASE_REPO" >"$_RESOLVE_LLAMA_LOG" 2>&1 -_RESOLVE_LLAMA_STATUS=$? -set -e -if [ "$_RESOLVE_LLAMA_STATUS" -eq 0 ]; then - _RESOLVED_LLAMA_TAG="$(tail -n 1 "$_RESOLVE_LLAMA_LOG" | tr -d '\r')" -else - _RESOLVED_LLAMA_TAG="" -fi -if [ -z "$_RESOLVED_LLAMA_TAG" ]; then - step "llama.cpp" "failed to resolve prebuilt tag via $_HELPER_RELEASE_REPO" "$C_WARN" - print_llama_error_log "$_RESOLVE_LLAMA_LOG" - set +e - # Resolve the llama.cpp tag for source-build fallback. Pass --published-repo - # so the resolver prefers Unsloth's tested tag (e.g. b8508) over the upstream - # bleeding-edge tag (e.g. b8514) from ggml-org/llama.cpp. - _RESOLVED_LLAMA_TAG="$(python "$SCRIPT_DIR/install_llama_prebuilt.py" --resolve-llama-tag "$_REQUESTED_LLAMA_TAG" --published-repo "$_HELPER_RELEASE_REPO" 2>/dev/null)" - _RESOLVE_UPSTREAM_STATUS=$? - set -e - if [ "$_RESOLVE_UPSTREAM_STATUS" -ne 0 ] || [ -z "$_RESOLVED_LLAMA_TAG" ]; then - if [ "$_REQUESTED_LLAMA_TAG" = "latest" ]; then - # Try Unsloth release repo first, then fall back to ggml-org upstream - _RESOLVED_LLAMA_TAG="$(curl -fsSL "https://api.github.com/repos/${_HELPER_RELEASE_REPO}/releases/latest" 2>/dev/null | python -c "import sys,json; print(json.load(sys.stdin)['tag_name'])" 2>/dev/null)" || _RESOLVED_LLAMA_TAG="" - if [ -z "$_RESOLVED_LLAMA_TAG" ]; then - _RESOLVED_LLAMA_TAG="$(curl -fsSL https://api.github.com/repos/ggml-org/llama.cpp/releases/latest 2>/dev/null | python -c "import sys,json; print(json.load(sys.stdin)['tag_name'])" 2>/dev/null)" || _RESOLVED_LLAMA_TAG="" - fi - fi - if [ -z "$_RESOLVED_LLAMA_TAG" ]; then - _RESOLVED_LLAMA_TAG="$_REQUESTED_LLAMA_TAG" - fi - fi +_LLAMA_PR="${UNSLOTH_LLAMA_PR:-}" + +_LLAMA_PR_FORCE="${UNSLOTH_LLAMA_PR_FORCE:-${_DEFAULT_LLAMA_PR_FORCE}}" +_LLAMA_SOURCE="${UNSLOTH_LLAMA_SOURCE:-${_DEFAULT_LLAMA_SOURCE}}" +_LLAMA_SOURCE="${_LLAMA_SOURCE%.git}" # normalize: strip trailing .git + +# Non-default source URL forces source build (fork has different code than prebuilt). +if [ "$_LLAMA_SOURCE" != "https://github.com/ggml-org/llama.cpp" ]; then + step "llama.cpp" "custom source: $_LLAMA_SOURCE -- forcing source build" "$C_WARN" _NEED_LLAMA_SOURCE_BUILD=true _SKIP_PREBUILT_INSTALL=true fi -rm -f "$_RESOLVE_LLAMA_LOG" + +# Baked-in PR_FORCE promotes to _LLAMA_PR when user hasn't set one. +if [ -z "$_LLAMA_PR" ] && [ -n "$_LLAMA_PR_FORCE" ] && \ + [[ "$_LLAMA_PR_FORCE" =~ ^[0-9]+$ ]] && [ "$_LLAMA_PR_FORCE" -gt 0 ]; then + _LLAMA_PR="$_LLAMA_PR_FORCE" + step "llama.cpp" "baked-in PR_FORCE=$_LLAMA_PR_FORCE" "$C_WARN" +fi + +if [ -n "$_LLAMA_PR" ]; then + if ! [[ "$_LLAMA_PR" =~ ^[0-9]+$ ]] || [ "$_LLAMA_PR" -le 0 ]; then + step "llama.cpp" "UNSLOTH_LLAMA_PR=$_LLAMA_PR is not a valid PR number" "$C_ERR" + exit 1 + fi + step "llama.cpp" "UNSLOTH_LLAMA_PR=$_LLAMA_PR -- will build from PR head" "$C_WARN" + _RESOLVED_LLAMA_TAG="pr-$_LLAMA_PR" + _NEED_LLAMA_SOURCE_BUILD=true + _SKIP_PREBUILT_INSTALL=true +elif [ "${_SKIP_PREBUILT_INSTALL:-false}" = true ]; then + # Custom source or other override already forced source build; skip + # the prebuilt release resolution entirely. When building from a custom + # fork, the fork may not carry upstream bNNNN tags, so resolve the tag + # only when the source is the default ggml-org repo. + if [ "$_LLAMA_SOURCE" = "https://github.com/ggml-org/llama.cpp" ]; then + _RESOLVE_TAG_ARGS=(--resolve-llama-tag "$_REQUESTED_LLAMA_TAG" --published-repo "$_HELPER_RELEASE_REPO") + if [ -n "${UNSLOTH_LLAMA_RELEASE_TAG:-}" ]; then + _RESOLVE_TAG_ARGS+=(--published-release-tag "$UNSLOTH_LLAMA_RELEASE_TAG") + fi + set +e + _RESOLVED_LLAMA_TAG="$(python "$SCRIPT_DIR/install_llama_prebuilt.py" "${_RESOLVE_TAG_ARGS[@]}" 2>/dev/null)" + _RESOLVE_UPSTREAM_STATUS=$? + set -e + if [ "$_RESOLVE_UPSTREAM_STATUS" -ne 0 ] || [ -z "$_RESOLVED_LLAMA_TAG" ]; then + _RESOLVED_LLAMA_TAG="$_REQUESTED_LLAMA_TAG" + fi + else + _RESOLVED_LLAMA_TAG="$_REQUESTED_LLAMA_TAG" + fi +else + _RESOLVE_INSTALL_ARGS=(--resolve-install-tag "$_REQUESTED_LLAMA_TAG" --published-repo "$_HELPER_RELEASE_REPO") + if [ -n "${UNSLOTH_LLAMA_RELEASE_TAG:-}" ]; then + _RESOLVE_INSTALL_ARGS+=(--published-release-tag "$UNSLOTH_LLAMA_RELEASE_TAG") + fi + _RESOLVE_LLAMA_LOG="$(mktemp)" + set +e + python "$SCRIPT_DIR/install_llama_prebuilt.py" \ + "${_RESOLVE_INSTALL_ARGS[@]}" >"$_RESOLVE_LLAMA_LOG" 2>&1 + _RESOLVE_LLAMA_STATUS=$? + set -e + if [ "$_RESOLVE_LLAMA_STATUS" -eq 0 ]; then + _RESOLVED_LLAMA_TAG="$(tail -n 1 "$_RESOLVE_LLAMA_LOG" | tr -d '\r')" + else + _RESOLVED_LLAMA_TAG="" + fi + if [ -z "$_RESOLVED_LLAMA_TAG" ]; then + step "llama.cpp" "failed to resolve a published llama.cpp release via $_HELPER_RELEASE_REPO" "$C_WARN" + print_llama_error_log "$_RESOLVE_LLAMA_LOG" + set +e + # Resolve the llama.cpp tag for source-build fallback. Pass --published-repo + # so the resolver prefers the latest usable Unsloth-published upstream tag + # before falling back to the bleeding-edge ggml-org/llama.cpp tag. + _RESOLVE_FALLBACK_ARGS=(--resolve-llama-tag "$_REQUESTED_LLAMA_TAG" --published-repo "$_HELPER_RELEASE_REPO") + if [ -n "${UNSLOTH_LLAMA_RELEASE_TAG:-}" ]; then + _RESOLVE_FALLBACK_ARGS+=(--published-release-tag "$UNSLOTH_LLAMA_RELEASE_TAG") + fi + _RESOLVED_LLAMA_TAG="$(python "$SCRIPT_DIR/install_llama_prebuilt.py" "${_RESOLVE_FALLBACK_ARGS[@]}" 2>/dev/null)" + _RESOLVE_UPSTREAM_STATUS=$? + set -e + if [ "$_RESOLVE_UPSTREAM_STATUS" -ne 0 ] || [ -z "$_RESOLVED_LLAMA_TAG" ]; then + _RESOLVED_LLAMA_TAG="$_REQUESTED_LLAMA_TAG" + fi + _NEED_LLAMA_SOURCE_BUILD=true + _SKIP_PREBUILT_INSTALL=true + fi + rm -f "$_RESOLVE_LLAMA_LOG" +fi substep "resolved llama.cpp tag: $_RESOLVED_LLAMA_TAG" verbose_substep "requested llama.cpp tag: $_REQUESTED_LLAMA_TAG (repo: $_HELPER_RELEASE_REPO)" @@ -520,7 +585,7 @@ else _PREBUILT_CMD=( python "$SCRIPT_DIR/install_llama_prebuilt.py" --install-dir "$LLAMA_CPP_DIR" - --llama-tag "$_RESOLVED_LLAMA_TAG" + --llama-tag "$_REQUESTED_LLAMA_TAG" --published-repo "$_HELPER_RELEASE_REPO" ) if [ -n "${UNSLOTH_LLAMA_RELEASE_TAG:-}" ]; then @@ -538,9 +603,22 @@ else set -e if [ "$_PREBUILT_STATUS" -eq 0 ]; then - step "llama.cpp" "prebuilt installed and validated" + if grep -Fq "already matches" "$_PREBUILT_LOG"; then + step "llama.cpp" "prebuilt up to date and validated" + else + step "llama.cpp" "prebuilt installed and validated" + fi verbose_substep "llama.cpp install dir: $LLAMA_CPP_DIR" rm -f "$_PREBUILT_LOG" + elif [ "$_PREBUILT_STATUS" -eq 3 ]; then + step "llama.cpp" "install blocked by active llama.cpp process" "$C_WARN" + print_llama_error_log "$_PREBUILT_LOG" + rm -f "$_PREBUILT_LOG" + if [ -d "$LLAMA_CPP_DIR" ]; then + substep "existing install was restored" + fi + substep "close Studio or other llama.cpp users and retry" + exit 3 else step "llama.cpp" "prebuilt install failed (continuing)" "$C_WARN" print_llama_error_log "$_PREBUILT_LOG" @@ -624,13 +702,27 @@ else [ -f "$LLAMA_SERVER_BIN" ] || _LLAMA_CPP_DEGRADED=true else BUILD_OK=true - _CLONE_BRANCH_ARGS=() - if [ "$_RESOLVED_LLAMA_TAG" != "latest" ] && [ -n "$_RESOLVED_LLAMA_TAG" ]; then - _CLONE_BRANCH_ARGS=(--branch "$_RESOLVED_LLAMA_TAG") - fi _BUILD_TMP="${LLAMA_CPP_DIR}.build.$$" rm -rf "$_BUILD_TMP" - run_quiet_no_exit "clone llama.cpp" git clone --depth 1 "${_CLONE_BRANCH_ARGS[@]}" https://github.com/ggml-org/llama.cpp.git "$_BUILD_TMP" || BUILD_OK=false + if [ -n "$_LLAMA_PR" ]; then + run_quiet_no_exit "clone llama.cpp" \ + git clone --depth 1 "${_LLAMA_SOURCE}.git" "$_BUILD_TMP" || BUILD_OK=false + if [ "$BUILD_OK" = true ]; then + run_quiet_no_exit "fetch PR #$_LLAMA_PR" \ + git -C "$_BUILD_TMP" fetch --depth 1 origin "pull/$_LLAMA_PR/head:pr-$_LLAMA_PR" || BUILD_OK=false + fi + if [ "$BUILD_OK" = true ]; then + run_quiet_no_exit "checkout PR #$_LLAMA_PR" \ + git -C "$_BUILD_TMP" checkout "pr-$_LLAMA_PR" || BUILD_OK=false + fi + else + _CLONE_BRANCH_ARGS=() + if [ "$_RESOLVED_LLAMA_TAG" != "latest" ] && [ -n "$_RESOLVED_LLAMA_TAG" ]; then + _CLONE_BRANCH_ARGS=(--branch "$_RESOLVED_LLAMA_TAG") + fi + run_quiet_no_exit "clone llama.cpp" \ + git clone --depth 1 "${_CLONE_BRANCH_ARGS[@]}" "${_LLAMA_SOURCE}.git" "$_BUILD_TMP" || BUILD_OK=false + fi if [ "$BUILD_OK" = true ]; then CMAKE_ARGS="-DLLAMA_BUILD_TESTS=OFF -DLLAMA_BUILD_EXAMPLES=OFF -DLLAMA_BUILD_SERVER=ON -DGGML_NATIVE=ON" @@ -794,7 +886,16 @@ else fi # end _SKIP_GGUF_BUILD check # ── Footer ── -if [ "$IS_COLAB" = true ]; then +if [ "$_LLAMA_ONLY" = "1" ]; then + echo "" + printf " ${C_DIM}%s${C_RST}\n" "$RULE" + if [ "$_LLAMA_CPP_DEGRADED" = true ]; then + printf " ${C_WARN}%s${C_RST}\n" "llama.cpp update finished (limited: llama.cpp unavailable)" + else + printf " ${C_TITLE}%s${C_RST}\n" "llama.cpp update finished" + fi + printf " ${C_DIM}%s${C_RST}\n" "$RULE" +elif [ "$IS_COLAB" = true ]; then echo "" printf " ${C_DIM}%s${C_RST}\n" "$RULE" if [ "$_LLAMA_CPP_DEGRADED" = true ]; then