mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-23 15:53:46 +00:00
* fix(studio): verify TLS against the OS trust store at runtime On macOS/Windows behind a TLS-inspecting proxy (Cisco Umbrella, Zscaler, ...), every huggingface.co request from the Studio backend fails with CERTIFICATE_VERIFY_FAILED: Python verifies against certifi's bundled Mozilla roots and ignores the OS trust store where the proxy CA lives. A terminal user can export SSL_CERT_FILE, but macOS .app / desktop-shortcut launches never read shell profiles, so GUI-launched Studio has no workaround. Inject truststore into ssl at each network-touching entry point (server, HF download worker, STT download worker, training worker) — injection is per-process and does not survive spawns. Default-on for macOS/Windows, opt-in on Linux, opt-out anywhere via UNSLOTH_STUDIO_NATIVE_TLS=0, mirroring UV_NATIVE_TLS in install.sh (#6671), whose runtime counterpart this is. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(studio): cover inference/export workers and the studio extra Review follow-ups: the inference worker resolves remote LoRA bases and the export worker pushes/loads from the Hub, both in fresh spawned interpreters that missed the injection; and pyproject's studio extra must mirror requirements/studio.txt (test_studio_extra_matches_requirements), so truststore is added there too for `pip install "unsloth[studio]"`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(studio): propagate native TLS to remaining children, gate 3.9 Second review round: the data-recipe jobs worker and the transformers -c config probe are further fresh interpreters with Hub/provider traffic, so activate in the worker and inline the gated injection in the probe script (it cannot import backend modules). uv/pip child installers do their own TLS (rustls ignores in-process injection), so activation now exports UV_SYSTEM_CERTS/UV_NATIVE_TLS, mirroring install.sh. truststore requires Python >=3.10 while the project floor is 3.9, so the dependency gets a python_version marker on both sides of the requirements mirror; activate_native_tls() already falls back to certifi where the import is unavailable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(studio): activate native TLS in diffusion trainer * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix uv TLS opt-out and remaining uncovered spawns for PR #8108 - native_tls: mirror one resolved value across UV_SYSTEM_CERTS/UV_NATIVE_TLS. uv takes either variable as an opt-in, so UV_SYSTEM_CERTS=0 alone was re-enabled by the legacy name defaulting to 1. Matches install.sh. - native_tls: warn instead of debug when injection fails, and document that explicit CA bundles become additive rather than exclusive under truststore. - model_config: activate in the _VISION_CHECK_SCRIPT child, which calls AutoConfig.from_pretrained and fails quiet, so VLM detection silently degraded behind a proxy. - prebuilt_core: activate at import so the llama.cpp / whisper.cpp installers and the resolve-prebuilt probe verify their urllib downloads the same way. - mlx_repair: forward the UV cert variables through the allowlisted child env, the one uv installer that did not inherit them. - diffusion_training_service: activate inside run_without_native_path_secret so truststore imports after the lease secret is scrubbed, and scrub the parent env before the child starts, like the other orchestrators. - tests: pin the uv mirror both ways, stop leaking the UV vars into the pytest process, assert the diffusion ordering, and add static coverage that every spawned entry point still activates. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Keep the vision probe's helper block intact for PR #8108 test_vision_subprocess_unions_sidecar_registry slices the embedded script at its first 'try:' to exec the inlined helpers, so putting the TLS activation above them left that slice empty. Move it below the helpers, still ahead of AutoConfig.from_pretrained. * Cut the native TLS log noise and pin the client-only limitation for PR #8108 Simulation findings on the branch: - A failed injection logged a full traceback at warning level. On Python 3.9, where truststore is excluded by the dependency marker on purpose, that is one stack per worker start. Now one line carrying the reason. - truststore's injection is client-side: an SSLContext built after it verifies a peer chain on every handshake, so it cannot serve TLS. Studio serves plain HTTP on loopback so nothing breaks today, but an in-process HTTPS listener added later would fail at handshake on the default-on platforms. Documented, and test_native_tls_entrypoints.py now fails if one appears. * Tighten native TLS comments * Cap truststore at the next minor truststore is pre-1.0 and 0.x minors are where it has changed verification behaviour (0.9 to 0.10), while fixes land as patches inside a minor (0.10.1, 0.10.3, 0.10.4). Since activation applies process-wide to every TLS connection, a semantic change should get a human look rather than arrive on the next install. 0.10.4 has been the current release for a year, so nothing is held back today. Mirrored in both declarations. * Generate the inline native TLS gate from native_tls.py The python -c probes and prebuilt_core.py cannot import backend modules, so each carried its own hand-written copy of the UNSLOTH_STUDIO_NATIVE_TLS gate. Three copies of the same platform defaults drift silently: a child would keep downloading against certifi while everything else used the OS store. native_tls.inline_gate_source() now renders the gate from the same constants native_tls_enabled() reads. The probes concatenate it, and prebuilt_core.py keeps a paste that test_native_tls_entrypoints.py asserts verbatim against the generated text. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Compare the prebuilt TLS gate by AST, not by text ruff-format restyles the pasted gate in prebuilt_core.py (quote style, line wrapping), so a substring assertion against inline_gate_source() fails the moment the formatter runs, as it did on 668cd9f76. Compare the parsed statements instead: still catches a real edit to the platform defaults, and ignores formatting that cannot change behaviour. * Vendor truststore instead of depending on it truststore was a new third-party dependency for every Studio user, added to fix CERTIFICATE_VERIFY_FAILED for the few behind a TLS-inspecting proxy. Nothing in Unsloth required it before, directly or transitively. It is MIT, six pure-Python files, no dependencies of its own, so vendor it at studio/backend/vendor/ and drop both declarations. pip vendors it for the same reason. utils/third_party_source.py is not an option here: it downloads over urllib at first use, which behind the very proxy this fixes would itself fail to verify. The copy has to exist before the network does. Consumers append the vendor directory to sys.path and import the top-level name, so a truststore the user installed themselves still wins. The directory is deliberately not a package: a dotted import would load the same files under a second module name, and each would wrap ssl again. test_vendored_truststore.py pins the tree against the published 0.10.4 wheel and scripts/sync_vendored_truststore.py re-syncs it, since vendored code is invisible to the dependency scanners. * Tighten native TLS comments * Drop the truststore sync script and treat the copy as static Nothing in Studio ever ran it: it only re-downloaded a wheel for a maintainer bumping the pin, which implies a refresh story this repo does not have. The vendored files are a static copy. truststore_manifest.json and test_vendored_truststore.py stay, since they are what makes them static: they pin the tree to the published 0.10.4 hashes and fail if anything edits it. A version bump now means replacing the tree and updating the manifest in the same commit, deliberately, which the README spells out. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen <moonshotaisubstack@gmail.com> Co-authored-by: danielhanchen <danielhanchen@gmail.com>
2446 lines
93 KiB
Python
2446 lines
93 KiB
Python
#!/usr/bin/env python3
|
|
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Component-agnostic machinery shared by the ggml-family prebuilt installers.
|
|
|
|
``install_llama_prebuilt.py`` and ``install_whisper_prebuilt.py`` both run on
|
|
this module, which owns two layers:
|
|
|
|
1. Primitives: HTTP fetches with retries and token-safe redirects, verified
|
|
downloads, safe archive extraction, the install lock, sha256 helpers, and
|
|
CUDA runtime-line / compute-capability selection. Moved verbatim out of
|
|
``install_llama_prebuilt.py``, which re-exports them under their old names.
|
|
|
|
2. The generic descriptor-driven install flow: release resolution (download
|
|
host fast path, GitHub API fallback), checksum-index parsing, coverage-aware
|
|
artifact selection, verified download + extraction + staged atomic
|
|
activation, marker/fingerprint handling, and the resolve probe. Whisper runs
|
|
entirely on this flow; a third component plugs in with a ``ComponentDescriptor``.
|
|
|
|
Seam rule: every function calling a collaborator tests monkeypatch takes an
|
|
``ops`` handle first. ``ops`` resolves names in the calling installer module's
|
|
globals first (so a ``monkeypatch.setattr`` still wins) and falls back to this
|
|
module's defaults, letting a descriptor-only component work with no installer
|
|
module behind it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import errno
|
|
import functools
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import random
|
|
import re
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
import zipfile
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Iterable, Iterator
|
|
|
|
try:
|
|
from filelock import FileLock, Timeout as FileLockTimeout
|
|
except ImportError:
|
|
FileLock = None
|
|
FileLockTimeout = None
|
|
|
|
# Fresh spawned interpreter, and this standalone module cannot import backend
|
|
# modules, so the gate is pasted from native_tls.py's inline_gate_source().
|
|
# test_native_tls_entrypoints.py asserts the paste still matches, by AST.
|
|
_TRUSTSTORE_VENDOR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "backend", "vendor")
|
|
_flag = os.environ.get("UNSLOTH_STUDIO_NATIVE_TLS", "").strip().lower()
|
|
if _flag in ("1", "true", "yes") or (
|
|
_flag not in ("0", "false", "no") and sys.platform in ("darwin", "win32")
|
|
):
|
|
try:
|
|
if _TRUSTSTORE_VENDOR not in sys.path:
|
|
sys.path.append(_TRUSTSTORE_VENDOR)
|
|
import truststore
|
|
truststore.inject_into_ssl()
|
|
except Exception:
|
|
pass
|
|
del _flag
|
|
|
|
|
|
class PrebuiltFallback(RuntimeError):
|
|
pass
|
|
|
|
|
|
class BusyInstallConflict(RuntimeError):
|
|
pass
|
|
|
|
|
|
# ── Defaults a component module may override via its own globals ──
|
|
USER_AGENT = "unsloth-studio-prebuilt"
|
|
GITHUB_AUTH_HOSTS = {"api.github.com", "github.com"}
|
|
HF_AUTH_HOSTS = {"huggingface.co", "www.huggingface.co"}
|
|
RETRYABLE_HTTP_STATUS = {408, 429, 500, 502, 503, 504}
|
|
HTTP_FETCH_ATTEMPTS = 4
|
|
HTTP_FETCH_BASE_DELAY_SECONDS = 0.75
|
|
JSON_FETCH_ATTEMPTS = 3
|
|
TTY_PROGRESS_START_DELAY_SECONDS = 0.5
|
|
INSTALL_LOCK_TIMEOUT_SECONDS = 300
|
|
INSTALL_STAGING_ROOT_NAME = ".staging"
|
|
SCHEMA_VERSION = 1
|
|
# Backend to retry when the preferred one has no covering asset; None disables it.
|
|
FALLBACK_BACKEND: str | None = "cpu"
|
|
_RATE_LIMIT_WAIT_CAP_SECONDS = 60.0
|
|
|
|
# Lowest CUDA major we ship prebuilts for, and highest we probe for installed
|
|
# runtime libraries. Detection and runtime-line derivation are generated per
|
|
# major, so a new toolkit (cuda14, ...) needs no code change while ggml keeps
|
|
# the cudart64_<major>.dll / libcudart.so.<major> naming.
|
|
_MIN_CUDA_MAJOR = 12
|
|
_MAX_PROBE_CUDA_MAJOR = 19
|
|
|
|
# Blackwell floor is sm_100 (B100/B200 sm_100, B300/GB300 sm_103 below consumer
|
|
# RTX 50 sm_120); the family needs toolkit >= 12.8, sm_103/sm_121 need 12.9.
|
|
_BLACKWELL_MIN_SM = 100
|
|
_BLACKWELL_MIN_TOOLKIT = (12, 8)
|
|
_BLACKWELL_SM_MIN_TOOLKIT = {103: (12, 9), 121: (12, 9)}
|
|
|
|
|
|
def log(message: str) -> None:
|
|
print(f"[prebuilt-core] {message}", file = sys.stderr)
|
|
|
|
|
|
def _cuda_runtime_lines_for_major(major: int) -> list[str]:
|
|
"""Runtime lines a driver of this CUDA major can use (its own major and any
|
|
older one, newest first down to the minimum we ship)."""
|
|
return [f"cuda{m}" for m in range(major, _MIN_CUDA_MAJOR - 1, -1)]
|
|
|
|
|
|
# ── The ops seam ──
|
|
_MISSING = object()
|
|
|
|
# Core functions expecting an ``ops`` first argument. ModuleOps binds itself when
|
|
# a lookup falls back to the core default, so a descriptor-only component gets
|
|
# working defaults while an installer wrapper (or a test monkeypatch) always wins.
|
|
_OPS_FIRST_NAMES = {
|
|
"auth_headers",
|
|
"github_api_headers",
|
|
"download_bytes",
|
|
"fetch_json",
|
|
"download_file",
|
|
"download_file_verified",
|
|
"download_file_verified_strict",
|
|
"github_release",
|
|
"github_release_assets",
|
|
"download_host_latest_release_tag",
|
|
"fetch_download_host_json",
|
|
"linux_runtime_dirs_for_required_libraries",
|
|
"detected_linux_runtime_lines",
|
|
"detected_windows_runtime_lines",
|
|
"parse_manifest",
|
|
"parse_release_checksums",
|
|
"fetch_release_checksums",
|
|
"expected_sha256_for",
|
|
"macos_min_os_ok",
|
|
"artifacts_for_host",
|
|
"select_artifact",
|
|
"select_artifact_with_fallback",
|
|
"auto_detect_backend",
|
|
"resolve_backend",
|
|
"fetch_release_bundle",
|
|
"asset_download_url",
|
|
"resolve_newest_release_tag",
|
|
"resolve_release_tag",
|
|
"resolve_release_via_download_host",
|
|
"fetch_release_for_install",
|
|
"write_prebuilt_metadata",
|
|
"load_prebuilt_metadata",
|
|
"existing_install_matches",
|
|
"metadata_path",
|
|
"selection_from_artifact",
|
|
"plan_selection",
|
|
"install_from_bundle",
|
|
"install_prebuilt",
|
|
"resolve_prebuilt",
|
|
"installed_server_path",
|
|
"assemble_install_tree",
|
|
"validate_staged_server",
|
|
"locate_server_in_tree",
|
|
# Underscored aliases the installer modules expose for their tests.
|
|
"_download_host_latest_release_tag",
|
|
"_download_host_json",
|
|
"_resolve_release_via_download_host",
|
|
"_install_from_bundle",
|
|
}
|
|
|
|
|
|
class ModuleOps:
|
|
"""Late-binding name resolver over an installer module's globals.
|
|
|
|
Lookup hits the wrapped globals first (so a ``monkeypatch.setattr`` is always
|
|
observed), then this module's defaults (ops-first defaults come back bound).
|
|
"""
|
|
|
|
def __init__(self, module_globals: dict[str, Any]) -> None:
|
|
self._globals = module_globals
|
|
|
|
def __getattr__(self, name: str) -> Any:
|
|
value = self._globals.get(name, _MISSING)
|
|
if value is _MISSING:
|
|
value = globals().get(name, _MISSING)
|
|
if value is _MISSING:
|
|
raise AttributeError(f"prebuilt component namespace has no attribute {name!r}")
|
|
if name in _OPS_FIRST_NAMES:
|
|
return functools.partial(value, self)
|
|
return value
|
|
|
|
|
|
@dataclass(frozen = True)
|
|
class ComponentDescriptor:
|
|
"""Everything the generic flow needs about one ggml-family component.
|
|
|
|
Hooks mirror the module-level names an installer defines; a descriptor-only
|
|
component supplies them here and ``component_ops`` builds a namespace with
|
|
core defaults behind them.
|
|
"""
|
|
|
|
component: str # manifest "component" value, e.g. "whisper.cpp"
|
|
log_prefix: str # stderr prefix, e.g. "whisper-prebuilt"
|
|
published_repo: str # fork publishing the prebuilt releases
|
|
manifest_asset_name: str
|
|
sha256_asset_name: str
|
|
metadata_filename: str # marker written into the install dir
|
|
user_agent: str
|
|
supported_backends: tuple[str, ...] = ("cpu", "cuda", "metal", "vulkan", "rocm")
|
|
schema_version: int = SCHEMA_VERSION
|
|
# Backend to retry when the preferred one has no covering asset: "cpu"
|
|
# (whisper) ships a CPU bundle in every release; None (llama) reports "no
|
|
# prebuilt" so the caller falls back to a source build.
|
|
fallback_backend: str | None = "cpu"
|
|
staging_root_name: str = INSTALL_STAGING_ROOT_NAME
|
|
run_staged_validation: bool = False
|
|
# Hooks. Each mirrors the module-level function it replaces; None keeps the
|
|
# core default (which raises if truly required).
|
|
detect_host: Callable[[], Any] | None = None
|
|
host_platform_tokens: Callable[[Any], tuple[str, str]] | None = None
|
|
server_binary_name: Callable[[Any], str] | None = None
|
|
runtime_bin_dir: Callable[[Path, Any], Path] | None = None
|
|
auto_detect_backend: Callable[[Any], str] | None = None
|
|
|
|
|
|
def component_namespace(descriptor: ComponentDescriptor) -> dict[str, Any]:
|
|
"""Materialize a descriptor into the module-like namespace ``ModuleOps``
|
|
resolves against; core defaults cover anything not listed here."""
|
|
prefix = descriptor.log_prefix
|
|
|
|
def component_log(message: str) -> None:
|
|
print(f"[{prefix}] {message}", file = sys.stderr)
|
|
|
|
namespace: dict[str, Any] = {
|
|
"COMPONENT": descriptor.component,
|
|
"SCHEMA_VERSION": descriptor.schema_version,
|
|
"DEFAULT_PUBLISHED_REPO": descriptor.published_repo,
|
|
"MANIFEST_ASSET_NAME": descriptor.manifest_asset_name,
|
|
"SHA256_ASSET_NAME": descriptor.sha256_asset_name,
|
|
"METADATA_FILENAME": descriptor.metadata_filename,
|
|
"SUPPORTED_BACKENDS": descriptor.supported_backends,
|
|
"USER_AGENT": descriptor.user_agent,
|
|
"FALLBACK_BACKEND": descriptor.fallback_backend,
|
|
"INSTALL_STAGING_ROOT_NAME": descriptor.staging_root_name,
|
|
"_RUN_STAGED_PREBUILT_VALIDATION": descriptor.run_staged_validation,
|
|
"log": component_log,
|
|
}
|
|
for hook in (
|
|
"detect_host",
|
|
"host_platform_tokens",
|
|
"server_binary_name",
|
|
"runtime_bin_dir",
|
|
"auto_detect_backend",
|
|
):
|
|
value = getattr(descriptor, hook)
|
|
if value is not None:
|
|
namespace[hook] = value
|
|
return namespace
|
|
|
|
|
|
def component_ops(descriptor: ComponentDescriptor) -> ModuleOps:
|
|
return ModuleOps(component_namespace(descriptor))
|
|
|
|
|
|
# ── Lock/busy classification ──
|
|
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
|
|
|
|
|
|
# ── HTTP: auth, redirects, retries ──
|
|
def parsed_hostname(url: str | None) -> str | None:
|
|
if not url:
|
|
return None
|
|
try:
|
|
hostname = urllib.parse.urlparse(url).hostname
|
|
except Exception:
|
|
return None
|
|
if not hostname:
|
|
return None
|
|
return hostname.lower()
|
|
|
|
|
|
def should_send_github_auth(url: str | None) -> bool:
|
|
return parsed_hostname(url) in GITHUB_AUTH_HOSTS
|
|
|
|
|
|
def should_send_hf_auth(url: str | None) -> bool:
|
|
return parsed_hostname(url) in HF_AUTH_HOSTS
|
|
|
|
|
|
def auth_headers(ops: ModuleOps, url: str | None = None) -> dict[str, str]:
|
|
headers = {
|
|
"User-Agent": ops.USER_AGENT,
|
|
}
|
|
token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
|
|
if token and should_send_github_auth(url):
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
return headers
|
|
# Anonymous huggingface.co fetches share a per-IP rate limit CI fleets
|
|
# exhaust (HTTP 429); authenticate when a token is available.
|
|
hf_token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
|
|
if hf_token and should_send_hf_auth(url):
|
|
headers["Authorization"] = f"Bearer {hf_token}"
|
|
return headers
|
|
|
|
|
|
class _CrossHostAuthStrippingRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
"""Drop Authorization when a redirect leaves the original host.
|
|
|
|
huggingface.co redirects downloads to CDN hosts whose signed URLs can reject
|
|
a foreign Authorization header; urllib forwards headers across redirects by
|
|
default (requests/huggingface_hub strip them).
|
|
"""
|
|
|
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
new_request = super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
if new_request is not None and parsed_hostname(newurl) != parsed_hostname(req.full_url):
|
|
new_request.headers.pop("Authorization", None)
|
|
new_request.unredirected_hdrs.pop("Authorization", None)
|
|
return new_request
|
|
|
|
|
|
_URL_OPENER = urllib.request.build_opener(_CrossHostAuthStrippingRedirectHandler())
|
|
|
|
|
|
def github_api_headers(ops: ModuleOps, url: str | None = None) -> dict[str, str]:
|
|
return {
|
|
"Accept": "application/vnd.github+json",
|
|
**ops.auth_headers(url),
|
|
}
|
|
|
|
|
|
def is_github_api_url(url: str | None) -> bool:
|
|
return parsed_hostname(url) == "api.github.com"
|
|
|
|
|
|
def is_retryable_url_error(exc: Exception) -> bool:
|
|
if isinstance(exc, urllib.error.HTTPError):
|
|
# GitHub returns 403 (not 429) on API rate-limit; anonymous calls share a
|
|
# 60-req/hour bucket per runner IP that CI fleets exhaust. Treat 403
|
|
# against api.github.com as retryable so we get a backoff cycle or two
|
|
# (honouring Retry-After / X-RateLimit-Reset) before the source-build
|
|
# fallback fires. 403s on other hosts (private downloads, auth) stay non-retryable.
|
|
if exc.code == 403:
|
|
return is_github_api_url(getattr(exc, "url", None))
|
|
return exc.code in RETRYABLE_HTTP_STATUS
|
|
if isinstance(exc, urllib.error.URLError):
|
|
return True
|
|
if isinstance(exc, TimeoutError):
|
|
return True
|
|
if isinstance(exc, socket.timeout):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _http_error_retry_delay(exc: Exception) -> float | None:
|
|
"""Recommended wait from rate-limit headers on a 403/429.
|
|
|
|
None when no header is present or the wait exceeds
|
|
_RATE_LIMIT_WAIT_CAP_SECONDS (the source-build fallback is faster).
|
|
"""
|
|
if not isinstance(exc, urllib.error.HTTPError):
|
|
return None
|
|
headers = getattr(exc, "headers", None)
|
|
if headers is None:
|
|
return None
|
|
retry_after = headers.get("Retry-After")
|
|
if retry_after and retry_after.strip().isdigit():
|
|
wait = float(retry_after.strip())
|
|
return wait if wait <= _RATE_LIMIT_WAIT_CAP_SECONDS else None
|
|
rate_reset = headers.get("X-RateLimit-Reset")
|
|
if rate_reset and rate_reset.strip().isdigit():
|
|
wait = float(rate_reset.strip()) - time.time()
|
|
if 0.0 < wait <= _RATE_LIMIT_WAIT_CAP_SECONDS:
|
|
return wait + 1.0 # +1s of slack so the bucket is fresh
|
|
return None
|
|
|
|
|
|
def sleep_backoff(
|
|
attempt: int,
|
|
*,
|
|
base_delay: float = HTTP_FETCH_BASE_DELAY_SECONDS,
|
|
exc: Exception | None = None,
|
|
) -> None:
|
|
delay = base_delay * (2 ** max(attempt - 1, 0))
|
|
header_delay = _http_error_retry_delay(exc) if exc is not None else None
|
|
if header_delay is not None:
|
|
delay = max(delay, header_delay)
|
|
delay += random.uniform(0.0, 0.2)
|
|
time.sleep(delay)
|
|
|
|
|
|
# ── Atomic writes and hashing ──
|
|
def atomic_write_bytes(destination: Path, data: bytes) -> None:
|
|
destination.parent.mkdir(parents = True, exist_ok = True)
|
|
with tempfile.NamedTemporaryFile(
|
|
prefix = destination.name + ".tmp-",
|
|
dir = destination.parent,
|
|
delete = False,
|
|
) as handle:
|
|
tmp_path = Path(handle.name)
|
|
handle.write(data)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
os.replace(tmp_path, destination)
|
|
|
|
|
|
def atomic_replace_from_tempfile(tmp_path: Path, destination: Path) -> None:
|
|
destination.parent.mkdir(parents = True, exist_ok = True)
|
|
os.replace(tmp_path, destination)
|
|
|
|
|
|
def sha256_file(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as handle:
|
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def sha256_bytes(data: bytes) -> str:
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def normalize_sha256_digest(value: str | None) -> str | None:
|
|
if not isinstance(value, str) or not value:
|
|
return None
|
|
lowered = value.lower()
|
|
if lowered.startswith("sha256:"):
|
|
lowered = lowered.split(":", 1)[1]
|
|
if len(lowered) != 64 or any(ch not in "0123456789abcdef" for ch in lowered):
|
|
return None
|
|
return lowered
|
|
|
|
|
|
def validate_schema_version(
|
|
payload: dict[str, Any],
|
|
*,
|
|
label: str,
|
|
schema_version: int = SCHEMA_VERSION,
|
|
error: type[Exception] = RuntimeError,
|
|
) -> None:
|
|
value = payload.get("schema_version")
|
|
if value is None:
|
|
return
|
|
try:
|
|
normalized = int(value)
|
|
except (TypeError, ValueError) as exc:
|
|
raise error(f"{label} schema_version was not an integer") from exc
|
|
if normalized != schema_version:
|
|
raise error(f"{label} schema_version={normalized} is unsupported")
|
|
|
|
|
|
# ── Download progress ──
|
|
def format_byte_count(num_bytes: float) -> str:
|
|
units = ["B", "KiB", "MiB", "GiB", "TiB"]
|
|
value = float(num_bytes)
|
|
for unit in units:
|
|
if abs(value) < 1024.0 or unit == units[-1]:
|
|
if unit == "B":
|
|
return f"{int(value)} {unit}"
|
|
return f"{value:.1f} {unit}"
|
|
value /= 1024.0
|
|
return f"{num_bytes:.1f} B"
|
|
|
|
|
|
def _progress_percent_step() -> int:
|
|
"""Non-tty milestone granularity. The in-app updater sets
|
|
UNSLOTH_PROGRESS_PERCENT_STEP=5 for finer progress lines."""
|
|
try:
|
|
step = int(os.environ.get("UNSLOTH_PROGRESS_PERCENT_STEP", "25"))
|
|
except ValueError:
|
|
return 25
|
|
return min(max(step, 1), 50)
|
|
|
|
|
|
class DownloadProgress:
|
|
def __init__(self, label: str, total_bytes: int | None) -> None:
|
|
self.label = label
|
|
self.total_bytes = total_bytes if total_bytes and total_bytes > 0 else None
|
|
self.start_time = time.monotonic()
|
|
self.last_emit = 0.0
|
|
term_ok = os.environ.get("TERM", "").lower() != "dumb"
|
|
self.stream = (
|
|
sys.stderr if sys.stderr.isatty() else sys.stdout if sys.stdout.isatty() else sys.stderr
|
|
)
|
|
self.is_tty = term_ok and self.stream.isatty()
|
|
self.completed = False
|
|
self.milestone_step = _progress_percent_step()
|
|
self.last_milestone_percent = -1
|
|
self.last_milestone_bytes = 0
|
|
self.has_rendered_tty_progress = False
|
|
|
|
def _render(
|
|
self,
|
|
downloaded_bytes: int,
|
|
*,
|
|
final: bool = False,
|
|
) -> str:
|
|
elapsed = max(time.monotonic() - self.start_time, 1e-6)
|
|
speed = downloaded_bytes / elapsed
|
|
speed_text = f"{format_byte_count(speed)}/s"
|
|
if self.total_bytes is not None:
|
|
percent = min(100.0, (downloaded_bytes / self.total_bytes) * 100.0)
|
|
return (
|
|
f"{self.label}: {percent:5.1f}% "
|
|
f"({format_byte_count(downloaded_bytes)}/{format_byte_count(self.total_bytes)}) "
|
|
f"at {speed_text}"
|
|
)
|
|
if final:
|
|
return f"{self.label}: {format_byte_count(downloaded_bytes)} downloaded at {speed_text}"
|
|
return f"{self.label}: {format_byte_count(downloaded_bytes)} downloaded at {speed_text}"
|
|
|
|
def update(self, downloaded_bytes: int) -> None:
|
|
now = time.monotonic()
|
|
if self.is_tty:
|
|
elapsed = now - self.start_time
|
|
if not self.has_rendered_tty_progress:
|
|
if self.total_bytes is not None and downloaded_bytes >= self.total_bytes:
|
|
return
|
|
if elapsed < TTY_PROGRESS_START_DELAY_SECONDS:
|
|
return
|
|
min_interval = 0.2
|
|
if (
|
|
self.has_rendered_tty_progress
|
|
and not self.completed
|
|
and (now - self.last_emit) < min_interval
|
|
):
|
|
return
|
|
self.last_emit = now
|
|
line = self._render(downloaded_bytes)
|
|
self.stream.write("\r\033[K" + line)
|
|
self.stream.flush()
|
|
self.has_rendered_tty_progress = True
|
|
return
|
|
|
|
should_emit = False
|
|
if self.total_bytes is not None:
|
|
percent = int((downloaded_bytes * 100) / max(self.total_bytes, 1))
|
|
step = self.milestone_step
|
|
milestone_percent = min((percent // step) * step, 100)
|
|
if milestone_percent > self.last_milestone_percent and milestone_percent < 100:
|
|
self.last_milestone_percent = milestone_percent
|
|
should_emit = True
|
|
else:
|
|
byte_step = 25 * 1024 * 1024
|
|
if (
|
|
downloaded_bytes - self.last_milestone_bytes >= byte_step
|
|
and (now - self.last_emit) >= 5.0
|
|
):
|
|
self.last_milestone_bytes = downloaded_bytes
|
|
should_emit = True
|
|
|
|
if not should_emit:
|
|
return
|
|
|
|
self.last_emit = now
|
|
self.stream.write(self._render(downloaded_bytes) + "\n")
|
|
self.stream.flush()
|
|
|
|
def finish(self, downloaded_bytes: int) -> None:
|
|
self.completed = True
|
|
line = self._render(downloaded_bytes, final = True)
|
|
if self.is_tty:
|
|
if not self.has_rendered_tty_progress:
|
|
return
|
|
self.stream.write("\r\033[K")
|
|
else:
|
|
self.stream.write(line + "\n")
|
|
self.stream.flush()
|
|
|
|
|
|
def download_label_from_url(url: str) -> str:
|
|
name = Path(urllib.parse.urlparse(url).path).name
|
|
return name or url
|
|
|
|
|
|
# ── Downloads ──
|
|
def download_bytes(
|
|
ops: ModuleOps,
|
|
url: str,
|
|
*,
|
|
timeout: int = 120,
|
|
attempts: int | None = None,
|
|
headers: dict[str, str] | None = None,
|
|
progress_label: str | None = None,
|
|
) -> bytes:
|
|
if attempts is None:
|
|
attempts = ops.HTTP_FETCH_ATTEMPTS
|
|
last_exc: Exception | None = None
|
|
for attempt in range(1, attempts + 1):
|
|
try:
|
|
request = urllib.request.Request(url, headers = headers or ops.auth_headers(url))
|
|
with ops._URL_OPENER.open(request, timeout = timeout) as response:
|
|
total_bytes: int | None = None
|
|
content_length = response.headers.get("Content-Length")
|
|
if content_length and content_length.isdigit():
|
|
total_bytes = int(content_length)
|
|
progress = DownloadProgress(progress_label, total_bytes) if progress_label else None
|
|
data = bytearray()
|
|
while True:
|
|
chunk = response.read(1024 * 1024)
|
|
if not chunk:
|
|
break
|
|
data.extend(chunk)
|
|
if progress is not None:
|
|
progress.update(len(data))
|
|
if progress is not None:
|
|
progress.finish(len(data))
|
|
return bytes(data)
|
|
except Exception as exc:
|
|
last_exc = exc
|
|
if attempt >= attempts or not is_retryable_url_error(exc):
|
|
raise
|
|
ops.log(f"fetch failed ({attempt}/{attempts}) for {url}: {exc}; retrying")
|
|
sleep_backoff(attempt, exc = exc)
|
|
assert last_exc is not None
|
|
raise last_exc
|
|
|
|
|
|
def fetch_json(ops: ModuleOps, url: str) -> Any:
|
|
attempts = ops.JSON_FETCH_ATTEMPTS if is_github_api_url(url) else 1
|
|
last_decode_exc: Exception | None = None
|
|
for attempt in range(1, attempts + 1):
|
|
try:
|
|
data = ops.download_bytes(
|
|
url,
|
|
timeout = 30,
|
|
headers = ops.github_api_headers(url)
|
|
if is_github_api_url(url)
|
|
else ops.auth_headers(url),
|
|
)
|
|
except urllib.error.HTTPError as exc:
|
|
if exc.code == 403 and is_github_api_url(url):
|
|
hint = ""
|
|
if not (os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")):
|
|
hint = "; set GH_TOKEN or GITHUB_TOKEN to avoid GitHub API rate limits"
|
|
raise RuntimeError(f"GitHub API returned 403 for {url}{hint}") from exc
|
|
raise
|
|
if not data:
|
|
last_decode_exc = RuntimeError(f"downloaded empty JSON payload from {url}")
|
|
else:
|
|
try:
|
|
payload = json.loads(data.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
last_decode_exc = RuntimeError(f"downloaded invalid JSON from {url}: {exc}")
|
|
else:
|
|
if not isinstance(payload, dict) and not isinstance(payload, list):
|
|
raise RuntimeError(
|
|
f"downloaded unexpected JSON type from {url}: {type(payload).__name__}"
|
|
)
|
|
return payload
|
|
if attempt >= attempts:
|
|
assert last_decode_exc is not None
|
|
raise last_decode_exc
|
|
ops.log(f"json fetch failed ({attempt}/{attempts}) for {url}; retrying")
|
|
sleep_backoff(attempt)
|
|
assert last_decode_exc is not None
|
|
raise last_decode_exc
|
|
|
|
|
|
def download_file(ops: ModuleOps, url: str, destination: Path) -> None:
|
|
destination.parent.mkdir(parents = True, exist_ok = True)
|
|
attempts = ops.HTTP_FETCH_ATTEMPTS
|
|
last_exc: Exception | None = None
|
|
for attempt in range(1, attempts + 1):
|
|
tmp_path: Path | None = None
|
|
try:
|
|
request = urllib.request.Request(url, headers = ops.auth_headers(url))
|
|
with tempfile.NamedTemporaryFile(
|
|
prefix = destination.name + ".tmp-",
|
|
dir = destination.parent,
|
|
delete = False,
|
|
) as handle:
|
|
tmp_path = Path(handle.name)
|
|
with ops._URL_OPENER.open(request, timeout = 120) as response:
|
|
total_bytes: int | None = None
|
|
content_length = response.headers.get("Content-Length")
|
|
if content_length and content_length.isdigit():
|
|
total_bytes = int(content_length)
|
|
progress = DownloadProgress(f"Downloading {destination.name}", total_bytes)
|
|
downloaded_bytes = 0
|
|
while True:
|
|
chunk = response.read(1024 * 1024)
|
|
if not chunk:
|
|
break
|
|
handle.write(chunk)
|
|
downloaded_bytes += len(chunk)
|
|
progress.update(downloaded_bytes)
|
|
progress.finish(downloaded_bytes)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
if not tmp_path.exists() or tmp_path.stat().st_size == 0:
|
|
raise RuntimeError(f"downloaded empty file from {url}")
|
|
atomic_replace_from_tempfile(tmp_path, destination)
|
|
return
|
|
except Exception as exc:
|
|
last_exc = exc
|
|
if tmp_path is not None:
|
|
try:
|
|
tmp_path.unlink(missing_ok = True)
|
|
except Exception:
|
|
pass
|
|
if attempt >= attempts or not is_retryable_url_error(exc):
|
|
raise
|
|
ops.log(f"download failed ({attempt}/{attempts}) for {url}: {exc}; retrying")
|
|
sleep_backoff(attempt, exc = exc)
|
|
assert last_exc is not None
|
|
raise last_exc
|
|
|
|
|
|
def download_file_verified(
|
|
ops: ModuleOps, url: str, destination: Path, *, expected_sha256: str | None, label: str
|
|
) -> None:
|
|
normalized_expected = normalize_sha256_digest(expected_sha256)
|
|
if not normalized_expected:
|
|
ops.download_file(url, destination)
|
|
ops.log(f"downloaded {label} without a published sha256; relying on install validation")
|
|
return
|
|
|
|
for attempt in range(1, 3):
|
|
ops.download_file(url, destination)
|
|
actual_sha256 = ops.sha256_file(destination)
|
|
if actual_sha256 == normalized_expected:
|
|
ops.log(f"verified {label} sha256={actual_sha256}")
|
|
return
|
|
|
|
ops.log(
|
|
f"{label} checksum mismatch on attempt {attempt}/2: "
|
|
f"expected={normalized_expected} actual={actual_sha256}"
|
|
)
|
|
destination.unlink(missing_ok = True)
|
|
if attempt == 2:
|
|
raise PrebuiltFallback(
|
|
f"{label} checksum mismatch after retry: expected={normalized_expected} actual={actual_sha256}"
|
|
)
|
|
ops.log(f"retrying {label} download after checksum mismatch")
|
|
|
|
|
|
def download_file_verified_strict(
|
|
ops: ModuleOps, url: str, destination: Path, *, expected_sha256: str, label: str
|
|
) -> None:
|
|
"""Verified download with a required digest (the generic flow fails closed on
|
|
a missing checksum, so None never reaches here)."""
|
|
for attempt in range(1, 3):
|
|
ops.download_file(url, destination)
|
|
actual = ops.sha256_file(destination)
|
|
if actual == expected_sha256:
|
|
ops.log(f"verified {label} sha256={actual}")
|
|
return
|
|
ops.log(
|
|
f"{label} checksum mismatch {attempt}/2: expected={expected_sha256} actual={actual}"
|
|
)
|
|
destination.unlink(missing_ok = True)
|
|
if attempt == 2:
|
|
raise PrebuiltFallback(f"{label} checksum mismatch after retry")
|
|
|
|
|
|
# ── GitHub release primitives ──
|
|
def release_asset_map(release: dict[str, Any]) -> dict[str, str]:
|
|
assets = release.get("assets")
|
|
if not isinstance(assets, list):
|
|
return {}
|
|
return {
|
|
asset["name"]: asset.get("browser_download_url", "")
|
|
for asset in assets
|
|
if isinstance(asset, dict)
|
|
and isinstance(asset.get("name"), str)
|
|
and isinstance(asset.get("browser_download_url"), str)
|
|
}
|
|
|
|
|
|
def github_release(
|
|
ops: ModuleOps,
|
|
repo: str,
|
|
tag: str,
|
|
*,
|
|
error: type[Exception] = RuntimeError,
|
|
) -> dict[str, Any]:
|
|
payload = ops.fetch_json(
|
|
f"https://api.github.com/repos/{repo}/releases/tags/{urllib.parse.quote(tag, safe = '')}"
|
|
)
|
|
if not isinstance(payload, dict):
|
|
raise error(f"unexpected release payload for {repo}@{tag}")
|
|
return payload
|
|
|
|
|
|
def github_release_assets(ops: ModuleOps, repo: str, tag: str) -> dict[str, str]:
|
|
payload = ops.fetch_json(
|
|
f"https://api.github.com/repos/{repo}/releases/tags/{urllib.parse.quote(tag, safe = '')}"
|
|
)
|
|
if not isinstance(payload, dict):
|
|
raise RuntimeError(f"unexpected release payload for {repo}@{tag}")
|
|
return release_asset_map(payload)
|
|
|
|
|
|
def release_asset_download_url(repo: str, release_tag: str, asset_name: str) -> str:
|
|
"""Tag-pinned asset URL on the release-assets CDN (github.com redirect, NOT
|
|
api.github.com, so no 60-req/hour unauthenticated limit)."""
|
|
return (
|
|
f"https://github.com/{urllib.parse.quote(repo, safe = '/')}/releases/download/"
|
|
f"{urllib.parse.quote(release_tag, safe = '')}/"
|
|
f"{urllib.parse.quote(asset_name, safe = '')}"
|
|
)
|
|
|
|
|
|
def download_host_latest_release_tag(ops: ModuleOps, repo: str) -> str | None:
|
|
"""Latest release tag from github.com/<repo>/releases/latest via its redirect
|
|
target (no api.github.com call). None on 404 so the caller falls back to the
|
|
API. /releases/latest resolves by created_at/make_latest, which can lag the
|
|
published_at newest the freshness check uses -- acceptable for install."""
|
|
url = f"https://github.com/{urllib.parse.quote(repo, safe = '/')}/releases/latest"
|
|
request = urllib.request.Request(
|
|
url,
|
|
method = "HEAD",
|
|
headers = {"User-Agent": ops.USER_AGENT},
|
|
)
|
|
try:
|
|
with ops._URL_OPENER.open(request, timeout = 30) as response:
|
|
final_url = response.geturl()
|
|
except urllib.error.HTTPError as exc:
|
|
if exc.code == 404:
|
|
return None
|
|
raise
|
|
marker = "/releases/tag/"
|
|
index = final_url.find(marker)
|
|
if index == -1:
|
|
return None
|
|
tag = urllib.parse.unquote(final_url[index + len(marker) :]).strip("/")
|
|
return tag or None
|
|
|
|
|
|
def fetch_download_host_json(ops: ModuleOps, url: str) -> Any:
|
|
# Public CDN asset: plain unauthenticated GET, not the rate-limited API.
|
|
data = ops.download_bytes(
|
|
url,
|
|
timeout = 30,
|
|
headers = {"User-Agent": ops.USER_AGENT},
|
|
)
|
|
return json.loads(data.decode("utf-8"))
|
|
|
|
|
|
# ── Archive extraction (traversal/symlink guarded) ──
|
|
def extract_archive(archive_path: Path, destination: Path) -> None:
|
|
def safe_extract_path(base: Path, member_name: str) -> Path:
|
|
normalized = member_name.replace("\\", "/")
|
|
member_path = Path(normalized)
|
|
if member_path.is_absolute():
|
|
raise PrebuiltFallback(f"archive member used an absolute path: {member_name}")
|
|
|
|
target = (base / member_path).resolve()
|
|
base_resolved = base.resolve()
|
|
try:
|
|
target.relative_to(base_resolved)
|
|
except ValueError as exc:
|
|
raise PrebuiltFallback(f"archive member escaped destination: {member_name}") from exc
|
|
return target
|
|
|
|
def _try_repair_missing_slash(
|
|
member_name: str, link_name: str, archive_names: set[str]
|
|
) -> str | None:
|
|
"""Repair a mangled symlink from some upstream llama.cpp Mac releases
|
|
(e.g. b9165, b9169) whose linkname drops the separator AND the file
|
|
basename's leading char between the top-level dir and the rest:
|
|
|
|
llama-b9165/libggml-rpc.0.dylib -> llama-b9165ibggml-rpc.0.11.1.dylib
|
|
|
|
Detect the pattern (linkname starts with the top-level dir but no
|
|
following slash), then find the archive entry under that dir whose
|
|
basename ends with the mangled suffix; only accept a unique match.
|
|
Returns the corrected linkname relative to the member's parent dir --
|
|
callers join it with `target.parent`, so a full `top/file` path would
|
|
double the prefix into `top/top/file`."""
|
|
if "/" not in member_name or "/" in link_name:
|
|
return None
|
|
top, _, _ = member_name.partition("/")
|
|
if not link_name.startswith(top) or len(link_name) <= len(top):
|
|
return None
|
|
bad_suffix = link_name[len(top) :]
|
|
if not bad_suffix or bad_suffix.startswith("/"):
|
|
return None
|
|
prefix = f"{top}/"
|
|
candidates = [
|
|
name
|
|
for name in archive_names
|
|
if name.startswith(prefix)
|
|
and "/" not in name[len(prefix) :]
|
|
and name[len(prefix) :].endswith(bad_suffix)
|
|
]
|
|
if len(candidates) != 1:
|
|
return None
|
|
# Strip the top-level dir so the caller's `target.parent / Path(...)`
|
|
# resolves inside the staging dir, not a duplicate `top/top/...` path.
|
|
return candidates[0][len(prefix) :]
|
|
|
|
def safe_link_target(
|
|
base: Path, member_name: str, link_name: str, target: Path, archive_names: set[str]
|
|
) -> tuple[str, Path]:
|
|
normalized = link_name.replace("\\", "/")
|
|
repaired = _try_repair_missing_slash(member_name, normalized, archive_names)
|
|
if repaired is not None:
|
|
normalized = repaired
|
|
link_path = Path(normalized)
|
|
if link_path.is_absolute():
|
|
raise PrebuiltFallback(
|
|
f"archive link used an absolute target: {member_name} -> {link_name}"
|
|
)
|
|
if not normalized:
|
|
raise PrebuiltFallback(f"archive link used an empty target: {member_name}")
|
|
|
|
resolved = (target.parent / link_path).resolve()
|
|
base_resolved = base.resolve()
|
|
try:
|
|
resolved.relative_to(base_resolved)
|
|
except ValueError as exc:
|
|
raise PrebuiltFallback(
|
|
f"archive link escaped destination: {member_name} -> {link_name}"
|
|
) from exc
|
|
return normalized, resolved
|
|
|
|
def extract_zip_safely(source: Path, base: Path) -> None:
|
|
with zipfile.ZipFile(source) as archive:
|
|
for member in archive.infolist():
|
|
target = safe_extract_path(base, member.filename)
|
|
mode = (member.external_attr >> 16) & 0o170000
|
|
if mode == 0o120000:
|
|
raise PrebuiltFallback(
|
|
f"zip archive contained a symlink entry: {member.filename}"
|
|
)
|
|
if member.is_dir():
|
|
target.mkdir(parents = True, exist_ok = True)
|
|
continue
|
|
target.parent.mkdir(parents = True, exist_ok = True)
|
|
with archive.open(member, "r") as src, target.open("wb") as dst:
|
|
shutil.copyfileobj(src, dst)
|
|
|
|
def extract_tar_safely(source: Path, base: Path) -> None:
|
|
pending_links: list[tuple[tarfile.TarInfo, Path]] = []
|
|
archive_names: set[str] = set()
|
|
with tarfile.open(source, "r:gz") as archive:
|
|
for member in archive.getmembers():
|
|
archive_names.add(member.name)
|
|
target = safe_extract_path(base, member.name)
|
|
if member.isdir():
|
|
target.mkdir(parents = True, exist_ok = True)
|
|
continue
|
|
if member.islnk() or member.issym():
|
|
pending_links.append((member, target))
|
|
continue
|
|
if not member.isfile():
|
|
raise PrebuiltFallback(
|
|
f"tar archive contained an unsupported entry: {member.name}"
|
|
)
|
|
target.parent.mkdir(parents = True, exist_ok = True)
|
|
extracted = archive.extractfile(member)
|
|
if extracted is None:
|
|
raise PrebuiltFallback(f"tar archive entry could not be read: {member.name}")
|
|
with extracted, target.open("wb") as dst:
|
|
shutil.copyfileobj(extracted, dst)
|
|
|
|
unresolved = list(pending_links)
|
|
while unresolved:
|
|
next_round: list[tuple[tarfile.TarInfo, Path]] = []
|
|
progressed = False
|
|
for member, target in unresolved:
|
|
normalized_link, resolved_target = safe_link_target(
|
|
base, member.name, member.linkname, target, archive_names
|
|
)
|
|
if not resolved_target.exists() and not resolved_target.is_symlink():
|
|
next_round.append((member, target))
|
|
continue
|
|
if resolved_target.is_dir():
|
|
raise PrebuiltFallback(
|
|
f"archive link targeted a directory: {member.name} -> {member.linkname}"
|
|
)
|
|
|
|
target.parent.mkdir(parents = True, exist_ok = True)
|
|
if target.exists() or target.is_symlink():
|
|
target.unlink()
|
|
|
|
if member.issym():
|
|
target.symlink_to(normalized_link)
|
|
else:
|
|
shutil.copy2(resolved_target, target)
|
|
progressed = True
|
|
|
|
if not progressed:
|
|
details = ", ".join(
|
|
f"{member.name} -> {member.linkname}" for member, _ in next_round
|
|
)
|
|
raise PrebuiltFallback(f"tar archive contained unresolved link entries: {details}")
|
|
unresolved = next_round
|
|
|
|
destination.mkdir(parents = True, exist_ok = True)
|
|
if archive_path.name.endswith(".zip"):
|
|
extract_zip_safely(archive_path, destination)
|
|
return
|
|
if archive_path.name.endswith(".tar.gz"):
|
|
extract_tar_safely(archive_path, destination)
|
|
return
|
|
raise PrebuiltFallback(f"unsupported archive format: {archive_path.name}")
|
|
|
|
|
|
def restore_tar_exec_bits(archive_path: Path, destination: Path) -> None:
|
|
"""Re-apply tar exec bits after the guarded extraction writes plain files;
|
|
server binaries must stay executable on Unix."""
|
|
if os.name == "nt" or not archive_path.name.endswith(".tar.gz"):
|
|
return
|
|
with tarfile.open(archive_path, "r:gz") as archive:
|
|
for member in archive.getmembers():
|
|
if not (member.isfile() and member.mode & 0o111):
|
|
continue
|
|
# Paths were already traversal-validated by the extractor above.
|
|
target = destination / Path(member.name.replace("\\", "/"))
|
|
if target.is_file():
|
|
os.chmod(target, target.stat().st_mode | 0o111)
|
|
|
|
|
|
# ── Install lock ──
|
|
@contextmanager
|
|
def install_lock(lock_path: Path) -> Iterator[None]:
|
|
lock_path.parent.mkdir(parents = True, exist_ok = True)
|
|
|
|
if FileLock is None:
|
|
# Fallback lock: exclusive file creation, writing our PID so stale locks
|
|
# from crashed processes can be detected.
|
|
fd: int | None = None
|
|
deadline = time.monotonic() + INSTALL_LOCK_TIMEOUT_SECONDS
|
|
while True:
|
|
try:
|
|
fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_RDWR)
|
|
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:
|
|
stale = False
|
|
try:
|
|
# errors="replace" so an undecodable lock reaches the int()
|
|
# below and is treated as a corrupt PID, not retried forever.
|
|
raw = lock_path.read_text(encoding = "utf-8", errors = "replace").strip()
|
|
except FileNotFoundError:
|
|
# Lock vanished between our open and read -- retry
|
|
continue
|
|
if not raw:
|
|
# Exists but PID not yet written; wait 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:
|
|
holder_pid = int(raw)
|
|
os.kill(holder_pid, 0) # signal 0 = existence check
|
|
except ValueError:
|
|
stale = True # PID unreadable (corrupted file)
|
|
except ProcessLookupError:
|
|
stale = True # holder is dead
|
|
except PermissionError:
|
|
pass # alive but owned by another user -- not stale
|
|
if stale:
|
|
lock_path.unlink(missing_ok = True)
|
|
continue
|
|
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.5)
|
|
try:
|
|
yield
|
|
finally:
|
|
if fd is not None:
|
|
os.close(fd)
|
|
lock_path.unlink(missing_ok = True)
|
|
return
|
|
|
|
try:
|
|
with FileLock(lock_path, timeout = INSTALL_LOCK_TIMEOUT_SECONDS):
|
|
yield
|
|
except FileLockTimeout as exc:
|
|
raise BusyInstallConflict(
|
|
f"timed out after {INSTALL_LOCK_TIMEOUT_SECONDS}s waiting for concurrent install lock: {lock_path}"
|
|
) from exc
|
|
|
|
|
|
def install_lock_path(install_dir: Path) -> Path:
|
|
return install_dir.parent / f".{install_dir.name}.install.lock"
|
|
|
|
|
|
# ── macOS version parsing ──
|
|
def parse_macos_version(value: str | None) -> tuple[int, int] | None:
|
|
"""Parse a macOS product version into (major, minor).
|
|
|
|
Handles "14.7.1", "15.5", "26.0", bare "26". None when empty/unparseable
|
|
(callers then defer to runtime validation rather than reject every prebuilt)."""
|
|
if not value:
|
|
return None
|
|
match = re.match(r"\s*(\d+)(?:\.(\d+))?", str(value))
|
|
if not match:
|
|
return None
|
|
return int(match.group(1)), int(match.group(2) or 0)
|
|
|
|
|
|
# ── GPU and CUDA runtime-line selection primitives ──
|
|
def normalize_compute_cap(value: Any) -> str | None:
|
|
raw = str(value).strip()
|
|
if not raw:
|
|
return None
|
|
if "." in raw:
|
|
parts = raw.split(".", 1)
|
|
if len(parts) != 2:
|
|
return None
|
|
major, minor = parts
|
|
if not major.isdigit() or not minor.isdigit():
|
|
return None
|
|
return f"{int(major)}{int(minor)}"
|
|
if raw.isdigit():
|
|
return str(int(raw))
|
|
return None
|
|
|
|
|
|
def normalize_compute_caps(compute_caps: Iterable[str]) -> list[str]:
|
|
normalized: list[str] = []
|
|
seen: set[str] = set()
|
|
for raw in compute_caps:
|
|
normalized_value = normalize_compute_cap(raw)
|
|
if normalized_value is None:
|
|
continue
|
|
if normalized_value in seen:
|
|
continue
|
|
seen.add(normalized_value)
|
|
normalized.append(normalized_value)
|
|
normalized.sort(key = int)
|
|
return normalized
|
|
|
|
|
|
def parse_cuda_visible_devices(value: str | None) -> list[str] | None:
|
|
if value is None:
|
|
return None
|
|
raw = value.strip()
|
|
if not raw or raw == "-1":
|
|
return []
|
|
return [token.strip() for token in raw.split(",") if token.strip()]
|
|
|
|
|
|
def supports_explicit_visible_device_matching(visible_devices: list[str] | None) -> bool:
|
|
if not visible_devices:
|
|
return False
|
|
for token in visible_devices:
|
|
lowered = token.lower()
|
|
if token.isdigit() or lowered.startswith("gpu-"):
|
|
continue
|
|
return False
|
|
return True
|
|
|
|
|
|
def select_visible_gpu_rows(
|
|
gpu_rows: Iterable[tuple[str, str, str]], visible_devices: list[str] | None
|
|
) -> list[tuple[str, str, str]]:
|
|
rows = list(gpu_rows)
|
|
if visible_devices is None:
|
|
return rows
|
|
if not visible_devices:
|
|
return []
|
|
|
|
by_index = {index: (index, uuid, cap) for index, uuid, cap in rows}
|
|
by_uuid = {uuid.lower(): (index, uuid, cap) for index, uuid, cap in rows}
|
|
selected: list[tuple[str, str, str]] = []
|
|
seen_indices: set[str] = set()
|
|
for token in visible_devices:
|
|
row = by_index.get(token)
|
|
if row is None:
|
|
normalized_token = token.lower()
|
|
row = by_uuid.get(normalized_token)
|
|
if row is None and normalized_token.startswith("gpu-"):
|
|
row = by_uuid.get(normalized_token)
|
|
if row is None and not normalized_token.startswith("gpu-"):
|
|
row = by_uuid.get("gpu-" + normalized_token)
|
|
if row is None:
|
|
continue
|
|
index = row[0]
|
|
if index in seen_indices:
|
|
continue
|
|
seen_indices.add(index)
|
|
selected.append(row)
|
|
return selected
|
|
|
|
|
|
def dir_provides_exact_library(directory: str | Path, library: str) -> bool:
|
|
if not library:
|
|
return False
|
|
candidate = Path(directory) / library
|
|
return candidate.exists() and (candidate.is_file() or candidate.is_symlink())
|
|
|
|
|
|
def linux_runtime_dirs_for_required_libraries(
|
|
ops: ModuleOps, required_libraries: Iterable[str]
|
|
) -> list[str]:
|
|
required = [library for library in required_libraries if library]
|
|
candidates: list[str | Path] = []
|
|
|
|
env_dirs = os.environ.get("CUDA_RUNTIME_LIB_DIR", "")
|
|
if env_dirs:
|
|
candidates.extend(part for part in env_dirs.split(os.pathsep) if part)
|
|
ld_library_path = os.environ.get("LD_LIBRARY_PATH", "")
|
|
if ld_library_path:
|
|
candidates.extend(part for part in ld_library_path.split(os.pathsep) if part)
|
|
|
|
cuda_roots: list[Path] = []
|
|
for name in ("CUDA_HOME", "CUDA_PATH", "CUDA_ROOT"):
|
|
value = os.environ.get(name)
|
|
if value:
|
|
cuda_roots.append(Path(value))
|
|
cuda_roots.extend(Path(path) for path in ops.glob_paths("/usr/local/cuda", "/usr/local/cuda-*"))
|
|
|
|
for root in cuda_roots:
|
|
candidates.extend(
|
|
[
|
|
root / "lib",
|
|
root / "lib64",
|
|
root / "targets" / "x86_64-linux" / "lib",
|
|
]
|
|
)
|
|
|
|
candidates.extend(
|
|
Path(path)
|
|
for path in ops.glob_paths(
|
|
"/lib",
|
|
"/lib64",
|
|
"/usr/lib",
|
|
"/usr/lib64",
|
|
"/usr/local/lib",
|
|
"/usr/local/lib64",
|
|
"/lib/x86_64-linux-gnu",
|
|
"/usr/lib/x86_64-linux-gnu",
|
|
)
|
|
)
|
|
candidates.extend(
|
|
Path(path) for path in ops.glob_paths("/usr/local/lib/ollama/cuda_v*", "/usr/lib/wsl/lib")
|
|
)
|
|
candidates.extend(Path(path) for path in ops.python_runtime_dirs())
|
|
candidates.extend(Path(path) for path in ops.ldconfig_runtime_dirs(required))
|
|
|
|
resolved = ops.dedupe_existing_dirs(candidates)
|
|
if not required:
|
|
return resolved
|
|
|
|
matched: list[tuple[int, str]] = []
|
|
for directory in resolved:
|
|
provided = sum(1 for library in required if dir_provides_exact_library(directory, library))
|
|
if provided:
|
|
matched.append((provided, directory))
|
|
|
|
matched.sort(key = lambda item: item[0], reverse = True)
|
|
return [directory for _, directory in matched]
|
|
|
|
|
|
def detected_linux_runtime_lines(ops: ModuleOps) -> tuple[list[str], dict[str, list[str]]]:
|
|
"""`cuda<major>` lines with a matching libcudart/libcublas file on disk (glob
|
|
match, so a versioned-only file counts), plus the dirs that matched."""
|
|
line_requirements = {
|
|
f"cuda{m}": [f"libcudart.so.{m}", f"libcublas.so.{m}"]
|
|
for m in range(_MAX_PROBE_CUDA_MAJOR, _MIN_CUDA_MAJOR - 1, -1)
|
|
}
|
|
detected: list[str] = []
|
|
runtime_dirs: dict[str, list[str]] = {}
|
|
for line, required in line_requirements.items():
|
|
dirs = ops.linux_runtime_dirs_for_required_libraries(required)
|
|
library_matches: dict[str, list[str]] = {}
|
|
matching_dirs: list[str] = []
|
|
for library in required:
|
|
matched_dirs = [
|
|
directory for directory in dirs if any(Path(directory).glob(f"{library}*"))
|
|
]
|
|
if not matched_dirs:
|
|
library_matches = {}
|
|
matching_dirs = []
|
|
break
|
|
library_matches[library] = matched_dirs
|
|
for directory in matched_dirs:
|
|
if directory not in matching_dirs:
|
|
matching_dirs.append(directory)
|
|
if library_matches:
|
|
detected.append(line)
|
|
runtime_dirs[line] = matching_dirs
|
|
return detected, runtime_dirs
|
|
|
|
|
|
def windows_runtime_line_info() -> dict[str, tuple[str, ...]]:
|
|
# Generated per CUDA major (newest first) so a new toolkit is detected without
|
|
# a code change while the cudart64_<major>.dll naming holds.
|
|
return {
|
|
f"cuda{m}": (
|
|
f"cudart64_{m}*.dll",
|
|
f"cublas64_{m}*.dll",
|
|
f"cublasLt64_{m}*.dll",
|
|
)
|
|
for m in range(_MAX_PROBE_CUDA_MAJOR, _MIN_CUDA_MAJOR - 1, -1)
|
|
}
|
|
|
|
|
|
def detected_windows_runtime_lines(ops: ModuleOps) -> tuple[list[str], dict[str, list[str]]]:
|
|
dirs = ops.windows_runtime_dirs()
|
|
detected: list[str] = []
|
|
runtime_dirs: dict[str, list[str]] = {}
|
|
for runtime_line, required_patterns in windows_runtime_line_info().items():
|
|
matching_dirs = ops.windows_runtime_dirs_for_patterns(required_patterns, dirs)
|
|
if matching_dirs:
|
|
detected.append(runtime_line)
|
|
runtime_dirs[runtime_line] = matching_dirs
|
|
return detected, runtime_dirs
|
|
|
|
|
|
def compatible_linux_runtime_lines(host: Any) -> list[str]:
|
|
if not host.driver_cuda_version:
|
|
return []
|
|
major, _minor = host.driver_cuda_version
|
|
if major < _MIN_CUDA_MAJOR:
|
|
return []
|
|
return _cuda_runtime_lines_for_major(major)
|
|
|
|
|
|
def runtime_line_from_cuda_version(cuda_version: str | None) -> str | None:
|
|
if not cuda_version:
|
|
return None
|
|
raw = str(cuda_version).strip()
|
|
if not raw:
|
|
return None
|
|
major, _, _ = raw.partition(".")
|
|
if major == "12":
|
|
return "cuda12"
|
|
if major == "13":
|
|
return "cuda13"
|
|
return None
|
|
|
|
|
|
@dataclass
|
|
class CudaRuntimePreference:
|
|
runtime_line: str | None
|
|
selection_log: list[str]
|
|
|
|
|
|
def detect_torch_cuda_runtime_preference(host: Any) -> CudaRuntimePreference:
|
|
selection_log: list[str] = []
|
|
if host.is_macos:
|
|
selection_log.append("torch_cuda_preference: skipped on macOS")
|
|
return CudaRuntimePreference(runtime_line = None, selection_log = selection_log)
|
|
if not (host.has_usable_nvidia and (host.is_linux or host.is_windows)):
|
|
selection_log.append(
|
|
"torch_cuda_preference: skipped because CUDA host prerequisites were not met"
|
|
)
|
|
return CudaRuntimePreference(runtime_line = None, selection_log = selection_log)
|
|
|
|
try:
|
|
import torch
|
|
except Exception as exc:
|
|
selection_log.append(f"torch_cuda_preference: import failed: {exc}")
|
|
return CudaRuntimePreference(runtime_line = None, selection_log = selection_log)
|
|
|
|
cuda_version = getattr(getattr(torch, "version", None), "cuda", None)
|
|
if not isinstance(cuda_version, str) or not cuda_version.strip():
|
|
selection_log.append(
|
|
"torch_cuda_preference: torch.version.cuda missing; skipping Torch shortcut"
|
|
)
|
|
return CudaRuntimePreference(runtime_line = None, selection_log = selection_log)
|
|
|
|
try:
|
|
cuda_available = bool(torch.cuda.is_available())
|
|
except Exception as exc:
|
|
selection_log.append(f"torch_cuda_preference: torch.cuda.is_available() failed: {exc}")
|
|
return CudaRuntimePreference(runtime_line = None, selection_log = selection_log)
|
|
|
|
if not cuda_available:
|
|
selection_log.append(
|
|
"torch_cuda_preference: torch.cuda.is_available() returned False; falling back to normal selection"
|
|
)
|
|
return CudaRuntimePreference(runtime_line = None, selection_log = selection_log)
|
|
|
|
runtime_line = runtime_line_from_cuda_version(cuda_version)
|
|
if runtime_line is None:
|
|
selection_log.append(
|
|
f"torch_cuda_preference: unsupported torch.version.cuda={cuda_version}; falling back to normal selection"
|
|
)
|
|
return CudaRuntimePreference(runtime_line = None, selection_log = selection_log)
|
|
|
|
selection_log.append(
|
|
"torch_cuda_preference: selected runtime_line="
|
|
f"{runtime_line} from torch.version.cuda={cuda_version}"
|
|
)
|
|
return CudaRuntimePreference(runtime_line = runtime_line, selection_log = selection_log)
|
|
|
|
|
|
def artifact_covers_sms(artifact: Any, host_sms: Iterable[str]) -> bool:
|
|
"""True when every host SM is in the artifact's supported_sms and within its
|
|
[min_sm, max_sm] range."""
|
|
if not artifact.supported_sms or artifact.min_sm is None or artifact.max_sm is None:
|
|
return False
|
|
supported = {str(value) for value in artifact.supported_sms}
|
|
return all(sm in supported and artifact.min_sm <= int(sm) <= artifact.max_sm for sm in host_sms)
|
|
|
|
|
|
def sm_range(artifact: Any) -> int:
|
|
"""SM-coverage span as a sort key (tighter range wins). A bundle with no SM
|
|
metadata gets a max range so it sorts last, never outranking a targeted bundle."""
|
|
if artifact.min_sm is not None and artifact.max_sm is not None:
|
|
return artifact.max_sm - artifact.min_sm
|
|
return 9999
|
|
|
|
|
|
def blackwell_capable_linux_runtime_lines(host_sms: list[str], artifacts: list[Any]) -> list[str]:
|
|
"""CUDA runtime lines (highest major first) shipping a bundle covering every
|
|
visible host SM. Lets a Blackwell host prefer a native sm_120 line over torch's
|
|
reported line, mirroring the Windows Blackwell preference."""
|
|
lines: set[str] = set()
|
|
for artifact in artifacts:
|
|
line = artifact.runtime_line
|
|
# Only rank "cuda<major>" lines; skip malformed/future-format values
|
|
# (e.g. "cuda13.1") rather than crash the major sort.
|
|
if not (line and line.startswith("cuda") and line[len("cuda") :].isdigit()):
|
|
continue
|
|
if not artifact.supported_sms or artifact.min_sm is None or artifact.max_sm is None:
|
|
continue
|
|
supported = {str(value) for value in artifact.supported_sms}
|
|
if all(
|
|
sm in supported and artifact.min_sm <= int(sm) <= artifact.max_sm for sm in host_sms
|
|
):
|
|
lines.add(line)
|
|
return sorted(lines, key = lambda line: int(line[len("cuda") :]), reverse = True)
|
|
|
|
|
|
def host_is_blackwell(host: Any) -> bool:
|
|
caps = normalize_compute_caps(host.compute_caps)
|
|
return bool(caps) and int(caps[-1]) >= _BLACKWELL_MIN_SM
|
|
|
|
|
|
def blackwell_min_toolkit_for_host(host: Any) -> tuple[int, int]:
|
|
"""Minimum CUDA toolkit this Blackwell host needs: 12.8 for the family, 12.9
|
|
if any SM is sm_103/sm_121 (no native target before 12.9)."""
|
|
req = _BLACKWELL_MIN_TOOLKIT
|
|
for sm in normalize_compute_caps(host.compute_caps):
|
|
req = max(req, _BLACKWELL_SM_MIN_TOOLKIT.get(int(sm), _BLACKWELL_MIN_TOOLKIT))
|
|
return req
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
# Generic descriptor-driven install flow (whisper dialect: one release carries
|
|
# a manifest of os/arch/backend artifacts plus a same-origin checksum index).
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
def host_platform_tokens(host: Any) -> tuple[str, str]:
|
|
"""Default (os, arch) asset tokens; components with their own HostInfo field
|
|
names override this hook."""
|
|
return host.os_token, host.arch_token
|
|
|
|
|
|
# ── Manifest parsing ──
|
|
def parse_manifest(ops: ModuleOps, payload: Any, *, label: str) -> dict[str, Any]:
|
|
"""Validate a component prebuilt manifest and return it normalized.
|
|
|
|
Rejects an unknown schema_version or wrong component. Returns keys:
|
|
schema_version, component, studio_protocol, upstream_tag, source_commit,
|
|
artifacts (list of dicts).
|
|
"""
|
|
component = ops.COMPONENT
|
|
if not isinstance(payload, dict):
|
|
raise PrebuiltFallback(f"{label} was not a JSON object")
|
|
validate_schema_version(
|
|
payload, label = label, schema_version = ops.SCHEMA_VERSION, error = PrebuiltFallback
|
|
)
|
|
manifest_component = payload.get("component")
|
|
if manifest_component != component:
|
|
raise PrebuiltFallback(
|
|
f"{label} describes component {manifest_component!r}, expected {component!r}"
|
|
)
|
|
|
|
artifacts_raw = payload.get("artifacts")
|
|
if not isinstance(artifacts_raw, list):
|
|
raise PrebuiltFallback(f"{label} omitted an 'artifacts' list")
|
|
artifacts: list[dict[str, Any]] = []
|
|
for index, raw in enumerate(artifacts_raw):
|
|
if not isinstance(raw, dict):
|
|
ops.log(f"{label} artifact[{index}] ignored: not an object")
|
|
continue
|
|
asset = raw.get("asset")
|
|
if not isinstance(asset, str) or not asset:
|
|
ops.log(f"{label} artifact[{index}] ignored: missing asset name")
|
|
continue
|
|
artifacts.append(raw)
|
|
|
|
studio_protocol = payload.get("studio_protocol")
|
|
return {
|
|
"schema_version": ops.SCHEMA_VERSION,
|
|
"component": component,
|
|
"studio_protocol": studio_protocol if isinstance(studio_protocol, str) else None,
|
|
"upstream_tag": payload.get("upstream_tag")
|
|
if isinstance(payload.get("upstream_tag"), str)
|
|
else None,
|
|
"source_commit": payload.get("source_commit")
|
|
if isinstance(payload.get("source_commit"), str)
|
|
else None,
|
|
"artifacts": artifacts,
|
|
}
|
|
|
|
|
|
def macos_min_os_ok(ops: ModuleOps, host: Any, min_os: Any) -> bool:
|
|
"""True if a macOS artifact requiring `min_os` can load here. The manifest
|
|
labels it `macos-<version>` (e.g. `macos-14.0`); strip that prefix before
|
|
parsing or every entry parses as None and the guard no-ops. Unknown host or
|
|
min_os -> True (defer to runtime validation)."""
|
|
if not isinstance(min_os, str) or not min_os.strip():
|
|
return True
|
|
raw = min_os.strip()
|
|
if raw.lower().startswith("macos-"):
|
|
raw = raw[len("macos-") :]
|
|
required = ops.parse_macos_version(raw)
|
|
if required is None or host.macos_version is None:
|
|
return True
|
|
return host.macos_version >= required
|
|
|
|
|
|
def artifacts_for_host(
|
|
ops: ModuleOps, manifest: dict[str, Any], host: Any, backend: str
|
|
) -> list[dict[str, Any]]:
|
|
"""Manifest artifacts matching this host os/arch/backend. On macOS, drop any
|
|
whose `min_os` exceeds the host version."""
|
|
os_token, arch_token = ops.host_platform_tokens(host)
|
|
return [
|
|
artifact
|
|
for artifact in manifest.get("artifacts", [])
|
|
if artifact.get("os") == os_token
|
|
and artifact.get("arch") == arch_token
|
|
and artifact.get("backend") == backend
|
|
and (not host.is_macos or ops.macos_min_os_ok(host, artifact.get("min_os")))
|
|
]
|
|
|
|
|
|
def select_artifact(
|
|
ops: ModuleOps, manifest: dict[str, Any], host: Any, backend: str
|
|
) -> dict[str, Any] | None:
|
|
"""First manifest artifact matching this host os/arch and backend, or None
|
|
(caller then applies the component's fallback policy). No accelerator
|
|
capability matching here: whisper bundles are slim per os/arch (the paired
|
|
llama.cpp installer already picked SM/gfx-appropriate ggml backends), and
|
|
llama keeps its own selection chain in install_llama_prebuilt.py."""
|
|
candidates = ops.artifacts_for_host(manifest, host, backend)
|
|
return candidates[0] if candidates else None
|
|
|
|
|
|
def select_artifact_with_fallback(
|
|
ops: ModuleOps, manifest: dict[str, Any], host: Any, backend: str
|
|
) -> tuple[dict[str, Any], str, bool]:
|
|
"""Select the backend artifact, else the descriptor's fallback-backend
|
|
artifact of the same release (whisper: CPU; llama: none, so a GPU miss
|
|
surfaces as "no prebuilt" and the caller does a source build).
|
|
|
|
Returns (artifact, effective_backend, used_fallback). Raises PrebuiltFallback
|
|
when neither the requested backend nor the fallback has an asset."""
|
|
os_token, arch_token = ops.host_platform_tokens(host)
|
|
artifact = ops.select_artifact(manifest, host, backend)
|
|
if artifact is not None:
|
|
return artifact, backend, False
|
|
fallback_backend = ops.FALLBACK_BACKEND
|
|
if fallback_backend and backend != fallback_backend:
|
|
fallback_artifact = ops.select_artifact(manifest, host, fallback_backend)
|
|
if fallback_artifact is not None:
|
|
ops.log(
|
|
f"no '{backend}' asset for {os_token}-{arch_token}; "
|
|
f"falling back to the {fallback_backend.upper()} asset of the same release"
|
|
)
|
|
return fallback_artifact, fallback_backend, True
|
|
raise PrebuiltFallback(
|
|
f"no {ops.COMPONENT} prebuilt asset for {os_token}-{arch_token} (backend '{backend}')"
|
|
)
|
|
|
|
|
|
def artifact_coverage(artifact: dict[str, Any]) -> dict[str, Any]:
|
|
"""The sm/gfx/min_os coverage recorded for an artifact (marker/fingerprint)."""
|
|
coverage: dict[str, Any] = {}
|
|
for key in ("sm_coverage", "gfx_coverage", "min_os", "sm", "gfx"):
|
|
if key in artifact and artifact.get(key) is not None:
|
|
coverage[key] = artifact.get(key)
|
|
return coverage
|
|
|
|
|
|
# ── Backend resolution ──
|
|
def auto_detect_backend(ops: ModuleOps, host: Any) -> str:
|
|
"""Host-preferred backend: Apple Silicon -> metal, usable NVIDIA -> cuda,
|
|
AMD gfx -> rocm, otherwise cpu."""
|
|
if host.is_apple_silicon:
|
|
return "metal"
|
|
if host.has_usable_nvidia:
|
|
return "cuda"
|
|
if host.has_rocm:
|
|
return "rocm"
|
|
return "cpu"
|
|
|
|
|
|
def resolve_backend(ops: ModuleOps, host: Any, requested: str | None, *, cpu_fallback: bool) -> str:
|
|
"""Resolve the effective backend from --backend / --cpu-fallback and detection."""
|
|
if cpu_fallback:
|
|
return "cpu"
|
|
value = (requested or "auto").strip().lower()
|
|
if value in {"", "auto"}:
|
|
return ops.auto_detect_backend(host)
|
|
if value in ops.SUPPORTED_BACKENDS:
|
|
return value
|
|
raise PrebuiltFallback(
|
|
f"unsupported --backend '{requested}'; choose from auto,{','.join(ops.SUPPORTED_BACKENDS)}"
|
|
)
|
|
|
|
|
|
# ── Release checksum index (trust anchor: the release's own sha256 asset) ──
|
|
def valid_sha256(value: Any) -> str | None:
|
|
if not isinstance(value, str):
|
|
return None
|
|
digest = value.strip().lower()
|
|
if digest.startswith("sha256:"):
|
|
digest = digest[len("sha256:") :]
|
|
if len(digest) == 64 and all(c in "0123456789abcdef" for c in digest):
|
|
return digest
|
|
return None
|
|
|
|
|
|
def parse_release_checksums(
|
|
ops: ModuleOps, repo: str, release_tag: str, payload: Any
|
|
) -> dict[str, str]:
|
|
"""Asset name -> sha256 from a release's checksum-index asset.
|
|
|
|
That index is the authority for each asset's sha256. It is validated for
|
|
schema/component and that its ``release_tag`` matches the resolved release,
|
|
so a redirected or mismatched index is rejected; malformed fails closed."""
|
|
label = f"{ops.SHA256_ASSET_NAME} in {repo}@{release_tag}"
|
|
if not isinstance(payload, dict):
|
|
raise PrebuiltFallback(f"{label} was not a JSON object")
|
|
if payload.get("schema_version") != ops.SCHEMA_VERSION:
|
|
raise PrebuiltFallback(f"{label} has an unexpected schema_version")
|
|
if payload.get("component") != ops.COMPONENT:
|
|
raise PrebuiltFallback(f"{label} did not describe {ops.COMPONENT}")
|
|
payload_tag = payload.get("release_tag")
|
|
if not isinstance(payload_tag, str) or not payload_tag:
|
|
raise PrebuiltFallback(f"{label} omitted release_tag")
|
|
if payload_tag != release_tag:
|
|
raise PrebuiltFallback(
|
|
f"{label} release_tag={payload_tag} did not match the resolved release {release_tag}"
|
|
)
|
|
artifacts = payload.get("artifacts")
|
|
if not isinstance(artifacts, dict):
|
|
raise PrebuiltFallback(f"{label} omitted an 'artifacts' map")
|
|
checksums: dict[str, str] = {}
|
|
for asset_name, entry in artifacts.items():
|
|
if not isinstance(asset_name, str) or not asset_name or not isinstance(entry, dict):
|
|
continue
|
|
digest = valid_sha256(entry.get("sha256"))
|
|
if digest is not None:
|
|
checksums[asset_name] = digest
|
|
if not checksums:
|
|
raise PrebuiltFallback(f"{label} carried no usable sha256 entries")
|
|
return checksums
|
|
|
|
|
|
def fetch_release_checksums(ops: ModuleOps, bundle: "ReleaseBundle") -> dict[str, str]:
|
|
"""Download + parse the release's checksum-index asset; fails closed if the
|
|
release does not publish it."""
|
|
sha_asset = ops.SHA256_ASSET_NAME
|
|
url = bundle.asset_urls.get(sha_asset)
|
|
if not url:
|
|
raise PrebuiltFallback(
|
|
f"release {bundle.repo}@{bundle.release_tag} has no {sha_asset}; "
|
|
f"cannot verify a download"
|
|
)
|
|
try:
|
|
raw = ops.download_bytes(url, timeout = 30, headers = ops.auth_headers(url))
|
|
payload = json.loads(raw.decode("utf-8"))
|
|
except (urllib.error.URLError, OSError, socket.timeout) as exc:
|
|
raise PrebuiltFallback(
|
|
f"could not fetch {sha_asset} from {bundle.repo}@{bundle.release_tag}: {exc}"
|
|
) from exc
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise PrebuiltFallback(
|
|
f"{sha_asset} in {bundle.repo}@{bundle.release_tag} was not valid JSON"
|
|
) from exc
|
|
return ops.parse_release_checksums(bundle.repo, bundle.release_tag, payload)
|
|
|
|
|
|
def expected_sha256_for(
|
|
ops: ModuleOps,
|
|
checksums: dict[str, str],
|
|
asset_name: str,
|
|
*,
|
|
manifest_sha256: str | None = None,
|
|
) -> str:
|
|
"""The sha256 the archive must match, from the release checksum-index entry.
|
|
An asset absent from the index fails closed. Any sha256 the manifest embeds
|
|
for the asset must agree with the index (a mismatch means a tampered manifest)."""
|
|
digest = checksums.get(asset_name)
|
|
if digest is None:
|
|
raise PrebuiltFallback(
|
|
f"{asset_name} is not covered by {ops.SHA256_ASSET_NAME}; "
|
|
f"refusing an unverifiable download"
|
|
)
|
|
embedded = valid_sha256(manifest_sha256)
|
|
if embedded is not None and embedded != digest:
|
|
raise PrebuiltFallback(
|
|
f"manifest sha256 for {asset_name} disagrees with {ops.SHA256_ASSET_NAME}; "
|
|
f"refusing a possibly tampered release"
|
|
)
|
|
ops.log(f"verifying {asset_name} against {ops.SHA256_ASSET_NAME} sha256={digest}")
|
|
return digest
|
|
|
|
|
|
# ── Release resolution ──
|
|
@dataclass
|
|
class ReleaseBundle:
|
|
repo: str
|
|
release_tag: str
|
|
manifest: dict[str, Any]
|
|
asset_urls: dict[str, str]
|
|
|
|
|
|
def fetch_release_bundle(ops: ModuleOps, repo: str, release_tag: str) -> ReleaseBundle:
|
|
"""Fetch a fork release, download+validate its manifest, return the bundle.
|
|
|
|
The single network seam for API-path release resolution; tests inject a fake
|
|
to exercise selection/install offline. A missing or unreachable release (404,
|
|
rate limit, no network) surfaces as PrebuiltFallback so the caller does a
|
|
source build rather than error out.
|
|
"""
|
|
manifest_asset = ops.MANIFEST_ASSET_NAME
|
|
try:
|
|
release = ops.github_release(repo, release_tag)
|
|
except PrebuiltFallback:
|
|
raise
|
|
except (urllib.error.URLError, OSError, socket.timeout) as exc:
|
|
raise PrebuiltFallback(f"could not fetch release {repo}@{release_tag}: {exc}") from exc
|
|
resolved_tag = release.get("tag_name")
|
|
resolved_tag = resolved_tag if isinstance(resolved_tag, str) and resolved_tag else release_tag
|
|
asset_urls = ops.release_asset_map(release)
|
|
manifest_url = asset_urls.get(manifest_asset)
|
|
if not manifest_url:
|
|
raise PrebuiltFallback(
|
|
f"release {repo}@{resolved_tag} has no {manifest_asset}; cannot select a prebuilt"
|
|
)
|
|
try:
|
|
manifest_bytes = ops.download_bytes(
|
|
manifest_url, timeout = 30, headers = ops.auth_headers(manifest_url)
|
|
)
|
|
except (urllib.error.URLError, OSError, socket.timeout) as exc:
|
|
raise PrebuiltFallback(
|
|
f"could not fetch {manifest_asset} from {repo}@{resolved_tag}: {exc}"
|
|
) from exc
|
|
try:
|
|
manifest_payload = json.loads(manifest_bytes.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise PrebuiltFallback(
|
|
f"{manifest_asset} in {repo}@{resolved_tag} was not valid JSON"
|
|
) from exc
|
|
manifest = ops.parse_manifest(
|
|
manifest_payload, label = f"{manifest_asset} in {repo}@{resolved_tag}"
|
|
)
|
|
return ReleaseBundle(
|
|
repo = repo, release_tag = resolved_tag, manifest = manifest, asset_urls = asset_urls
|
|
)
|
|
|
|
|
|
def asset_download_url(ops: ModuleOps, bundle: ReleaseBundle, asset_name: str) -> str:
|
|
url = bundle.asset_urls.get(asset_name)
|
|
if url:
|
|
return url
|
|
# A manifest can list an asset the release JSON omitted; fall back to the
|
|
# deterministic release-download URL.
|
|
return ops.release_asset_download_url(bundle.repo, bundle.release_tag, asset_name)
|
|
|
|
|
|
def resolve_newest_release_tag(ops: ModuleOps, repo: str) -> str:
|
|
"""Newest published (non-draft/non-prerelease) release tag for `repo` by
|
|
``published_at`` -- what the freshness checks use, NOT GitHub's
|
|
``/releases/latest`` pointer (sorts by commit date, can lag the newest build)."""
|
|
payload = ops.fetch_json(f"https://api.github.com/repos/{repo}/releases?per_page=30")
|
|
if not isinstance(payload, list):
|
|
raise PrebuiltFallback(f"unexpected releases payload for {repo}")
|
|
published = [
|
|
r
|
|
for r in payload
|
|
if isinstance(r, dict)
|
|
and not r.get("draft")
|
|
and not r.get("prerelease")
|
|
and isinstance(r.get("tag_name"), str)
|
|
and r.get("tag_name")
|
|
]
|
|
if not published:
|
|
raise PrebuiltFallback(f"{repo} has no published prebuilt release yet")
|
|
newest = max(published, key = lambda r: r.get("published_at") or "")
|
|
return newest["tag_name"]
|
|
|
|
|
|
def resolve_release_tag(
|
|
ops: ModuleOps, published_repo: str, *, published_release_tag: str | None
|
|
) -> str:
|
|
"""The release tag to install: an explicit override, else the newest
|
|
published release resolved at runtime."""
|
|
override = (published_release_tag or "").strip()
|
|
if override:
|
|
return override
|
|
return ops.resolve_newest_release_tag(published_repo)
|
|
|
|
|
|
def resolve_release_via_download_host(
|
|
ops: ModuleOps, repo: str, published_release_tag: str | None
|
|
) -> tuple[ReleaseBundle, dict[str, str]] | None:
|
|
"""Resolve the release + manifest + checksum index entirely from the download
|
|
host, with zero api.github.com calls. None (caller falls back to the API) on a
|
|
missing/renamed asset, a 404, or a tag mismatch. Fetches the checksum index
|
|
first (an in-progress release can publish it before the manifest)."""
|
|
manifest_asset = ops.MANIFEST_ASSET_NAME
|
|
sha_asset = ops.SHA256_ASSET_NAME
|
|
release_tag = (published_release_tag or "").strip() or ops._download_host_latest_release_tag(
|
|
repo
|
|
)
|
|
if not release_tag:
|
|
return None
|
|
sha_url = ops.release_asset_download_url(repo, release_tag, sha_asset)
|
|
try:
|
|
sha_payload = ops._download_host_json(sha_url)
|
|
except urllib.error.HTTPError as exc:
|
|
if exc.code == 404:
|
|
return None
|
|
raise
|
|
except (
|
|
urllib.error.URLError,
|
|
OSError,
|
|
socket.timeout,
|
|
UnicodeDecodeError,
|
|
json.JSONDecodeError,
|
|
):
|
|
return None
|
|
try:
|
|
checksums = ops.parse_release_checksums(repo, release_tag, sha_payload)
|
|
except PrebuiltFallback:
|
|
return None # schema/component/tag mismatch -> let the API path decide
|
|
manifest_url = ops.release_asset_download_url(repo, release_tag, manifest_asset)
|
|
try:
|
|
manifest_payload = ops._download_host_json(manifest_url)
|
|
except urllib.error.HTTPError as exc:
|
|
if exc.code == 404:
|
|
return None
|
|
raise
|
|
except (
|
|
urllib.error.URLError,
|
|
OSError,
|
|
socket.timeout,
|
|
UnicodeDecodeError,
|
|
json.JSONDecodeError,
|
|
):
|
|
return None
|
|
try:
|
|
manifest = ops.parse_manifest(
|
|
manifest_payload, label = f"{manifest_asset} in {repo}@{release_tag}"
|
|
)
|
|
except PrebuiltFallback:
|
|
return None
|
|
# Tag-pinned CDN URLs for every asset the install may fetch (asset_download_url
|
|
# reconstructs any missing one), keeping this a pure download-host path.
|
|
names = {
|
|
str(a.get("asset"))
|
|
for a in manifest.get("artifacts", [])
|
|
if isinstance(a, dict) and a.get("asset")
|
|
}
|
|
names |= {manifest_asset, sha_asset}
|
|
asset_urls = {name: ops.release_asset_download_url(repo, release_tag, name) for name in names}
|
|
bundle = ReleaseBundle(
|
|
repo = repo, release_tag = release_tag, manifest = manifest, asset_urls = asset_urls
|
|
)
|
|
return bundle, checksums
|
|
|
|
|
|
def fetch_release_for_install(
|
|
ops: ModuleOps, repo: str, *, published_release_tag: str | None
|
|
) -> tuple[ReleaseBundle, dict[str, str]]:
|
|
"""Resolve the release + manifest + checksum index, preferring the download
|
|
host (no api.github.com rate limit) and falling back to the GitHub API. The
|
|
single network seam the install/probe paths use."""
|
|
fast = ops._resolve_release_via_download_host(repo, published_release_tag)
|
|
if fast is not None:
|
|
bundle, checksums = fast
|
|
ops.log(f"resolved {repo}@{bundle.release_tag} via the download host (no GitHub API)")
|
|
return bundle, checksums
|
|
release_tag = ops.resolve_release_tag(repo, published_release_tag = published_release_tag)
|
|
bundle = ops.fetch_release_bundle(repo, release_tag)
|
|
checksums = ops.fetch_release_checksums(bundle)
|
|
return bundle, checksums
|
|
|
|
|
|
# ── Marker and fingerprint ──
|
|
def compute_install_fingerprint(
|
|
*,
|
|
published_repo: str,
|
|
release_tag: str,
|
|
upstream_tag: str | None,
|
|
source_commit: str | None,
|
|
asset: str,
|
|
asset_sha256: str,
|
|
backend: str,
|
|
runtime_line: str | None,
|
|
coverage: dict[str, Any],
|
|
) -> str:
|
|
payload = {
|
|
"published_repo": published_repo,
|
|
"release_tag": release_tag,
|
|
"upstream_tag": upstream_tag,
|
|
"source_commit": source_commit,
|
|
"asset": asset,
|
|
"asset_sha256": asset_sha256,
|
|
"backend": backend,
|
|
"runtime_line": runtime_line,
|
|
"coverage": coverage,
|
|
}
|
|
return hashlib.sha256(
|
|
json.dumps(payload, sort_keys = True, separators = (",", ":")).encode("utf-8")
|
|
).hexdigest()
|
|
|
|
|
|
@dataclass(frozen = True)
|
|
class InstallSelection:
|
|
"""The identity of a chosen prebuilt: everything the marker/fingerprint record."""
|
|
|
|
published_repo: str
|
|
release_tag: str
|
|
upstream_tag: str | None
|
|
source_commit: str | None
|
|
asset: str
|
|
asset_sha256: str
|
|
backend: str
|
|
runtime_line: str | None
|
|
coverage: dict[str, Any]
|
|
studio_protocol: str | None
|
|
# Slim pairing identity (whisper slim bundles ride the llama ggml runtime);
|
|
# all None for fat installs, never part of the fingerprint.
|
|
install_kind: str | None = None
|
|
paired_llama_tag: str | None = None
|
|
linked_from: str | None = None
|
|
# Filenames the slim wiring hardlinked beside the server; the sidecar launch
|
|
# guard verifies exactly these instead of hardcoded per-OS names.
|
|
linked_libraries: tuple[str, ...] | None = None
|
|
runtime_wiring_version: int | None = None
|
|
linked_runtime_directories: tuple[str, ...] | None = None
|
|
|
|
def fingerprint(self) -> str:
|
|
return compute_install_fingerprint(
|
|
published_repo = self.published_repo,
|
|
release_tag = self.release_tag,
|
|
upstream_tag = self.upstream_tag,
|
|
source_commit = self.source_commit,
|
|
asset = self.asset,
|
|
asset_sha256 = self.asset_sha256,
|
|
backend = self.backend,
|
|
runtime_line = self.runtime_line,
|
|
coverage = self.coverage,
|
|
)
|
|
|
|
|
|
def selection_from_artifact(
|
|
ops: ModuleOps,
|
|
*,
|
|
published_repo: str,
|
|
release_tag: str,
|
|
manifest: dict[str, Any],
|
|
artifact: dict[str, Any],
|
|
backend: str,
|
|
asset_sha256: str,
|
|
) -> InstallSelection:
|
|
return InstallSelection(
|
|
published_repo = published_repo,
|
|
release_tag = release_tag,
|
|
upstream_tag = manifest.get("upstream_tag"),
|
|
source_commit = manifest.get("source_commit"),
|
|
asset = str(artifact.get("asset")),
|
|
asset_sha256 = asset_sha256,
|
|
backend = backend,
|
|
runtime_line = artifact.get("runtime_line")
|
|
if isinstance(artifact.get("runtime_line"), str)
|
|
else None,
|
|
coverage = ops.artifact_coverage(artifact),
|
|
studio_protocol = manifest.get("studio_protocol"),
|
|
)
|
|
|
|
|
|
def metadata_path(ops: ModuleOps, install_dir: Path) -> Path:
|
|
return install_dir / ops.METADATA_FILENAME
|
|
|
|
|
|
def write_prebuilt_metadata(ops: ModuleOps, install_dir: Path, selection: InstallSelection) -> None:
|
|
coverage = selection.coverage
|
|
payload = {
|
|
"schema_version": ops.SCHEMA_VERSION,
|
|
"component": ops.COMPONENT,
|
|
"published_repo": selection.published_repo,
|
|
"release_tag": selection.release_tag,
|
|
"upstream_tag": selection.upstream_tag,
|
|
"source_commit": selection.source_commit,
|
|
"asset": selection.asset,
|
|
"asset_sha256": selection.asset_sha256,
|
|
"backend": selection.backend,
|
|
"runtime_line": selection.runtime_line,
|
|
"sm_coverage": coverage.get("sm_coverage") or coverage.get("sm"),
|
|
"gfx_coverage": coverage.get("gfx_coverage") or coverage.get("gfx"),
|
|
"min_os": coverage.get("min_os"),
|
|
"studio_protocol": selection.studio_protocol,
|
|
"install_fingerprint": selection.fingerprint(),
|
|
"installed_at_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
}
|
|
if selection.install_kind == "slim":
|
|
# Additive slim fields; fat markers keep the legacy payload exactly.
|
|
payload["install_kind"] = "slim"
|
|
payload["paired_llama_tag"] = selection.paired_llama_tag
|
|
payload["linked_from"] = selection.linked_from
|
|
if selection.linked_libraries is not None:
|
|
payload["linked_libraries"] = list(selection.linked_libraries)
|
|
if selection.runtime_wiring_version is not None:
|
|
payload["runtime_wiring_version"] = selection.runtime_wiring_version
|
|
if selection.linked_runtime_directories is not None:
|
|
payload["linked_runtime_directories"] = list(selection.linked_runtime_directories)
|
|
ops.metadata_path(install_dir).write_text(
|
|
json.dumps(payload, indent = 2) + "\n", encoding = "utf-8"
|
|
)
|
|
|
|
|
|
def load_prebuilt_metadata(ops: ModuleOps, install_dir: Path) -> dict[str, Any] | None:
|
|
path = ops.metadata_path(install_dir)
|
|
if not path.is_file():
|
|
return None
|
|
try:
|
|
payload = json.loads(path.read_text(encoding = "utf-8"))
|
|
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
|
|
return None
|
|
return payload if isinstance(payload, dict) else None
|
|
|
|
|
|
def existing_install_matches(
|
|
ops: ModuleOps, install_dir: Path, host: Any, selection: InstallSelection
|
|
) -> bool:
|
|
"""True iff the marker records this exact selection and the server binary is on disk."""
|
|
metadata = ops.load_prebuilt_metadata(install_dir)
|
|
if metadata is None:
|
|
return False
|
|
if not ops.installed_server_path(install_dir, host).is_file():
|
|
return False
|
|
recorded = metadata.get("install_fingerprint")
|
|
if not isinstance(recorded, str) or recorded != selection.fingerprint():
|
|
return False
|
|
ops.log(
|
|
f"existing {ops.COMPONENT} install already matches {selection.release_tag} "
|
|
f"({selection.backend}); nothing to do"
|
|
)
|
|
return True
|
|
|
|
|
|
# ── Install tree assembly and staged activation ──
|
|
def locate_server_in_tree(ops: ModuleOps, root: Path, host: Any) -> Path:
|
|
name = ops.server_binary_name(host)
|
|
matches = sorted(root.rglob(name))
|
|
if not matches:
|
|
raise PrebuiltFallback(f"archive did not contain a {name} binary")
|
|
return matches[0]
|
|
|
|
|
|
def assemble_install_tree(ops: ModuleOps, bundle_root: Path, staged_root: Path, host: Any) -> Path:
|
|
"""Lay out staged_root as a full install: <runtime bin dir>/<server + libs>.
|
|
|
|
Everything beside the server in the archive (shared libs, backend kernel
|
|
subdirs, license/build-info) is co-located into the canonical bin dir so the
|
|
server's RUNPATH=$ORIGIN resolves its libs.
|
|
"""
|
|
bin_dir = ops.runtime_bin_dir(staged_root, host)
|
|
bin_dir.mkdir(parents = True, exist_ok = True)
|
|
for entry in sorted(bundle_root.iterdir()):
|
|
dest = bin_dir / entry.name
|
|
if entry.is_dir() and not entry.is_symlink():
|
|
shutil.copytree(entry, dest, symlinks = True)
|
|
else:
|
|
shutil.copy2(entry, dest, follow_symlinks = False)
|
|
server = bin_dir / ops.server_binary_name(host)
|
|
if not server.exists():
|
|
raise PrebuiltFallback(f"staged install is missing the {ops.COMPONENT} server binary")
|
|
if not host.is_windows:
|
|
os.chmod(server, server.stat().st_mode | 0o111)
|
|
return server
|
|
|
|
|
|
def swap_into_place(staged_root: Path, install_dir: Path) -> None:
|
|
"""Atomically replace install_dir with staged_root (same filesystem), with rollback."""
|
|
install_dir.parent.mkdir(parents = True, exist_ok = True)
|
|
backup: Path | None = None
|
|
try:
|
|
if install_dir.exists():
|
|
backup = install_dir.parent / f".{install_dir.name}.old-{os.getpid()}"
|
|
os.replace(install_dir, backup)
|
|
os.replace(staged_root, install_dir)
|
|
except OSError as exc:
|
|
restored = False
|
|
if backup is not None and backup.exists() and not install_dir.exists():
|
|
try:
|
|
os.replace(backup, install_dir)
|
|
restored = True
|
|
except OSError as rollback_exc:
|
|
raise PrebuiltFallback(
|
|
"prebuilt activation failed and the previous install could not be restored "
|
|
f"({exc}; rollback: {rollback_exc})"
|
|
) from rollback_exc
|
|
if is_busy_lock_error(exc):
|
|
detail = "; restored the previous install" if restored else ""
|
|
raise BusyInstallConflict(
|
|
f"the existing install appears to still be in use{detail} ({exc})"
|
|
) from exc
|
|
raise
|
|
if backup is not None:
|
|
shutil.rmtree(backup, ignore_errors = True)
|
|
|
|
|
|
def validate_staged_server(ops: ModuleOps, staged_root: Path, host: Any) -> None:
|
|
"""Optional pre-activate smoke test, gated off by default (the component's
|
|
_RUN_STAGED_PREBUILT_VALIDATION switch)."""
|
|
if not ops._RUN_STAGED_PREBUILT_VALIDATION:
|
|
return
|
|
server = ops.installed_server_path(staged_root, host)
|
|
env = os.environ.copy()
|
|
bin_dir = str(ops.runtime_bin_dir(staged_root, host))
|
|
for var in ("LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH"):
|
|
env[var] = bin_dir + (os.pathsep + env[var] if env.get(var) else "")
|
|
try:
|
|
result = subprocess.run(
|
|
[str(server), "--help"],
|
|
capture_output = True,
|
|
text = True,
|
|
timeout = 60,
|
|
env = env,
|
|
**ops.windows_hidden_subprocess_kwargs(),
|
|
)
|
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
raise PrebuiltFallback(f"staged {ops.COMPONENT} server failed to launch: {exc}") from exc
|
|
if result.returncode != 0:
|
|
raise PrebuiltFallback(
|
|
f"staged {ops.COMPONENT} server --help exited {result.returncode}: "
|
|
f"{result.stderr.strip()}"
|
|
)
|
|
|
|
|
|
def windows_hidden_subprocess_kwargs() -> dict[str, object]:
|
|
"""Return Windows-only subprocess kwargs that suppress console windows."""
|
|
if sys.platform != "win32":
|
|
return {}
|
|
|
|
kwargs: dict[str, object] = {}
|
|
create_no_window = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
if create_no_window:
|
|
kwargs["creationflags"] = create_no_window
|
|
|
|
startupinfo_factory = getattr(subprocess, "STARTUPINFO", None)
|
|
startf_use_showwindow = getattr(subprocess, "STARTF_USESHOWWINDOW", 0)
|
|
sw_hide = getattr(subprocess, "SW_HIDE", 0)
|
|
if startupinfo_factory is not None and startf_use_showwindow:
|
|
startupinfo = startupinfo_factory()
|
|
startupinfo.dwFlags |= startf_use_showwindow
|
|
startupinfo.wShowWindow = sw_hide
|
|
kwargs["startupinfo"] = startupinfo
|
|
|
|
return kwargs
|
|
|
|
|
|
def install_from_bundle(
|
|
ops: ModuleOps, install_dir: Path, host: Any, bundle: ReleaseBundle, selection: InstallSelection
|
|
) -> None:
|
|
staging_root = install_dir.parent / ops.INSTALL_STAGING_ROOT_NAME
|
|
staging_root.mkdir(parents = True, exist_ok = True)
|
|
staging = Path(tempfile.mkdtemp(prefix = f"{install_dir.name}.staging-", dir = staging_root))
|
|
try:
|
|
archive_path = staging / selection.asset
|
|
url = ops.asset_download_url(bundle, selection.asset)
|
|
ops.log(f"downloading {url}")
|
|
ops.download_file_verified(
|
|
url, archive_path, expected_sha256 = selection.asset_sha256, label = selection.asset
|
|
)
|
|
extract_dir = staging / "extracted"
|
|
ops.extract_archive(archive_path, extract_dir)
|
|
|
|
server = ops.locate_server_in_tree(extract_dir, host)
|
|
bundle_root = server.parent
|
|
|
|
staged_root = staging / "staged"
|
|
ops.assemble_install_tree(bundle_root, staged_root, host)
|
|
# Component hook (default no-op): slim whisper wires the llama ggml
|
|
# runtime here so staged validation sees the final linked tree; a returned
|
|
# selection (with the wired filenames) supersedes the input so the marker
|
|
# records what was actually linked.
|
|
updated = ops.prepare_runtime_payload(staged_root, host, selection)
|
|
if updated is not None:
|
|
selection = updated
|
|
ops.validate_staged_server(staged_root, host)
|
|
ops.write_prebuilt_metadata(staged_root, selection)
|
|
ops._swap_into_place(staged_root, install_dir)
|
|
finally:
|
|
shutil.rmtree(staging, ignore_errors = True)
|
|
try:
|
|
staging_root.rmdir()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def plan_selection(
|
|
ops: ModuleOps,
|
|
host: Any,
|
|
bundle: ReleaseBundle,
|
|
*,
|
|
published_repo: str,
|
|
backend: str,
|
|
checksums: dict[str, str],
|
|
) -> InstallSelection:
|
|
"""Choose an artifact (with the component's fallback policy) and resolve its
|
|
trusted sha256 from the release checksum index."""
|
|
artifact, effective_backend, _used_fallback = ops.select_artifact_with_fallback(
|
|
bundle.manifest, host, backend
|
|
)
|
|
asset = str(artifact.get("asset"))
|
|
manifest_sha256 = artifact.get("sha256") if isinstance(artifact.get("sha256"), str) else None
|
|
expected_sha = ops.expected_sha256_for(
|
|
checksums,
|
|
asset,
|
|
manifest_sha256 = manifest_sha256,
|
|
)
|
|
return ops.selection_from_artifact(
|
|
published_repo = published_repo,
|
|
release_tag = bundle.release_tag,
|
|
manifest = bundle.manifest,
|
|
artifact = artifact,
|
|
backend = effective_backend,
|
|
asset_sha256 = expected_sha,
|
|
)
|
|
|
|
|
|
def install_prebuilt(
|
|
ops: ModuleOps,
|
|
install_dir: Path,
|
|
*,
|
|
published_repo: str,
|
|
published_release_tag: str | None = None,
|
|
backend: str | None = "auto",
|
|
cpu_fallback: bool = False,
|
|
force: bool = False,
|
|
host: Any = None,
|
|
) -> int:
|
|
if host is None:
|
|
host = ops.detect_host()
|
|
effective_backend = ops.resolve_backend(host, backend, cpu_fallback = cpu_fallback)
|
|
os_token, arch_token = ops.host_platform_tokens(host)
|
|
ops.log(
|
|
f"target {ops.COMPONENT} from {published_repo} "
|
|
f"({os_token}-{arch_token}, backend {effective_backend})"
|
|
)
|
|
|
|
bundle, checksums = ops.fetch_release_for_install(
|
|
published_repo, published_release_tag = published_release_tag
|
|
)
|
|
selection = ops.plan_selection(
|
|
host,
|
|
bundle,
|
|
published_repo = published_repo,
|
|
backend = effective_backend,
|
|
checksums = checksums,
|
|
)
|
|
|
|
return install_selected_prebuilt(
|
|
ops,
|
|
install_dir,
|
|
host = host,
|
|
bundle = bundle,
|
|
selection = selection,
|
|
force = force,
|
|
)
|
|
|
|
|
|
def install_selected_prebuilt(
|
|
ops: ModuleOps,
|
|
install_dir: Path,
|
|
*,
|
|
host: Any,
|
|
bundle: ReleaseBundle,
|
|
selection: InstallSelection,
|
|
force: bool,
|
|
) -> int:
|
|
"""Validate and activate an already selected release plan.
|
|
|
|
Components that need to examine more than one published release can keep
|
|
release selection component-specific while sharing the lock, idempotency,
|
|
atomic activation, and post-install verification path.
|
|
"""
|
|
|
|
if not force and ops.existing_install_matches(install_dir, host, selection):
|
|
return 0
|
|
|
|
with ops.install_lock(ops.install_lock_path(install_dir)):
|
|
# Re-check under the lock: a concurrent run may have just finished.
|
|
if not force and ops.existing_install_matches(install_dir, host, selection):
|
|
return 0
|
|
ops._install_from_bundle(install_dir, host, bundle, selection)
|
|
|
|
server = ops.installed_server_path(install_dir, host)
|
|
if not server.is_file():
|
|
raise PrebuiltFallback(f"post-install verification failed: {server} is missing")
|
|
ops.log(
|
|
f"installed {ops.COMPONENT} {bundle.release_tag} " f"({selection.backend}) at {install_dir}"
|
|
)
|
|
return 0
|
|
|
|
|
|
def resolve_prebuilt(
|
|
ops: ModuleOps,
|
|
host: Any,
|
|
*,
|
|
published_repo: str,
|
|
published_release_tag: str | None,
|
|
backend: str | None,
|
|
cpu_fallback: bool,
|
|
) -> dict[str, Any]:
|
|
"""Host-aware "is a prebuilt available" probe. No archive download."""
|
|
effective_backend = ops.resolve_backend(host, backend, cpu_fallback = cpu_fallback)
|
|
try:
|
|
bundle, _checksums = ops.fetch_release_for_install(
|
|
published_repo, published_release_tag = published_release_tag
|
|
)
|
|
artifact, resolved_backend, used_fallback = ops.select_artifact_with_fallback(
|
|
bundle.manifest, host, effective_backend
|
|
)
|
|
except PrebuiltFallback:
|
|
return {"prebuilt_available": False, "repo": published_repo}
|
|
os_token, arch_token = ops.host_platform_tokens(host)
|
|
payload = {
|
|
"prebuilt_available": True,
|
|
"repo": published_repo,
|
|
"release_tag": bundle.release_tag,
|
|
"upstream_tag": bundle.manifest.get("upstream_tag"),
|
|
"backend": resolved_backend,
|
|
"requested_backend": effective_backend,
|
|
"cpu_fallback": used_fallback,
|
|
"asset": str(artifact.get("asset")),
|
|
"os": os_token,
|
|
"arch": arch_token,
|
|
"runtime_line": artifact.get("runtime_line"),
|
|
}
|
|
# Component hook (default none): whisper adds install_kind slim|fat. The JSON
|
|
# emitter sorts keys, so an appended field can't perturb the legacy key order.
|
|
payload.update(ops.resolver_payload_extra(artifact))
|
|
return payload
|
|
|
|
|
|
def prepare_runtime_payload(staged_root: Path, host: Any, selection: InstallSelection) -> Any:
|
|
"""Post-assemble hook; the core stages nothing extra. Components override it
|
|
to wire external runtime files into the staged bin dir (whisper slim hardlinks
|
|
the llama install's ggml libraries) before validation, and may return an
|
|
updated InstallSelection for the marker to record instead."""
|
|
return None
|
|
|
|
|
|
def resolver_payload_extra(artifact: dict[str, Any]) -> dict[str, Any]:
|
|
"""Additive --resolve-prebuilt payload fields; the core adds none."""
|
|
return {}
|
|
|
|
|
|
def installed_server_path(ops: ModuleOps, install_dir: Path, host: Any) -> Path:
|
|
return ops.runtime_bin_dir(install_dir, host) / ops.server_binary_name(host)
|
|
|
|
|
|
# Underscored aliases so ops lookups that mirror the installer modules' private
|
|
# names still resolve for a descriptor-only component.
|
|
_download_host_latest_release_tag = download_host_latest_release_tag
|
|
_download_host_json = fetch_download_host_json
|
|
_resolve_release_via_download_host = resolve_release_via_download_host
|
|
_install_from_bundle = install_from_bundle
|
|
_swap_into_place = swap_into_place
|
|
|
|
|
|
def emit_resolver_output(payload: dict[str, Any], *, output_format: str) -> None:
|
|
if output_format == "json":
|
|
print(json.dumps(payload, sort_keys = True))
|
|
return
|
|
if "asset" in payload and payload.get("prebuilt_available"):
|
|
print(payload["asset"])
|
|
return
|
|
print(json.dumps(payload, sort_keys = True))
|