From f91ef8f9b048938dd22bb37e820855d7a97f7871 Mon Sep 17 00:00:00 2001 From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Date: Fri, 3 Apr 2026 02:56:01 +0400 Subject: [PATCH 01/15] fix(studio): lazy-import transformers in model_config to fix 5.x version switch (#4806) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(studio): lazy-import AutoConfig in model_config.py to fix transformers 5.x version switch Move `from transformers import AutoConfig` from module level to inside load_model_config() where it is actually used. model_config.py is transitively imported at module load time via: core/inference/__init__ → llama_cpp → utils.models → model_config In inference subprocesses (mp.spawn), this chain runs before _activate_transformers_version() can prepend .venv_t5/ to sys.path. The eager import caches transformers 4.57.6 in sys.modules, and the subsequent sys.path change has no effect — Python always checks sys.modules before sys.path. Making the import lazy ensures transformers is not loaded until after version activation, so the subprocess picks up the correct version. * fix(studio): also lazy-import extract_model_size_b in llama_cpp.py Belt-and-suspenders: make the import that originally triggered the chain lazy as well, so future module-level AutoConfig additions in utils.models cannot reintroduce the problem. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- studio/backend/core/inference/llama_cpp.py | 10 ++++++++-- studio/backend/utils/models/model_config.py | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 44c700bf3d..d752500bf4 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -52,8 +52,14 @@ _REPROMPT_MAX_CHARS = 2000 _SHARD_FULL_RE = re.compile(r"^(.*)-(\d{5})-of-(\d{5})\.gguf$") _SHARD_RE = re.compile(r"^(.*)-\d{5}-of-\d{5}\.gguf$") -# Model size extraction (shared with routes/inference.py) -from utils.models import extract_model_size_b as _extract_model_size_b + +# Model size extraction — lazy import to avoid pulling in transformers +# at module level. See PR description for the full explanation. +def _extract_model_size_b(model_id: str): + from utils.models import extract_model_size_b + + return extract_model_size_b(model_id) + # ── Pre-compiled patterns for tool XML stripping ───────────── _TOOL_CLOSED_PATS = [ diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py index df1058abf6..6cffc534aa 100644 --- a/studio/backend/utils/models/model_config.py +++ b/studio/backend/utils/models/model_config.py @@ -5,7 +5,6 @@ Model and LoRA configuration handling """ -from transformers import AutoConfig from dataclasses import dataclass from typing import Optional, Dict, Any from utils.paths import ( @@ -422,6 +421,7 @@ def load_model_config( """ Load model config with optional authentication control. """ + from transformers import AutoConfig if token: # Explicit token provided - use it From 6644a771b4c5c0b13f9e06c2e459c4d82a204c6b Mon Sep 17 00:00:00 2001 From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Date: Fri, 3 Apr 2026 04:03:54 +0400 Subject: [PATCH 02/15] fix: patch PEFT for Gemma4ClippableLinear in loader checkpoint path (fixes export) (#4807) * fix: patch PEFT for Gemma4ClippableLinear in loader checkpoint path The same Gemma4ClippableLinear monkey-patch that exists in vision.py for training is needed in loader.py for loading existing checkpoints (used by export and inference). Gemma4ClippableLinear wraps nn.Linear but does not subclass it, so PEFT's LoRA injection fails with "Target module not supported". The patch redirects PEFT to target the inner .linear child instead. Applied only to the vision model PeftModel.from_pretrained path. Temporary fix until PEFT adds native support (peft#3129). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: wrap ClippableLinear patch in try/finally to always restore Ensures _create_and_replace is restored even if PeftModel.from_pretrained raises, preventing leaked global state across subsequent model loads. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- unsloth/models/loader.py | 74 +++++++++++++++++++++++++++++++++++----- 1 file changed, 66 insertions(+), 8 deletions(-) diff --git a/unsloth/models/loader.py b/unsloth/models/loader.py index a811d3fb75..cfe3acb656 100644 --- a/unsloth/models/loader.py +++ b/unsloth/models/loader.py @@ -1533,14 +1533,72 @@ class FastModel(FastBaseModel): if is_peft: # From https://github.com/huggingface/peft/issues/184 # Now add PEFT adapters - model = PeftModel.from_pretrained( - model, - old_model_name, - token = token, - revision = revision, - is_trainable = True, - trust_remote_code = trust_remote_code, - ) + + # Gemma4 ClippableLinear wraps nn.Linear -- PEFT can't inject LoRA + # on it directly. Monkey-patch PEFT to target the inner .linear + # child instead (same patch as vision.py training path). + # See https://github.com/huggingface/peft/issues/3129 + _clippable_linear_cls = None + try: + from transformers.models.gemma4.modeling_gemma4 import ( + Gemma4ClippableLinear as _clippable_linear_cls, + ) + except ImportError: + pass + + if _clippable_linear_cls is not None: + from peft.tuners.lora.model import LoraModel as _LoraModel + + _original_car = _LoraModel._create_and_replace + + def _patched_car( + self, + peft_config, + adapter_name, + target, + target_name, + parent, + current_key = None, + **kwargs, + ): + if isinstance(target, _clippable_linear_cls): + return _original_car( + self, + peft_config, + adapter_name, + target.linear, + "linear", + target, + current_key = current_key, + **kwargs, + ) + return _original_car( + self, + peft_config, + adapter_name, + target, + target_name, + parent, + current_key = current_key, + **kwargs, + ) + + _LoraModel._create_and_replace = _patched_car + + try: + model = PeftModel.from_pretrained( + model, + old_model_name, + token = token, + revision = revision, + is_trainable = True, + trust_remote_code = trust_remote_code, + ) + finally: + # Always restore original PEFT method, even if loading fails + if _clippable_linear_cls is not None: + _LoraModel._create_and_replace = _original_car + # Patch it as well! model = FastBaseModel.post_patch_model( model, use_gradient_checkpointing, trust_remote_code = trust_remote_code From a7e69641171467f49487979b1ce29d03aaacf2ca Mon Sep 17 00:00:00 2001 From: Manan Shah <52329525+Manan17@users.noreply.github.com> Date: Fri, 3 Apr 2026 00:03:35 -0500 Subject: [PATCH 03/15] Fix/gemma4 install script (#4815) * transformer 5.5.0 has now been released * fallback for python < 3.10 : --- install_gemma4_mlx.sh | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/install_gemma4_mlx.sh b/install_gemma4_mlx.sh index e06339e204..b653af9154 100755 --- a/install_gemma4_mlx.sh +++ b/install_gemma4_mlx.sh @@ -132,29 +132,17 @@ step "install" "installing mlx, mlx-lm..." uv pip install --python "$_VENV_PY" -q mlx mlx-lm 2>/dev/null substep "done" -TRANSFORMERS_WHL="transformers-5.5.0-py3-none-any.whl" -TRANSFORMERS_GH="git+https://github.com/huggingface/transformers.git@v5.5-release" - step "install" "installing transformers>=5.5.0..." -if uv pip install --python "$_VENV_PY" -q "$TRANSFORMERS_GH" 2>/dev/null; then - substep "installed from huggingface/transformers v5.5-release" -elif uv pip install --python "$_VENV_PY" -q "transformers>=5.5.0" 2>/dev/null; then +if uv pip install --python "$_VENV_PY" -q "transformers>=5.5.0" 2>/dev/null; then substep "installed from PyPI" else - substep "not on PyPI, trying unsloth branch..." - _whl_tmp=$(mktemp -d)/"${TRANSFORMERS_WHL}" - if curl -fsSL "${REPO_URL}/${TRANSFORMERS_WHL}" -o "$_whl_tmp" 2>/dev/null && \ - uv pip install --python "$_VENV_PY" -q "$_whl_tmp" 2>/dev/null; then - substep "installed from branch ${BRANCH}" - elif [ -f "./${TRANSFORMERS_WHL}" ]; then - substep "using local ./${TRANSFORMERS_WHL}" - uv pip install --python "$_VENV_PY" -q "./${TRANSFORMERS_WHL}" + substep "PyPI install failed (Python <3.10?), trying GitHub..." + if uv pip install --python "$_VENV_PY" -q "git+https://github.com/huggingface/transformers.git@v5.5-release" 2>/dev/null; then + substep "installed from huggingface/transformers v5.5-release" else - rm -f "$_whl_tmp" 2>/dev/null - step "install" "skipping transformers — could not find >=5.5.0" "$C_WARN" - substep "tried: huggingface/transformers v5.5-release, PyPI, branch ${BRANCH}, local ./${TRANSFORMERS_WHL}" + step "warning" "could not install transformers>=5.5.0" "$C_WARN" + substep "tried: PyPI, huggingface/transformers v5.5-release" fi - rm -f "$_whl_tmp" 2>/dev/null fi # ── Find mlx-lm models directory ───────────────────────────── From c1685b945902393e863a1ccddc347aa5229769df Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:54:03 -0700 Subject: [PATCH 04/15] Gemma 4 update.md --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 26a578656c..7046a2af7c 100644 --- a/README.md +++ b/README.md @@ -109,18 +109,19 @@ You can use the same Docker image as Unsloth Studio. For RTX 50x, B200, 6000 GPUs: `uv pip install unsloth --torch-backend=auto`. Read our guides for: [Blackwell](https://unsloth.ai/docs/blog/fine-tuning-llms-with-blackwell-rtx-50-series-and-unsloth) and [DGX Spark](https://unsloth.ai/docs/blog/fine-tuning-llms-with-nvidia-dgx-spark-and-unsloth).
To install Unsloth on **AMD** and **Intel** GPUs, follow our [AMD Guide](https://unsloth.ai/docs/get-started/install/amd) and [Intel Guide](https://unsloth.ai/docs/get-started/install/intel). -## ✨ Free Notebooks +## 📒 Free Notebooks -Train for free with our notebooks. Read our [guide](https://unsloth.ai/docs/get-started/fine-tuning-llms-guide). Add dataset, run, then deploy your trained model. +Train for free with our notebooks. You can use our new [free Unsloth Studio notebook](https://colab.research.google.com/github/unslothai/unsloth/blob/main/studio/Unsloth_Studio_Colab.ipynb) to run and train models for free in a web UI. +Read our [guide](https://unsloth.ai/docs/get-started/fine-tuning-llms-guide). Add dataset, run, then deploy your trained model. | Model | Free Notebooks | Performance | Memory use | |-----------|---------|--------|----------| +| **Gemma 4 (E2B)** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Gemma4_(E2B)-Vision.ipynb) | 1.5x faster | 50% less | | **Qwen3.5 (4B)** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Qwen3_5_(4B)_Vision.ipynb) | 1.5x faster | 60% less | | **gpt-oss (20B)** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/gpt-oss-(20B)-Fine-tuning.ipynb) | 2x faster | 70% less | | **Qwen3.5 GSPO** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Qwen3_5_(4B)_Vision_GRPO.ipynb) | 2x faster | 70% less | | **gpt-oss (20B): GRPO** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/gpt-oss-(20B)-GRPO.ipynb) | 2x faster | 80% less | | **Qwen3: Advanced GRPO** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Qwen3_(4B)-GRPO.ipynb) | 2x faster | 70% less | -| **Gemma 3 (4B) Vision** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Gemma3_(4B)-Vision.ipynb) | 1.7x faster | 60% less | | **embeddinggemma (300M)** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/EmbeddingGemma_(300M).ipynb) | 2x faster | 20% less | | **Mistral Ministral 3 (3B)** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Ministral_3_VL_(3B)_Vision.ipynb) | 1.5x faster | 60% less | | **Llama 3.1 (8B) Alpaca** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Llama3.1_(8B)-Alpaca.ipynb) | 2x faster | 70% less | @@ -132,6 +133,7 @@ Train for free with our notebooks. Read our [guide](https://unsloth.ai/docs/get- - See detailed documentation for Unsloth [here](https://unsloth.ai/docs) ## 🦥 Unsloth News +- **Gemma 4**: Run and train Google’s new models directly in Unsloth Studio! [Blog](https://unsloth.ai/docs/models/gemma-4) - **Introducing Unsloth Studio**: our new web UI for running and training LLMs. [Blog](https://unsloth.ai/docs/new/studio) - **Qwen3.5** - 0.8B, 2B, 4B, 9B, 27B, 35-A3B, 112B-A10B are now supported. [Guide + notebooks](https://unsloth.ai/docs/models/qwen3.5/fine-tune) - Train **MoE LLMs 12x faster** with 35% less VRAM - DeepSeek, GLM, Qwen and gpt-oss. [Blog](https://unsloth.ai/docs/new/faster-moe) From ac562bac668347b70017dce7540fad1dd96989bc Mon Sep 17 00:00:00 2001 From: DoubleMathew Date: Fri, 3 Apr 2026 02:34:20 -0500 Subject: [PATCH 05/15] Fix/llama.cppbuilding (#4804) * Simplify llama.cpp install logic * print release tag * Retry failed json decode * don't pull all ggml releases * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Remove test file changes from main PR Test changes for test_pr4562_bugfixes.py will be submitted in a separate PR to keep this PR focused on the install path simplification. * Fix setup.sh executable bit and direct tag lookup for pinned releases - Restore setup.sh file mode to 100755 (was accidentally changed to 100644) - Add direct GitHub API tag lookup in iter_release_payloads_by_time for non-latest requested tags (e.g. b7879) instead of relying on paginated release scans that may miss older releases beyond the 5-page limit - Update stale DEFAULT_PUBLISHED_REPO comment to match new value * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix force-compile default ref and remove dead code in setup.ps1 - Change FORCE_COMPILE_DEFAULT_REF from "main" to "master" in all three files (install_llama_prebuilt.py, setup.sh, setup.ps1) since ggml-org/llama.cpp uses "master" as its default branch, not "main". Using "main" would cause git clone --branch to fail when UNSLOTH_LLAMA_FORCE_COMPILE=1 with UNSLOTH_LLAMA_TAG=latest. - Remove dead if ($SkipPrebuiltInstall) block inside the else branch of setup.ps1 that could never be reached (the outer elseif already handles $SkipPrebuiltInstall=true). - Maintain setup.sh executable bit (100755). * Improve iter_release_payloads_by_time error handling for direct tag lookup When a pinned release tag is not found (HTTP 404), fall through to the paginated release scan instead of silently returning empty results. Non-404 errors (network failures, rate limits) are propagated to the caller so users get actionable error messages. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han --- studio/install_llama_prebuilt.py | 571 ++++++++++++++++++++++++++++--- studio/setup.ps1 | 160 ++++----- studio/setup.sh | 272 +++++++-------- 3 files changed, 711 insertions(+), 292 deletions(-) diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index 5e8fe314b4..8d06c7d0e1 100755 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -65,9 +65,10 @@ def env_int(name: str, default: int, *, minimum: int | None = None) -> int: # errors. Only use "master" temporarily when the latest release is missing # support for a new model architecture. DEFAULT_LLAMA_TAG = os.environ.get("UNSLOTH_LLAMA_TAG", "latest") -# Force all installs to use mainline llama.cpp from ggml-org. -# Previously: DEFAULT_PUBLISHED_REPO = os.environ.get("UNSLOTH_LLAMA_RELEASE_REPO", "unslothai/llama.cpp") -DEFAULT_PUBLISHED_REPO = "ggml-org/llama.cpp" +# Default published repo for prebuilt release resolution. Linux uses +# Unsloth prebuilts; setup.sh/setup.ps1 pass --published-repo explicitly +# for macOS/Windows to override with ggml-org/llama.cpp when needed. +DEFAULT_PUBLISHED_REPO = "unslothai/llama.cpp" DEFAULT_PUBLISHED_TAG = os.environ.get("UNSLOTH_LLAMA_RELEASE_TAG") DEFAULT_PUBLISHED_MANIFEST_ASSET = os.environ.get( "UNSLOTH_LLAMA_RELEASE_MANIFEST_ASSET", "llama-prebuilt-manifest.json" @@ -89,6 +90,12 @@ GITHUB_AUTH_HOSTS = {"api.github.com", "github.com"} RETRYABLE_HTTP_STATUS = {408, 429, 500, 502, 503, 504} HTTP_FETCH_ATTEMPTS = 4 HTTP_FETCH_BASE_DELAY_SECONDS = 0.75 +JSON_FETCH_ATTEMPTS = 3 +DEFAULT_GITHUB_RELEASE_SCAN_MAX_PAGES = env_int( + "UNSLOTH_LLAMA_GITHUB_RELEASE_SCAN_MAX_PAGES", + 5, + minimum = 1, +) SERVER_PORT_BIND_ATTEMPTS = 3 SERVER_BIND_RETRY_WINDOW_SECONDS = 5.0 TTY_PROGRESS_START_DELAY_SECONDS = 0.5 @@ -97,6 +104,58 @@ DEFAULT_MAX_PREBUILT_RELEASE_FALLBACKS = env_int( 2, minimum = 1, ) +FORCE_COMPILE_DEFAULT_REF = os.environ.get("UNSLOTH_LLAMA_FORCE_COMPILE_REF", "master") + +DIRECT_LINUX_BUNDLE_PROFILES: dict[str, dict[str, Any]] = { + "cuda12-older": { + "runtime_line": "cuda12", + "coverage_class": "older", + "supported_sms": ["70", "75", "80", "86", "89"], + "min_sm": 70, + "max_sm": 89, + "rank": 10, + }, + "cuda12-newer": { + "runtime_line": "cuda12", + "coverage_class": "newer", + "supported_sms": ["86", "89", "90", "100", "120"], + "min_sm": 86, + "max_sm": 120, + "rank": 20, + }, + "cuda12-portable": { + "runtime_line": "cuda12", + "coverage_class": "portable", + "supported_sms": ["70", "75", "80", "86", "89", "90", "100", "120"], + "min_sm": 70, + "max_sm": 120, + "rank": 30, + }, + "cuda13-older": { + "runtime_line": "cuda13", + "coverage_class": "older", + "supported_sms": ["75", "80", "86", "89"], + "min_sm": 75, + "max_sm": 89, + "rank": 40, + }, + "cuda13-newer": { + "runtime_line": "cuda13", + "coverage_class": "newer", + "supported_sms": ["86", "89", "90", "100", "120"], + "min_sm": 86, + "max_sm": 120, + "rank": 50, + }, + "cuda13-portable": { + "runtime_line": "cuda13", + "coverage_class": "portable", + "supported_sms": ["75", "80", "86", "89", "90", "100", "120"], + "min_sm": 75, + "max_sm": 120, + "rank": 60, + }, +} @dataclass @@ -753,32 +812,48 @@ def download_bytes( def fetch_json(url: str) -> Any: - try: - data = download_bytes( - url, - timeout = 30, - headers = github_api_headers(url) - if is_github_api_url(url) - else 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: - raise RuntimeError(f"downloaded empty JSON payload from {url}") - try: - payload = json.loads(data.decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError) as exc: - raise RuntimeError(f"downloaded invalid JSON from {url}: {exc}") from exc - if not isinstance(payload, dict) and not isinstance(payload, list): - raise RuntimeError( - f"downloaded unexpected JSON type from {url}: {type(payload).__name__}" - ) - return payload + attempts = 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 = download_bytes( + url, + timeout = 30, + headers = github_api_headers(url) + if is_github_api_url(url) + else 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 + 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(url: str, destination: Path) -> None: @@ -838,12 +913,16 @@ def download_file_verified( url: str, destination: Path, *, - expected_sha256: str, + expected_sha256: str | None, label: str, ) -> None: normalized_expected = normalize_sha256_digest(expected_sha256) if not normalized_expected: - raise PrebuiltFallback(f"{label} did not have a valid approved sha256") + download_file(url, destination) + log( + f"downloaded {label} without a published sha256; relying on install validation" + ) + return for attempt in range(1, 3): download_file(url, destination) @@ -898,7 +977,12 @@ def github_release(repo: str, tag: str) -> dict[str, Any]: return payload -def github_releases(repo: str, *, per_page: int = 100) -> list[dict[str, Any]]: +def github_releases( + repo: str, + *, + per_page: int = 100, + max_pages: int = 0, +) -> list[dict[str, Any]]: releases: list[dict[str, Any]] = [] page = 1 while True: @@ -912,6 +996,8 @@ def github_releases(repo: str, *, per_page: int = 100) -> list[dict[str, Any]]: if len(payload) < per_page: break page += 1 + if max_pages > 0 and page > max_pages: + break return releases @@ -925,6 +1011,372 @@ def latest_upstream_release_tag() -> str: return tag +def is_release_tag_like(value: str | None) -> bool: + return isinstance(value, str) and bool(re.fullmatch(r"b\d+", value.strip())) + + +def release_time_sort_key(release: dict[str, Any]) -> tuple[str, int]: + published_at = release.get("published_at") + created_at = release.get("created_at") + release_id = release.get("id") + timestamp = ( + published_at + if isinstance(published_at, str) and published_at + else created_at + if isinstance(created_at, str) and created_at + else "" + ) + try: + normalized_id = int(release_id) + except (TypeError, ValueError): + normalized_id = 0 + return (timestamp, normalized_id) + + +def iter_release_payloads_by_time( + repo: str, + published_release_tag: str = "", + requested_tag: str = "", +) -> Iterable[dict[str, Any]]: + if published_release_tag: + yield github_release(repo, published_release_tag) + return + + if ( + requested_tag + and requested_tag != "latest" + and is_release_tag_like(requested_tag) + ): + try: + yield github_release(repo, requested_tag) + return + except urllib.error.HTTPError as exc: + if exc.code == 404: + log( + f"release tag {requested_tag} not found in {repo}; scanning recent releases" + ) + else: + raise + except Exception: + raise + + releases = [ + release + for release in github_releases( + repo, max_pages = DEFAULT_GITHUB_RELEASE_SCAN_MAX_PAGES + ) + if isinstance(release, dict) + and not release.get("draft") + and not release.get("prerelease") + ] + releases.sort(key = release_time_sort_key, reverse = True) + for release in releases: + yield release + + +def direct_release_matches_request( + *, release_tag: str, llama_tag: str, requested_tag: str +) -> bool: + if requested_tag == "latest": + return True + for candidate in (release_tag, llama_tag): + if refs_match(candidate, requested_tag): + return True + return False + + +def synthetic_checksums_for_release( + repo: str, release_tag: str, upstream_tag: str +) -> ApprovedReleaseChecksums: + return ApprovedReleaseChecksums( + repo = repo, + release_tag = release_tag, + upstream_tag = upstream_tag, + artifacts = {}, + ) + + +def parse_direct_linux_release_bundle( + repo: str, release: dict[str, Any] +) -> PublishedReleaseBundle | None: + release_tag = release.get("tag_name") + if not isinstance(release_tag, str) or not release_tag: + return None + + assets = release_asset_map(release) + artifacts: list[PublishedLlamaArtifact] = [] + inferred_labels: list[str] = [] + + linux_asset_re = re.compile( + r"^app-(?P