Merge branch 'main' into pip

This commit is contained in:
Daniel Han 2026-04-03 15:02:41 -07:00
commit 9ad3b761ee
41 changed files with 2450 additions and 451 deletions

View file

@ -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). <br>
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 Googles 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)

View file

@ -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 ─────────────────────────────

View file

@ -93,6 +93,14 @@
"min_p": 0.0,
"repetition_penalty": 1.0
},
"gemma-4": {
"temperature": 1.0,
"top_p": 0.95,
"top_k": 64,
"min_p": 0.0,
"repetition_penalty": 1.0,
"presence_penalty": 0.0
},
"gemma-3n": {
"temperature": 1.0,
"top_p": 0.95,
@ -366,7 +374,7 @@
"qwen2.5-coder", "qwen2.5-vl", "qwen2.5-omni", "qwen2.5-math", "qwen2.5",
"qwen2-vl", "qwen2",
"qwq",
"gemma-3n", "gemma-3", "medgemma", "gemma-2",
"gemma-4", "gemma-3n", "gemma-3", "medgemma", "gemma-2",
"llama-4", "llama-3.3", "llama-3.2", "llama-3.1", "llama-3",
"phi-4", "phi-3",
"mistral-nemo", "mistral-small", "mistral-large", "magistral", "ministral",

View file

@ -0,0 +1,47 @@
# Model defaults for unsloth/gemma-4-26B-A4B-it
# Also applies to: google/gemma-4-26B-A4B-it, unsloth/gemma-4-26B-A4B-it-GGUF
training:
trust_remote_code: false
max_seq_length: 2048
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false
train_on_completions: true
gradient_checkpointing: "unsloth"
optim: "adamw_8bit"
lr_scheduler_type: "linear"
lora:
lora_r: 8
lora_alpha: 8
lora_dropout: 0.0
target_modules:
- "all-linear"
use_rslora: false
use_loftq: false
finetune_vision_layers: true
finetune_language_layers: true
finetune_attention_modules: true
finetune_mlp_modules: true
logging:
enable_wandb: false
wandb_project: "llm-finetuning"
enable_tensorboard: false
tensorboard_dir: "runs"
log_frequency: 10
inference:
trust_remote_code: false
temperature: 1.0
top_p: 0.95
top_k: 64
min_p: 0.0

View file

@ -0,0 +1,47 @@
# Model defaults for unsloth/gemma-4-26B-A4B (base/pretrained)
# Also applies to: google/gemma-4-26B-A4B
training:
trust_remote_code: false
max_seq_length: 2048
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false
train_on_completions: true
gradient_checkpointing: "unsloth"
optim: "adamw_8bit"
lr_scheduler_type: "linear"
lora:
lora_r: 8
lora_alpha: 8
lora_dropout: 0.0
target_modules:
- "all-linear"
use_rslora: false
use_loftq: false
finetune_vision_layers: true
finetune_language_layers: true
finetune_attention_modules: true
finetune_mlp_modules: true
logging:
enable_wandb: false
wandb_project: "llm-finetuning"
enable_tensorboard: false
tensorboard_dir: "runs"
log_frequency: 10
inference:
trust_remote_code: false
temperature: 1.0
top_p: 0.95
top_k: 64
min_p: 0.0

View file

@ -0,0 +1,47 @@
# Model defaults for unsloth/gemma-4-31B-it
# Also applies to: google/gemma-4-31B-it, unsloth/gemma-4-31B-it-GGUF
training:
trust_remote_code: false
max_seq_length: 2048
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false
train_on_completions: true
gradient_checkpointing: "unsloth"
optim: "adamw_8bit"
lr_scheduler_type: "linear"
lora:
lora_r: 8
lora_alpha: 8
lora_dropout: 0.0
target_modules:
- "all-linear"
use_rslora: false
use_loftq: false
finetune_vision_layers: true
finetune_language_layers: true
finetune_attention_modules: true
finetune_mlp_modules: true
logging:
enable_wandb: false
wandb_project: "llm-finetuning"
enable_tensorboard: false
tensorboard_dir: "runs"
log_frequency: 10
inference:
trust_remote_code: false
temperature: 1.0
top_p: 0.95
top_k: 64
min_p: 0.0

View file

@ -0,0 +1,47 @@
# Model defaults for unsloth/gemma-4-31B (base/pretrained)
# Also applies to: google/gemma-4-31B
training:
trust_remote_code: false
max_seq_length: 2048
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false
train_on_completions: true
gradient_checkpointing: "unsloth"
optim: "adamw_8bit"
lr_scheduler_type: "linear"
lora:
lora_r: 8
lora_alpha: 8
lora_dropout: 0.0
target_modules:
- "all-linear"
use_rslora: false
use_loftq: false
finetune_vision_layers: true
finetune_language_layers: true
finetune_attention_modules: true
finetune_mlp_modules: true
logging:
enable_wandb: false
wandb_project: "llm-finetuning"
enable_tensorboard: false
tensorboard_dir: "runs"
log_frequency: 10
inference:
trust_remote_code: false
temperature: 1.0
top_p: 0.95
top_k: 64
min_p: 0.0

View file

@ -0,0 +1,47 @@
# Model defaults for unsloth/gemma-4-E2B-it
# Also applies to: google/gemma-4-E2B-it, unsloth/gemma-4-E2B-it-GGUF
training:
trust_remote_code: false
max_seq_length: 2048
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false
train_on_completions: true
gradient_checkpointing: "unsloth"
optim: "adamw_8bit"
lr_scheduler_type: "linear"
lora:
lora_r: 8
lora_alpha: 8
lora_dropout: 0.0
target_modules:
- "all-linear"
use_rslora: false
use_loftq: false
finetune_vision_layers: true
finetune_language_layers: true
finetune_attention_modules: true
finetune_mlp_modules: true
logging:
enable_wandb: false
wandb_project: "llm-finetuning"
enable_tensorboard: false
tensorboard_dir: "runs"
log_frequency: 10
inference:
trust_remote_code: false
temperature: 1.0
top_p: 0.95
top_k: 64
min_p: 0.0

View file

@ -0,0 +1,47 @@
# Model defaults for unsloth/gemma-4-E2B (base/pretrained)
# Also applies to: google/gemma-4-E2B
training:
trust_remote_code: false
max_seq_length: 2048
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false
train_on_completions: true
gradient_checkpointing: "unsloth"
optim: "adamw_8bit"
lr_scheduler_type: "linear"
lora:
lora_r: 8
lora_alpha: 8
lora_dropout: 0.0
target_modules:
- "all-linear"
use_rslora: false
use_loftq: false
finetune_vision_layers: true
finetune_language_layers: true
finetune_attention_modules: true
finetune_mlp_modules: true
logging:
enable_wandb: false
wandb_project: "llm-finetuning"
enable_tensorboard: false
tensorboard_dir: "runs"
log_frequency: 10
inference:
trust_remote_code: false
temperature: 1.0
top_p: 0.95
top_k: 64
min_p: 0.0

View file

@ -0,0 +1,47 @@
# Model defaults for unsloth/gemma-4-E4B-it
# Also applies to: google/gemma-4-E4B-it, unsloth/gemma-4-E4B-it-GGUF
training:
trust_remote_code: false
max_seq_length: 2048
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false
train_on_completions: true
gradient_checkpointing: "unsloth"
optim: "adamw_8bit"
lr_scheduler_type: "linear"
lora:
lora_r: 8
lora_alpha: 8
lora_dropout: 0.0
target_modules:
- "all-linear"
use_rslora: false
use_loftq: false
finetune_vision_layers: true
finetune_language_layers: true
finetune_attention_modules: true
finetune_mlp_modules: true
logging:
enable_wandb: false
wandb_project: "llm-finetuning"
enable_tensorboard: false
tensorboard_dir: "runs"
log_frequency: 10
inference:
trust_remote_code: false
temperature: 1.0
top_p: 0.95
top_k: 64
min_p: 0.0

View file

@ -0,0 +1,47 @@
# Model defaults for unsloth/gemma-4-E4B (base/pretrained)
# Also applies to: google/gemma-4-E4B
training:
trust_remote_code: false
max_seq_length: 2048
num_epochs: 0
learning_rate: 2e-4
batch_size: 2
gradient_accumulation_steps: 4
warmup_steps: 5
max_steps: 30
save_steps: 30
weight_decay: 0.001
random_seed: 3407
packing: false
train_on_completions: true
gradient_checkpointing: "unsloth"
optim: "adamw_8bit"
lr_scheduler_type: "linear"
lora:
lora_r: 8
lora_alpha: 8
lora_dropout: 0.0
target_modules:
- "all-linear"
use_rslora: false
use_loftq: false
finetune_vision_layers: true
finetune_language_layers: true
finetune_attention_modules: true
finetune_mlp_modules: true
logging:
enable_wandb: false
wandb_project: "llm-finetuning"
enable_tensorboard: false
tensorboard_dir: "runs"
log_frequency: 10
inference:
trust_remote_code: false
temperature: 1.0
top_p: 0.95
top_k: 64
min_p: 0.0

View file

@ -167,12 +167,7 @@ def _validate_recipe_runtime_support(
recipe: dict[str, Any],
model_providers: list[Any],
) -> None:
if not _recipe_has_llm_columns(recipe):
raise ValueError(
"Recipe Studio currently requires at least one AI generation step."
)
if not model_providers:
if _recipe_has_llm_columns(recipe) and not model_providers:
raise ValueError("Add a Provider connection block before running this recipe.")
@ -266,6 +261,21 @@ def create_data_designer(
model_providers = build_model_providers(recipe)
_validate_recipe_runtime_support(recipe, model_providers)
# DataDesigner requires at least one model provider in its registry even
# when the pipeline contains no LLM columns. Supply a lightweight stub
# so sampler/expression-only recipes can run without a real provider.
if not model_providers:
from data_designer.config.models import ModelProvider
model_providers = [
ModelProvider(
name = "_unused",
endpoint = "http://localhost",
provider_type = "openai",
api_key = None,
)
]
return DataDesigner(
artifact_path = artifact_path,
model_providers = model_providers,

View file

@ -22,6 +22,7 @@ import threading
import time
from pathlib import Path
from typing import Generator, Optional
from urllib.parse import urlparse
import httpx
@ -52,8 +53,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 = [
@ -102,6 +109,7 @@ class LlamaCppBackend:
self._supports_tools: bool = False
self._cache_type_kv: Optional[str] = None
self._reasoning_default: bool = True
self._speculative_type: Optional[str] = None
# KV-cache estimation fields (populated by _read_gguf_metadata)
self._n_layers: Optional[int] = None
self._n_kv_heads: Optional[int] = None
@ -191,6 +199,10 @@ class LlamaCppBackend:
def cache_type_kv(self) -> Optional[str]:
return self._cache_type_kv
@property
def speculative_type(self) -> Optional[str]:
return self._speculative_type
# ── Binary discovery ──────────────────────────────────────────
@staticmethod
@ -1048,6 +1060,7 @@ class LlamaCppBackend:
n_ctx: int = 4096,
chat_template_override: Optional[str] = None,
cache_type_kv: Optional[str] = None,
speculative_type: Optional[str] = None,
n_threads: Optional[int] = None,
n_gpu_layers: Optional[int] = None, # Accepted for caller compat, unused
) -> bool:
@ -1308,6 +1321,46 @@ class LlamaCppBackend:
else:
self._cache_type_kv = None
# Speculative decoding (n-gram self-speculation, zero VRAM cost)
# ngram-mod: ~16 MB shared hash pool, constant memory/complexity,
# variable draft lengths. Helps most when the model repeats
# existing text (code refactoring, summarization, reasoning).
# For general chat with low repetition, overhead is ~5 ms.
#
# Benchmarks from llama.cpp PRs #18471, #19164:
# Scenario | Without | With | Speedup
# gpt-oss-120b code refactor | 181 t/s | 446 t/s | 2.5x
# Qwen3-235B offloaded | 12 t/s | 21 t/s | 1.8x
# gpt-oss-120b repeat (92% accept)| 181 t/s | 814 t/s | 4.5x
#
# Params from llama.cpp docs (docs/speculative.md):
# --spec-ngram-size-n 24 (small n not recommended)
# --draft-min 48 --draft-max 64 (MoEs need long drafts;
# dense models can reduce these)
# ref: https://github.com/ggml-org/llama.cpp/blob/master/docs/speculative.md
# ref: https://github.com/ggml-org/llama.cpp/pull/19164
# ref: https://github.com/ggml-org/llama.cpp/pull/18471
_valid_spec_types = {"ngram-simple", "ngram-mod"}
if speculative_type and speculative_type in _valid_spec_types:
if not is_vision: # spec decoding disabled for vision models
cmd.extend(["--spec-type", speculative_type])
if speculative_type == "ngram-mod":
cmd.extend(
[
"--spec-ngram-size-n",
"24",
"--draft-min",
"48",
"--draft-max",
"64",
]
)
self._speculative_type = speculative_type
else:
self._speculative_type = None
else:
self._speculative_type = None
# Apply custom chat template override if provided
if chat_template_override:
import tempfile
@ -1546,6 +1599,7 @@ class LlamaCppBackend:
self._reasoning_always_on = False
self._supports_tools = False
self._cache_type_kv = None
self._speculative_type = None
self._n_layers = None
self._n_kv_heads = None
self._n_heads = None
@ -2264,7 +2318,7 @@ class LlamaCppBackend:
Agentic loop: let the model call tools, execute them, and continue.
Yields dicts with:
{"type": "status", "text": "Searching: ..."} -- tool status updates
{"type": "status", "text": "Searching: ..."/"Reading: ..."} -- tool status updates
{"type": "content", "text": "token"} -- streamed content tokens (cumulative)
{"type": "reasoning", "text": "token"} -- streamed reasoning tokens (cumulative)
"""
@ -2831,7 +2885,18 @@ class LlamaCppBackend:
arguments = raw_args
if tool_name == "web_search":
status_text = f"Searching: {arguments.get('query', '')}"
_ws_url = (arguments.get("url") or "").strip()
if _ws_url:
_parsed = urlparse(_ws_url)
if _parsed.scheme in ("http", "https") and _parsed.hostname:
_ws_host = _parsed.hostname
if _ws_host.startswith("www."):
_ws_host = _ws_host[4:]
status_text = f"Reading: {_ws_host}"
else:
status_text = "Reading page..."
else:
status_text = f"Searching: {arguments.get('query', '')}"
elif tool_name == "python":
preview = (
(arguments.get("code") or "").strip().split("\n")[0][:60]

View file

@ -14,6 +14,8 @@ import os
os.environ["UNSLOTH_IS_PRESENT"] = "1"
import random
import re
import shlex
import ssl
import subprocess
import sys
@ -27,14 +29,239 @@ logger = get_logger(__name__)
_EXEC_TIMEOUT = 300 # 5 minutes
# Pre-import modules used in _sandbox_preexec at module level so that
# the preexec_fn closure does not trigger the import machinery in the
# forked child (which can deadlock in multi-threaded servers).
_libc = None
if sys.platform == "linux":
try:
import ctypes
import ctypes.util
_libc_name = ctypes.util.find_library("c")
if _libc_name:
_libc = ctypes.CDLL(_libc_name, use_errno = True)
except (OSError, AttributeError):
pass
_resource = None
if sys.platform != "win32":
try:
import resource as _resource
except ImportError:
pass
# Strict raster-image allowlist for sandbox file serving.
# No .svg (XSS risk via embedded scripts), no .html, no .pdf.
_IMAGE_EXTS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"})
_MAX_OUTPUT_CHARS = 8000 # truncate long output
_BASH_BLOCKED_WORDS = {"rm", "sudo", "dd", "chmod", "mkfs", "shutdown", "reboot"}
_BLOCKED_COMMANDS_COMMON = frozenset(
{
"rm",
"sudo",
"su",
"dd",
"chmod",
"chown",
"mkfs",
"shutdown",
"reboot",
"passwd",
"mount",
"umount",
"fdisk",
"kill",
"killall",
"pkill",
}
)
_BLOCKED_COMMANDS_WIN = frozenset(
{
"rmdir",
"takeown",
"icacls",
"runas",
"powershell",
"pwsh",
}
)
_BLOCKED_COMMANDS = (
_BLOCKED_COMMANDS_COMMON | _BLOCKED_COMMANDS_WIN
if sys.platform == "win32"
else _BLOCKED_COMMANDS_COMMON
)
def _find_blocked_commands(command: str) -> set[str]:
"""Detect blocked commands using shlex tokenization and regex scanning.
Catches: full paths (/usr/bin/sudo), quoted strings ("sudo"),
split-quotes (su""do), backslash escapes (\\rm), and command-position
words after ;, |, &&, $().
"""
blocked = set()
# 1. shlex tokenization (handles quotes, escapes, concatenation)
try:
tokens = (
shlex.split(command)
if sys.platform != "win32"
else shlex.split(command, posix = False)
)
except ValueError:
tokens = command.split()
for token in tokens:
base = os.path.basename(token).lower()
# Strip common Windows executable extensions so that
# runas.exe, shutdown.bat, etc. match the blocklist.
stem, ext = os.path.splitext(base)
if ext in {".exe", ".com", ".bat", ".cmd"}:
base = stem
if base in _BLOCKED_COMMANDS:
blocked.add(base)
# 2. Regex: catch blocked words at shell command boundaries
# (semicolons, pipes, &&, ||, backticks, $(), <(), subshells, newlines)
# Uses a single combined pattern for all blocked words.
# Handles optional Unix path prefix (/usr/bin/) and Windows drive
# letter prefix (C:\Windows\...\).
lowered = command.lower()
if _BLOCKED_COMMANDS:
words_alt = "|".join(re.escape(w) for w in sorted(_BLOCKED_COMMANDS))
pattern = (
rf"(?:^|[;&|`\n(]\s*|[$]\(\s*|<\(\s*)"
rf"(?:[\w./\\-]*/|[a-zA-Z]:[/\\][\w./\\-]*)?"
rf"({words_alt})(?:\.(?:exe|com|bat|cmd))?\b"
)
blocked.update(re.findall(pattern, lowered))
# 3. Check for nested shell invocations (bash -c 'sudo whoami',
# bash -lc '...', bash --login -c '...', cmd /c '...').
# When a -c or /c flag is found, look backwards for a shell name
# (skipping intermediate flags like --login, -l, -x) and recursively
# scan the nested command string.
_SHELLS = {"bash", "sh", "zsh", "dash", "ksh", "csh", "tcsh", "fish"}
_SHELLS_WIN = {"cmd", "cmd.exe"}
for i, token in enumerate(tokens):
tok_lower = token.lower()
# Match -c exactly, or combined flags ending in c (e.g. -lc, -xc)
is_unix_c = tok_lower == "-c" or (
tok_lower.startswith("-")
and tok_lower.endswith("c")
and not tok_lower.startswith("--")
)
is_win_c = tok_lower == "/c"
if not (is_unix_c or is_win_c) or i < 1 or i + 1 >= len(tokens):
continue
# Look backwards past any flags to find the shell binary.
# On Unix, flags start with - (skip those). On Windows, flags
# start with / but so do absolute paths, so only skip short
# single-char /X flags (not /bin/bash style paths).
for j in range(i - 1, -1, -1):
prev = tokens[j]
if prev.startswith("-"):
continue # skip Unix flags like --login, -l
if is_win_c and prev.startswith("/") and len(prev) <= 3:
continue # skip Windows flags like /s, /q (not /bin/bash)
prev_base = os.path.basename(prev).lower()
if is_unix_c and prev_base in _SHELLS:
blocked |= _find_blocked_commands(tokens[i + 1])
elif is_win_c and prev_base in _SHELLS_WIN:
blocked |= _find_blocked_commands(tokens[i + 1])
break # stop at first non-flag token
return blocked
def _build_safe_env(workdir: str) -> dict[str, str]:
"""Build a minimal, credential-free environment for sandboxed subprocesses.
Strips HF_TOKEN, WANDB_API_KEY, AWS_*, GH_TOKEN, LD_PRELOAD, DYLD_*, etc.
Preserves the active Python interpreter and virtualenv directories in PATH
so that pip, uv, and packages installed in the Studio runtime remain
accessible.
"""
# Start with the directory containing the running Python interpreter
# so that subprocess calls to 'python', 'pip', etc. resolve to the
# same environment the Studio server is running in.
exe_dir = os.path.dirname(sys.executable)
path_entries = [exe_dir] if exe_dir else []
# If a virtualenv is active, include its bin/Scripts directory.
venv = os.environ.get("VIRTUAL_ENV")
if venv:
venv_bin = os.path.join(venv, "Scripts" if sys.platform == "win32" else "bin")
if venv_bin not in path_entries:
path_entries.append(venv_bin)
if sys.platform == "win32":
sysroot = os.environ.get("SystemRoot", r"C:\Windows")
path_entries.extend([os.path.join(sysroot, "System32"), sysroot])
else:
path_entries.extend(["/usr/local/bin", "/usr/bin", "/bin"])
# Deduplicate while preserving order
deduped = list(dict.fromkeys(p for p in path_entries if p))
env = {
"PATH": os.pathsep.join(deduped),
"HOME": workdir,
"TMPDIR": workdir,
"LANG": os.environ.get("LANG", "C.UTF-8"),
"TERM": "dumb",
"PYTHONIOENCODING": "utf-8",
}
if venv:
env["VIRTUAL_ENV"] = venv
# Windows needs SystemRoot for Python/subprocess to work
if sys.platform == "win32":
env["SystemRoot"] = os.environ.get("SystemRoot", r"C:\Windows")
return env
def _sandbox_preexec():
"""Pre-exec hook: drop privilege escalation ability and set resource limits.
On Linux, applies PR_SET_NO_NEW_PRIVS so sudo/su/pkexec fail at the
kernel level. On Linux and macOS, sets RLIMIT_FSIZE.
No-op on Windows (use creationflags instead).
Note: RLIMIT_NPROC is intentionally NOT set because Linux enforces it
per real UID, not per process tree, so it would starve the Studio
server and other sessions sharing the same user account.
All modules and handles are resolved at import time (module level) so
this function does not trigger Python imports in the forked child,
avoiding potential deadlocks in multi-threaded servers.
"""
if _libc is not None:
try:
# PR_SET_NO_NEW_PRIVS = 38, arg2 = 1 (enable)
_libc.prctl(38, 1, 0, 0, 0)
except (OSError, AttributeError):
pass # Not available (container, old kernel, etc.)
if _resource is not None:
try:
# Limit file size to 100MB (prevents disk filling)
_resource.setrlimit(
_resource.RLIMIT_FSIZE, (100 * 1024 * 1024, 100 * 1024 * 1024)
)
except (ValueError, OSError):
pass
def _get_shell_cmd(command: str) -> list[str]:
"""Return the platform-appropriate shell invocation for a command string."""
if sys.platform == "win32":
return ["cmd", "/c", command]
return ["bash", "-c", command]
# Per-session working directories so each chat thread gets its own sandbox.
# Falls back to a shared ~/studio_sandbox/ for API callers without a session_id.
# Falls back to a shared ~/studio_sandbox/_default for API callers without a
# session_id.
_workdirs: dict[str, str] = {}
@ -55,7 +282,7 @@ def _get_workdir(session_id: str | None = None) -> str:
if not os.path.realpath(workdir).startswith(os.path.realpath(sandbox_root)):
workdir = os.path.join(sandbox_root, "_invalid")
else:
workdir = sandbox_root
workdir = os.path.join(sandbox_root, "_default")
os.makedirs(workdir, exist_ok = True)
_workdirs[key] = workdir
return _workdirs[key]
@ -428,6 +655,7 @@ def _check_signal_escape_patterns(code: str):
signal_tampering = []
exception_catching = []
shell_escapes = []
warnings = []
def _ast_name_matches(node, names):
@ -445,10 +673,84 @@ def _check_signal_escape_patterns(code: str):
return full_name in names
return False
# Dangerous os/subprocess functions that can execute shell commands
_SHELL_EXEC_FUNCS = frozenset(
{
"os.system",
"os.popen",
"os.popen2",
"os.popen3",
"os.popen4",
"os.execl",
"os.execle",
"os.execlp",
"os.execlpe",
"os.execv",
"os.execve",
"os.execvp",
"os.execvpe",
"os.spawnl",
"os.spawnle",
"os.spawnlp",
"os.spawnlpe",
"os.spawnv",
"os.spawnve",
"os.spawnvp",
"os.spawnvpe",
"os.posix_spawn",
"os.posix_spawnp",
"subprocess.run",
"subprocess.call",
"subprocess.check_call",
"subprocess.check_output",
"subprocess.Popen",
"subprocess.getoutput",
"subprocess.getstatusoutput",
}
)
def _extract_string_from_node(node):
"""Extract a plain string value from an AST node, if it is a constant."""
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
return None
def _extract_strings_from_list(node):
"""Extract string elements from an AST List or Tuple node."""
if isinstance(node, (ast.List, ast.Tuple)):
parts = []
for elt in node.elts:
s = _extract_string_from_node(elt)
if s is not None:
parts.append(s)
return parts
return []
# Keyword argument names that carry command content (as opposed to
# control flags like check=True, text=True, capture_output=True).
_CMD_KWARGS = frozenset({"args", "command", "executable", "path", "file"})
def _check_args_for_blocked(args_nodes):
"""Check if any call arguments contain blocked commands."""
found = set()
for arg in args_nodes:
s = _extract_string_from_node(arg)
if s is not None:
found |= _find_blocked_commands(s)
strs = _extract_strings_from_list(arg)
for s in strs:
found |= _find_blocked_commands(s)
return found
class SignalEscapeVisitor(ast.NodeVisitor):
def __init__(self):
self.imports_signal = False
self.signal_aliases = {"signal"}
self.os_aliases = {"os"}
self.subprocess_aliases = {"subprocess"}
# Maps bare function names to their fully-qualified form
# for from-import tracking (e.g. "system" -> "os.system")
self.shell_exec_aliases: dict[str, str] = {}
self.loop_depth = 0
def visit_Import(self, node):
@ -457,6 +759,10 @@ def _check_signal_escape_patterns(code: str):
self.imports_signal = True
if alias.asname:
self.signal_aliases.add(alias.asname)
elif alias.name == "os":
self.os_aliases.add(alias.asname or "os")
elif alias.name == "subprocess":
self.subprocess_aliases.add(alias.asname or "subprocess")
self.generic_visit(node)
def visit_ImportFrom(self, node):
@ -474,6 +780,16 @@ def _check_signal_escape_patterns(code: str):
"alarm",
):
self.signal_aliases.add(alias.asname or alias.name)
elif node.module in ("os", "subprocess"):
if node.module == "os":
self.os_aliases.add("os")
else:
self.subprocess_aliases.add("subprocess")
# Track from-imports of dangerous functions
for alias in node.names:
fq = f"{node.module}.{alias.name}"
if fq in _SHELL_EXEC_FUNCS:
self.shell_exec_aliases[alias.asname or alias.name] = fq
self.generic_visit(node)
def visit_While(self, node):
@ -538,6 +854,111 @@ def _check_signal_escape_patterns(code: str):
"description": "Modifies signal mask (may block SIGALRM)",
}
)
# --- Shell escape detection ---
# Resolve the fully qualified function name for os.*/subprocess.*
shell_func = None
if isinstance(func, ast.Attribute):
if isinstance(func.value, ast.Name):
if func.value.id in self.os_aliases:
shell_func = f"os.{func.attr}"
elif func.value.id in self.subprocess_aliases:
shell_func = f"subprocess.{func.attr}"
elif isinstance(func, ast.Name):
# Check from-import aliases: from os import system; system(...)
shell_func = self.shell_exec_aliases.get(func.id)
if shell_func and shell_func in _SHELL_EXEC_FUNCS:
# Expand **kwargs dicts to inspect their keys
expanded_kwargs: dict[str, ast.AST] = {}
has_opaque_kwargs = False
for kw in node.keywords:
if kw.arg is not None:
expanded_kwargs[kw.arg] = kw.value
elif isinstance(kw.value, ast.Dict):
for k, v in zip(kw.value.keys, kw.value.values):
key = _extract_string_from_node(k) if k else None
if key is not None:
expanded_kwargs[key] = v
else:
has_opaque_kwargs = True
cmd_kw_values = [
v for k, v in expanded_kwargs.items() if k in _CMD_KWARGS
]
all_call_args = list(node.args) + cmd_kw_values
blocked_in_args = _check_args_for_blocked(all_call_args)
if has_opaque_kwargs:
# Can't inspect dynamic **kwargs -- flag as unsafe
shell_escapes.append(
{
"type": "shell_escape_dynamic",
"line": node.lineno,
"description": (
f"{shell_func}() called with dynamic **kwargs"
),
}
)
elif blocked_in_args:
shell_escapes.append(
{
"type": "shell_escape",
"line": node.lineno,
"description": (
f"{shell_func}() invokes blocked command(s): "
f"{', '.join(sorted(blocked_in_args))}"
),
}
)
else:
# Only flag dynamic args for functions that interpret
# strings as shell commands, or when shell= might be
# enabled. Treat any non-literal-False shell= value
# as potentially True (conservative).
_STRING_SHELL_FUNCS = frozenset(
{
"os.system",
"os.popen",
"os.popen2",
"os.popen3",
"os.popen4",
"subprocess.getoutput",
"subprocess.getstatusoutput",
}
)
shell_node = expanded_kwargs.get("shell")
shell_safe = shell_node is None or (
isinstance(shell_node, ast.Constant)
and shell_node.value is False
)
if shell_func in _STRING_SHELL_FUNCS or not shell_safe:
def _is_safe_literal(n):
if _extract_string_from_node(n) is not None:
return True
if isinstance(n, (ast.List, ast.Tuple)):
return all(
_extract_string_from_node(e) is not None
for e in n.elts
)
return False
has_non_literal = any(
not _is_safe_literal(a) for a in all_call_args
)
if has_non_literal:
shell_escapes.append(
{
"type": "shell_escape_dynamic",
"line": node.lineno,
"description": (
f"{shell_func}() called with non-literal "
f"shell command (potential shell escape)"
),
}
)
self.generic_visit(node)
def visit_ExceptHandler(self, node):
@ -553,7 +974,12 @@ def _check_signal_escape_patterns(code: str):
}
)
elif isinstance(node.type, ast.Name):
if node.type.id in ("TimeoutError", "BaseException", "Exception"):
# Only flag BaseException and TimeoutError, NOT Exception.
# except Exception does not catch SystemExit or
# KeyboardInterrupt, so it cannot suppress timeout
# enforcement. Flagging Exception causes false positives
# on normal error-handling patterns.
if node.type.id in ("TimeoutError", "BaseException"):
exception_catching.append(
{
"type": f"catches_{node.type.id}_in_loop",
@ -564,7 +990,7 @@ def _check_signal_escape_patterns(code: str):
elif isinstance(node.type, ast.Tuple):
for elt in node.type.elts:
if isinstance(elt, ast.Name):
if elt.id in ("TimeoutError", "BaseException", "Exception"):
if elt.id in ("TimeoutError", "BaseException"):
exception_catching.append(
{
"type": f"catches_{elt.id}_in_loop",
@ -580,10 +1006,15 @@ def _check_signal_escape_patterns(code: str):
if visitor.imports_signal and not signal_tampering:
warnings.append("Code imports 'signal' module - review manually for safety")
is_safe = len(signal_tampering) == 0 and len(exception_catching) == 0
is_safe = (
len(signal_tampering) == 0
and len(exception_catching) == 0
and len(shell_escapes) == 0
)
return is_safe, {
"signal_tampering": signal_tampering,
"exception_catching": exception_catching,
"shell_escapes": shell_escapes,
"warnings": warnings,
}
@ -604,10 +1035,18 @@ def _check_code_safety(code: str) -> str | None:
reasons = [
item.get("description", "") for item in info.get("signal_tampering", [])
]
return (
f"Error: unsafe code detected ({'; '.join(reasons)}). "
f"Please remove signal manipulation from your code."
)
shell_reasons = [
item.get("description", "") for item in info.get("shell_escapes", [])
]
exception_reasons = [
item.get("description", "") for item in info.get("exception_catching", [])
]
all_reasons = [r for r in reasons + shell_reasons + exception_reasons if r]
if all_reasons:
return (
f"Error: unsafe code detected ({'; '.join(all_reasons)}). "
f"Please remove unsafe patterns from your code."
)
return None
@ -662,13 +1101,20 @@ def _python_exec(
with os.fdopen(fd, "w") as f:
f.write(code)
proc = subprocess.Popen(
[sys.executable, tmp_path],
safe_env = _build_safe_env(workdir)
popen_kwargs = dict(
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
cwd = workdir,
env = safe_env,
)
if sys.platform != "win32":
popen_kwargs["preexec_fn"] = _sandbox_preexec
else:
popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
proc = subprocess.Popen([sys.executable, tmp_path], **popen_kwargs)
# Spawn cancel watcher if we have a cancel event
if cancel_event is not None:
@ -734,21 +1180,27 @@ def _bash_exec(
if not command or not command.strip():
return "No command provided."
# Block dangerous commands
tokens = set(command.lower().split())
blocked = tokens & _BASH_BLOCKED_WORDS
# Block dangerous commands (shlex + regex based)
blocked = _find_blocked_commands(command)
if blocked:
return f"Blocked command(s) for safety: {', '.join(sorted(blocked))}"
try:
workdir = _get_workdir(session_id)
proc = subprocess.Popen(
["bash", "-c", command],
safe_env = _build_safe_env(workdir)
popen_kwargs = dict(
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
cwd = workdir,
env = safe_env,
)
if sys.platform != "win32":
popen_kwargs["preexec_fn"] = _sandbox_preexec
else:
popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
proc = subprocess.Popen(_get_shell_cmd(command), **popen_kwargs)
if cancel_event is not None:
watcher = threading.Thread(

View file

@ -145,6 +145,8 @@ def _get_hf_download_state(
blobs_dirs: list[Path] = []
if model_names:
from utils.paths import resolve_cached_repo_id_case
for name in model_names:
if not name:
continue
@ -154,6 +156,7 @@ def _get_hf_download_state(
# relative paths, and Windows paths.
if name.startswith(("/", ".", "~")) or "\\" in name:
continue
name = resolve_cached_repo_id_case(name)
# HF cache dir format: models--org--name (slashes -> --)
cache_dir_name = "models--" + name.replace("/", "--")
blobs_dir = cache / cache_dir_name / "blobs"

View file

@ -48,6 +48,10 @@ class LoadRequest(BaseModel):
None,
description = "Physical GPU indices to use, for example [0, 1]. Omit or pass [] to use automatic selection. Explicit gpu_ids are unsupported when the parent CUDA_VISIBLE_DEVICES uses UUID/MIG entries. Not supported for GGUF models.",
)
speculative_type: Optional[str] = Field(
None,
description = "Speculative decoding mode for GGUF models (e.g. 'ngram-simple', 'ngram-mod'). Ignored for non-GGUF and vision models.",
)
class UnloadRequest(BaseModel):
@ -163,6 +167,10 @@ class LoadResponse(BaseModel):
None,
description = "Jinja2 chat template string (from GGUF metadata or tokenizer)",
)
speculative_type: Optional[str] = Field(
None,
description = "Active speculative decoding mode (e.g. 'ngram-simple', 'ngram-mod'), or None if disabled",
)
class UnloadResponse(BaseModel):
@ -225,6 +233,10 @@ class InferenceStatusResponse(BaseModel):
None,
description = "Model's native context length from GGUF metadata (not capped by VRAM)",
)
speculative_type: Optional[str] = Field(
None,
description = "Active speculative decoding mode (e.g. 'ngram-simple', 'ngram-mod'), or None if disabled",
)
# =====================================================================

View file

@ -179,6 +179,7 @@ async def load_model(
supports_reasoning = llama_backend.supports_reasoning,
reasoning_always_on = llama_backend.reasoning_always_on,
chat_template = llama_backend.chat_template,
speculative_type = llama_backend.speculative_type,
)
else:
if (
@ -263,6 +264,7 @@ async def load_model(
n_ctx = request.max_seq_length,
chat_template_override = request.chat_template_override,
cache_type_kv = request.cache_type_kv,
speculative_type = request.speculative_type,
)
else:
# Local mode: llama-server loads via -m <path>
@ -275,6 +277,7 @@ async def load_model(
n_ctx = request.max_seq_length,
chat_template_override = request.chat_template_override,
cache_type_kv = request.cache_type_kv,
speculative_type = request.speculative_type,
)
if not success:
@ -317,6 +320,7 @@ async def load_model(
supports_tools = llama_backend.supports_tools,
cache_type_kv = llama_backend.cache_type_kv,
chat_template = llama_backend.chat_template,
speculative_type = llama_backend.speculative_type,
)
# ── Standard path: load via Unsloth/transformers ──────────
@ -652,6 +656,7 @@ async def get_status(
context_length = llama_backend.context_length,
max_context_length = llama_backend.max_context_length,
native_context_length = llama_backend.native_context_length,
speculative_type = llama_backend.speculative_type,
)
# Otherwise, report Unsloth backend status

View file

@ -49,8 +49,10 @@ try:
)
from core.inference import get_inference_backend
from utils.paths import (
is_local_path,
outputs_root,
exports_root,
resolve_cached_repo_id_case,
resolve_output_dir,
resolve_export_dir,
)
@ -77,8 +79,10 @@ except ImportError:
)
from core.inference import get_inference_backend
from utils.paths import (
is_local_path,
outputs_root,
exports_root,
resolve_cached_repo_id_case,
resolve_output_dir,
resolve_export_dir,
)
@ -597,10 +601,15 @@ async def get_model_config(
This endpoint wraps the backend load_model_defaults function.
"""
try:
from utils.models.model_config import is_local_path
if not is_local_path(model_name):
model_name = model_name.lower()
resolved = resolve_cached_repo_id_case(model_name)
if resolved != model_name:
logger.info(
"Using cached repo_id casing '%s' for requested '%s'",
resolved,
model_name,
)
model_name = resolved
logger.info(f"Getting model config for: {model_name}")
from utils.models.model_config import detect_audio_type

View file

@ -0,0 +1,120 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
from pathlib import Path
import sys
import types
# Keep this test runnable in lightweight environments where optional logging
# deps are not installed.
if "structlog" not in sys.modules:
class _DummyLogger:
def __getattr__(self, _name):
return lambda *args, **kwargs: None
sys.modules["structlog"] = types.SimpleNamespace(
BoundLogger = _DummyLogger,
get_logger = lambda *args, **kwargs: _DummyLogger(),
)
from utils.paths.path_utils import (
resolve_cached_repo_id_case,
get_cache_case_resolution_stats,
reset_cache_case_resolution_state,
)
import utils.paths.path_utils as path_utils
def _mk_cache_repo(cache_root: Path, repo_id: str) -> Path:
repo_dir = cache_root / f"models--{repo_id.replace('/', '--')}"
repo_dir.mkdir(parents = True, exist_ok = True)
return repo_dir
def test_resolve_cached_repo_id_case_exact_hit(tmp_path, monkeypatch):
reset_cache_case_resolution_state()
_mk_cache_repo(tmp_path, "Org/Model")
monkeypatch.setattr(path_utils, "_hf_hub_cache_dir", lambda: tmp_path)
resolved = resolve_cached_repo_id_case("Org/Model")
assert resolved == "Org/Model"
stats = get_cache_case_resolution_stats()
assert stats["calls"] == 1
assert stats["exact_hits"] == 1
assert stats["variant_hits"] == 0
def test_resolve_cached_repo_id_case_variant_hit(tmp_path, monkeypatch):
reset_cache_case_resolution_state()
_mk_cache_repo(tmp_path, "Org/Model")
monkeypatch.setattr(path_utils, "_hf_hub_cache_dir", lambda: tmp_path)
resolved = resolve_cached_repo_id_case("org/model")
assert resolved == "Org/Model"
stats = get_cache_case_resolution_stats()
assert stats["variant_hits"] == 1
assert stats["tie_breaks"] == 0
def test_resolve_cached_repo_id_case_tie_break_deterministic(tmp_path, monkeypatch):
reset_cache_case_resolution_state()
_mk_cache_repo(tmp_path, "Org/Model")
_mk_cache_repo(tmp_path, "org/model")
monkeypatch.setattr(path_utils, "_hf_hub_cache_dir", lambda: tmp_path)
resolved = resolve_cached_repo_id_case("oRg/mOdEl")
# Deterministic rule: lexical sort of candidate repo ids.
assert resolved == "Org/Model"
stats = get_cache_case_resolution_stats()
assert stats["variant_hits"] == 1
assert stats["tie_breaks"] == 1
def test_resolve_cached_repo_id_case_no_cache_fallback(tmp_path, monkeypatch):
reset_cache_case_resolution_state()
monkeypatch.setattr(path_utils, "_hf_hub_cache_dir", lambda: tmp_path)
resolved = resolve_cached_repo_id_case("Org/Missing")
assert resolved == "Org/Missing"
stats = get_cache_case_resolution_stats()
assert stats["fallbacks"] == 1
assert stats["variant_hits"] == 0
assert stats["exact_hits"] == 0
def test_resolve_cached_repo_id_case_memoization(tmp_path, monkeypatch):
reset_cache_case_resolution_state()
_mk_cache_repo(tmp_path, "Org/Model")
monkeypatch.setattr(path_utils, "_hf_hub_cache_dir", lambda: tmp_path)
first = resolve_cached_repo_id_case("org/model")
second = resolve_cached_repo_id_case("org/model")
assert first == "Org/Model"
assert second == "Org/Model"
stats = get_cache_case_resolution_stats()
assert stats["calls"] == 2
assert stats["variant_hits"] == 1
assert stats["memo_hits"] == 1
def test_resolve_cached_repo_id_case_late_cache_population(tmp_path, monkeypatch):
"""Regression guard: memoized fallback should not hide a later cache variant."""
reset_cache_case_resolution_state()
monkeypatch.setattr(path_utils, "_hf_hub_cache_dir", lambda: tmp_path)
first = resolve_cached_repo_id_case("org/model")
assert first == "org/model"
# Simulate cache being populated after first miss (e.g. another code path/download).
_mk_cache_repo(tmp_path, "Org/Model")
second = resolve_cached_repo_id_case("org/model")
# Desired behavior: second lookup should pick up the now-existing variant.
assert second == "Org/Model"

View file

@ -0,0 +1,81 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import asyncio
import sys
import types
# Keep this test runnable in lightweight environments where optional logging
# deps are not installed.
if "structlog" not in sys.modules:
class _DummyLogger:
def __getattr__(self, _name):
return lambda *args, **kwargs: None
sys.modules["structlog"] = types.SimpleNamespace(
BoundLogger = _DummyLogger,
get_logger = lambda *args, **kwargs: _DummyLogger(),
)
import routes.models as models_route
import utils.models.model_config as model_config_module
def test_get_model_config_resolves_cached_case_before_model_checks(monkeypatch):
calls: dict[str, str] = {}
class _DummyModelConfig:
is_lora = False
base_model = None
def _record_load(model_name):
calls["load_model_defaults"] = model_name
return {}
def _record_vision(model_name, hf_token = None):
calls["is_vision_model"] = model_name
return False
def _record_embedding(model_name, hf_token = None):
calls["is_embedding_model"] = model_name
return False
def _record_audio(model_name, hf_token = None):
calls["detect_audio_type"] = model_name
return None
def _record_from_identifier(cls, model_name):
calls["from_identifier"] = model_name
return _DummyModelConfig()
monkeypatch.setattr(models_route, "is_local_path", lambda _: False)
monkeypatch.setattr(
models_route, "resolve_cached_repo_id_case", lambda _: "Org/Model"
)
monkeypatch.setattr(models_route, "load_model_defaults", _record_load)
monkeypatch.setattr(models_route, "is_vision_model", _record_vision)
monkeypatch.setattr(models_route, "is_embedding_model", _record_embedding)
monkeypatch.setattr(model_config_module, "detect_audio_type", _record_audio)
monkeypatch.setattr(
models_route.ModelConfig,
"from_identifier",
classmethod(_record_from_identifier),
)
monkeypatch.setattr(models_route, "_get_max_position_embeddings", lambda _: 4096)
monkeypatch.setattr(models_route, "_get_model_size_bytes", lambda *_args, **_kw: 0)
result = asyncio.run(
models_route.get_model_config(
model_name = "org/model",
hf_token = None,
current_subject = "test-subject",
)
)
assert result.model_name == "Org/Model"
assert calls["load_model_defaults"] == "Org/Model"
assert calls["is_vision_model"] == "Org/Model"
assert calls["is_embedding_model"] == "Org/Model"
assert calls["detect_audio_type"] == "Org/Model"
assert calls["from_identifier"] == "Org/Model"

View file

@ -5,13 +5,14 @@
Model and LoRA configuration handling
"""
from transformers import AutoConfig
from dataclasses import dataclass
from typing import Optional, Dict, Any
from utils.paths import (
normalize_path,
is_local_path,
is_model_cached,
get_cache_path,
resolve_cached_repo_id_case,
outputs_root,
exports_root,
resolve_output_dir,
@ -158,6 +159,38 @@ MODEL_NAME_MAPPING = {
"unsloth/gemma-3n-E4B-unsloth-bnb-4bit",
"google/gemma-3n-E4B",
],
"unsloth_gemma-4-31B-it.yaml": [
"unsloth/gemma-4-31B-it",
"google/gemma-4-31B-it",
],
"unsloth_gemma-4-26B-A4B-it.yaml": [
"unsloth/gemma-4-26B-A4B-it",
"google/gemma-4-26B-A4B-it",
],
"unsloth_gemma-4-E2B-it.yaml": [
"unsloth/gemma-4-E2B-it",
"google/gemma-4-E2B-it",
],
"unsloth_gemma-4-E4B-it.yaml": [
"unsloth/gemma-4-E4B-it",
"google/gemma-4-E4B-it",
],
"unsloth_gemma-4-31B.yaml": [
"unsloth/gemma-4-31B",
"google/gemma-4-31B",
],
"unsloth_gemma-4-26B-A4B.yaml": [
"unsloth/gemma-4-26B-A4B",
"google/gemma-4-26B-A4B",
],
"unsloth_gemma-4-E2B.yaml": [
"unsloth/gemma-4-E2B",
"google/gemma-4-E2B",
],
"unsloth_gemma-4-E4B.yaml": [
"unsloth/gemma-4-E4B",
"google/gemma-4-E4B",
],
"unsloth_gpt-oss-20b.yaml": [
"openai/gpt-oss-20b",
"unsloth/gpt-oss-20b-unsloth-bnb-4bit",
@ -422,6 +455,7 @@ def load_model_config(
"""
Load model config with optional authentication control.
"""
from transformers import AutoConfig
if token:
# Explicit token provided - use it
@ -711,12 +745,8 @@ def _detect_audio_from_tokenizer(
# 1) Check local HF cache first (works for gated/offline models)
try:
from huggingface_hub.constants import HF_HUB_CACHE
cache_dir = Path(HF_HUB_CACHE)
repo_dir_name = f"models--{model_name.replace('/', '--')}"
repo_dir = cache_dir / repo_dir_name
if repo_dir.exists():
repo_dir = get_cache_path(model_name)
if repo_dir is not None and repo_dir.exists():
snapshots_dir = repo_dir / "snapshots"
if snapshots_dir.exists():
for snapshot in snapshots_dir.iterdir():
@ -1627,11 +1657,18 @@ class ModelConfig:
identifier = f"unsloth/{identifier}"
path = identifier
# Enforce lowercase for remote Hugging Face identifiers to prevent cache duplication
# Hugging Face Hub APIs are case-insensitive remotely, but case-sensitive locally (repo_folder_name).
# Preserve requested casing, but if a case-variant already exists in local HF cache,
# reuse that exact repo_id spelling to avoid one-time re-downloads after #2592.
if not is_local:
identifier = identifier.lower()
path = path.lower()
resolved_identifier = resolve_cached_repo_id_case(identifier)
if resolved_identifier != identifier:
logger.info(
"Using cached repo_id casing '%s' for requested '%s'",
resolved_identifier,
identifier,
)
identifier = resolved_identifier
path = resolved_identifier
# Auto-detect GGUF models (check before LoRA/vision detection)
if is_local:
@ -1852,6 +1889,12 @@ class ModelConfig:
identifier = f"unsloth/{identifier}"
path = identifier
if not is_local:
resolved_identifier = resolve_cached_repo_id_case(identifier)
if resolved_identifier != identifier:
identifier = resolved_identifier
path = resolved_identifier
# --- Logic for Base Model and Vision Detection ---
base_model = None
is_vision = False

View file

@ -5,7 +5,15 @@
Path utilities for model and dataset handling
"""
from .path_utils import normalize_path, is_local_path, is_model_cached, get_cache_path
from .path_utils import (
normalize_path,
is_local_path,
is_model_cached,
get_cache_path,
resolve_cached_repo_id_case,
get_cache_case_resolution_stats,
reset_cache_case_resolution_state,
)
from .storage_roots import (
studio_root,
assets_root,
@ -40,6 +48,9 @@ __all__ = [
"is_local_path",
"is_model_cached",
"get_cache_path",
"resolve_cached_repo_id_case",
"get_cache_case_resolution_stats",
"reset_cache_case_resolution_state",
"studio_root",
"assets_root",
"datasets_root",

View file

@ -14,6 +14,20 @@ from loggers import get_logger
logger = get_logger(__name__)
# Per-process cache to avoid repeated cache-dir scans for the same identifier.
_CACHE_CASE_RESOLUTION_MEMO: dict[str, str] = {}
# Lightweight instrumentation counters for operational visibility.
_CACHE_CASE_RESOLUTION_STATS: dict[str, int] = {
"calls": 0,
"memo_hits": 0,
"exact_hits": 0,
"variant_hits": 0,
"tie_breaks": 0,
"fallbacks": 0,
"errors": 0,
}
def _is_wsl() -> bool:
"""Detect if we are running inside WSL (Windows Subsystem for Linux)."""
@ -94,8 +108,9 @@ def is_local_path(path: str) -> bool:
def get_cache_path(model_name: str) -> Optional[Path]:
"""Get HuggingFace cache path for a model if it exists."""
cache_dir = Path.home() / ".cache" / "huggingface" / "hub"
model_cache_name = model_name.replace("/", "--")
cache_dir = _hf_hub_cache_dir()
resolved_name = resolve_cached_repo_id_case(model_name)
model_cache_name = resolved_name.replace("/", "--")
model_cache_path = cache_dir / f"models--{model_cache_name}"
return model_cache_path if model_cache_path.exists() else None
@ -113,3 +128,102 @@ def is_model_cached(model_name: str) -> bool:
return True
return False
def _hf_hub_cache_dir() -> Path:
"""Return HF cache root honoring HF_HUB_CACHE when available."""
try:
from huggingface_hub.constants import HF_HUB_CACHE
return Path(HF_HUB_CACHE)
except Exception as exc:
logger.debug(
"Could not read huggingface_hub HF_HUB_CACHE, using default hub path: %s",
exc,
)
return Path.home() / ".cache" / "huggingface" / "hub"
def resolve_cached_repo_id_case(model_name: str, use_memo: bool = True) -> str:
"""Resolve repo_id to the exact casing already present in local HF cache.
Policy: prefer the requested/canonical repo_id, but if a case-variant already
exists in local HF cache, reuse that exact cached spelling. This avoids
duplicate downloads while preserving user intent whenever possible.
"""
_CACHE_CASE_RESOLUTION_STATS["calls"] += 1
if not model_name or "/" not in model_name:
_CACHE_CASE_RESOLUTION_STATS["fallbacks"] += 1
return model_name
cache_dir = _hf_hub_cache_dir()
if not cache_dir.exists():
_CACHE_CASE_RESOLUTION_STATS["fallbacks"] += 1
return model_name
expected_dir = f"models--{model_name.replace('/', '--')}"
# Always check the exact-case path first so a newly-appeared exact match
# wins over any previously memoized variant.
exact_path = cache_dir / expected_dir
if exact_path.is_dir():
if use_memo:
_CACHE_CASE_RESOLUTION_MEMO[model_name] = model_name
_CACHE_CASE_RESOLUTION_STATS["exact_hits"] += 1
return model_name
# Validate memoized entries still exist on disk before returning them.
# This prevents stale results when cache dirs are deleted/recreated.
if use_memo:
cached = _CACHE_CASE_RESOLUTION_MEMO.get(model_name)
if cached is not None:
cached_path = cache_dir / f"models--{cached.replace('/', '--')}"
if cached_path.is_dir():
_CACHE_CASE_RESOLUTION_STATS["memo_hits"] += 1
return cached
# Stale entry -- drop it and re-scan below.
_CACHE_CASE_RESOLUTION_MEMO.pop(model_name, None)
expected_lower = expected_dir.lower()
try:
candidates: list[str] = []
for entry in cache_dir.iterdir():
if not entry.is_dir():
continue
if entry.name.lower() != expected_lower:
continue
if not entry.name.startswith("models--"):
continue
repo_part = entry.name[len("models--") :]
if not repo_part:
continue
candidates.append(repo_part.replace("--", "/"))
if candidates:
# Deterministic tie-break if multiple case variants coexist.
resolved = sorted(candidates)[0]
if len(candidates) > 1:
_CACHE_CASE_RESOLUTION_STATS["tie_breaks"] += 1
_CACHE_CASE_RESOLUTION_STATS["variant_hits"] += 1
if use_memo:
_CACHE_CASE_RESOLUTION_MEMO[model_name] = resolved
return resolved
except Exception as exc:
_CACHE_CASE_RESOLUTION_STATS["errors"] += 1
logger.debug(f"Could not resolve cached repo_id case for '{model_name}': {exc}")
_CACHE_CASE_RESOLUTION_STATS["fallbacks"] += 1
return model_name
def get_cache_case_resolution_stats() -> dict[str, int]:
"""Return a copy of case-resolution instrumentation counters."""
return dict(_CACHE_CASE_RESOLUTION_STATS)
def reset_cache_case_resolution_state() -> None:
"""Clear resolver memo and counters (primarily for tests)."""
_CACHE_CASE_RESOLUTION_MEMO.clear()
for key in _CACHE_CASE_RESOLUTION_STATS:
_CACHE_CASE_RESOLUTION_STATS[key] = 0

View file

@ -52,6 +52,18 @@ const WebSearchToolUIImpl: ToolCallMessagePartComponent = ({
status,
}) => {
const query = (args as { query?: string })?.query ?? "";
const url = ((args as { url?: string })?.url ?? "").trim();
const isUrlFetch = !!url;
const displayDomain = (() => {
if (!url) return "";
try {
const parsed = new URL(url);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "";
return parsed.hostname.replace(/^www\./, "");
} catch {
return "";
}
})();
const isRunning = status?.type === "running";
const sources = result
? parseSearchResults(
@ -75,7 +87,13 @@ const WebSearchToolUIImpl: ToolCallMessagePartComponent = ({
return (
<ToolFallbackRoot open={open} onOpenChange={setOpen}>
<ToolFallbackTrigger
toolName={query ? `Searched "${query}"` : "Web Search"}
toolName={
isUrlFetch
? displayDomain ? `Read ${displayDomain}` : "Read page"
: query
? `Searched "${query}"`
: "Web Search"
}
status={status}
icon={GlobeIcon}
/>
@ -83,7 +101,12 @@ const WebSearchToolUIImpl: ToolCallMessagePartComponent = ({
{isRunning ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<LoaderIcon className="size-3.5 animate-spin" />
<span>Searching for &ldquo;{query}&rdquo;&hellip;</span>
<span>
{isUrlFetch
? <>Reading {displayDomain || "page"}&hellip;</>
: <>Searching for &ldquo;{query}&rdquo;&hellip;</>
}
</span>
</div>
) : sources.length > 0 ? (
<div className="flex flex-wrap gap-1.5">

View file

@ -421,6 +421,10 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
return {
async *run({ messages, abortSignal, unstable_threadId }) {
let runtime = useChatRuntimeStore.getState();
// Capture the thread ID once at the start so it stays stable even if
// the user switches chats while waiting for model load / auto-load.
const resolvedThreadId =
(unstable_threadId ?? runtime.activeThreadId) || undefined;
// Wait for in-progress model load to finish before inferring
if (runtime.modelLoading) {
@ -473,14 +477,14 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
}
runtime.clearPendingAudio();
}
const useAdapter = await resolveUseAdapter(unstable_threadId);
const useAdapter = await resolveUseAdapter(resolvedThreadId);
// ── Audio model path (non-streaming) ─────────────────────
const activeModel = runtime.models.find(
(m) => m.id === params.checkpoint,
);
if (activeModel?.isAudio && !activeModel?.hasAudioInput) {
const threadKey = unstable_threadId || "__default";
const threadKey = resolvedThreadId || "__default";
runtime.setThreadRunning(threadKey, true);
try {
yield {
@ -527,7 +531,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
return;
}
const threadKey = unstable_threadId || "__default";
const threadKey = resolvedThreadId || "__default";
let waitingFirstChunk = true;
let firstTokenSettled = false;
const streamStartTime = Date.now();
@ -600,7 +604,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
const mins = useChatRuntimeStore.getState().toolCallTimeout;
return mins >= 9999 ? 9999 : mins * 60;
})(),
session_id: unstable_threadId || undefined,
session_id: resolvedThreadId,
}
: {}),
},
@ -641,7 +645,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
let parsedResult: string | { text: string; images: string[]; sessionId: string };
if (imgIdx !== -1) {
const text = rawResult.slice(0, imgIdx);
const sessionId = unstable_threadId || "";
// Fall back to "_default" to match the backend sandbox directory
// used when no session_id is provided (see tools.py _get_workdir).
const sessionId = resolvedThreadId || "_default";
try {
const images = JSON.parse(rawResult.slice(imgIdx + imgMarker.length)) as string[];
parsedResult = { text, images, sessionId };

View file

@ -592,6 +592,9 @@ export function ChatPage(): ReactElement {
}, []);
const handleNewCompare = useCallback(() => {
setView({ mode: "compare", pairId: crypto.randomUUID() });
// Clear activeThreadId so compare panes do not inherit the single-chat
// thread ID as a fallback for session_id routing.
useChatRuntimeStore.getState().setActiveThreadId(null);
useChatRuntimeStore.getState().setContextUsage(null);
}, []);
@ -619,6 +622,9 @@ export function ChatPage(): ReactElement {
const enterCompare = useCallback(() => {
setViewBeforeCompare((prev) => prev ?? view);
setView({ mode: "compare", pairId: crypto.randomUUID() });
// Clear activeThreadId so compare panes do not inherit the single-chat
// thread ID as a fallback for session_id routing.
useChatRuntimeStore.getState().setActiveThreadId(null);
useChatRuntimeStore.getState().setContextUsage(null);
}, [view]);
@ -626,9 +632,13 @@ export function ChatPage(): ReactElement {
if (!viewBeforeCompare) return;
setView(viewBeforeCompare);
setViewBeforeCompare(null);
// Restore context usage from the active thread's last assistant message
// Restore context usage from the active thread's last assistant message.
// Use the thread ID from the saved view rather than the store, because
// activeThreadId may have been cleared on compare entry.
const store = useChatRuntimeStore.getState();
const threadId = store.activeThreadId;
const threadId =
("threadId" in viewBeforeCompare ? viewBeforeCompare.threadId : null) ??
store.activeThreadId;
if (threadId) {
void db.messages
.where("threadId")
@ -735,6 +745,7 @@ export function ChatPage(): ReactElement {
await selectModelRef.current({ id: targetLora.id, isLora: true });
if (canceled) return;
setView({ mode: "compare", pairId: crypto.randomUUID() });
useChatRuntimeStore.getState().setActiveThreadId(null);
useChatRuntimeStore.getState().setContextUsage(null);
clearHandoff();
console.info("[chat-handoff] loaded lora + opened compare");

View file

@ -280,6 +280,15 @@ export function ChatSettingsPanel({
}: ChatSettingsPanelProps) {
const isMobile = useIsMobile();
const isGguf = useChatRuntimeStore((s) => s.activeGgufVariant) != null;
const speculativeType = useChatRuntimeStore((s) => s.speculativeType);
const setSpeculativeType = useChatRuntimeStore((s) => s.setSpeculativeType);
const loadedSpeculativeType = useChatRuntimeStore(
(s) => s.loadedSpeculativeType,
);
const currentModels = useChatRuntimeStore((s) => s.models);
const currentCheckpoint = params.checkpoint;
const currentModelIsVision =
currentModels.find((m) => m.id === currentCheckpoint)?.isVision ?? false;
const ggufContextLength = useChatRuntimeStore((s) => s.ggufContextLength);
const ggufMaxContextLength = useChatRuntimeStore(
(s) => s.ggufMaxContextLength,
@ -299,7 +308,8 @@ export function ChatSettingsPanel({
const ctxMaxValue = ggufNativeContextLength ?? ggufContextLength ?? null;
const kvDirty = kvCacheDtype !== loadedKvCacheDtype;
const ctxDirty = customContextLength !== null;
const modelSettingsDirty = kvDirty || ctxDirty;
const specDirty = speculativeType !== loadedSpeculativeType;
const modelSettingsDirty = kvDirty || ctxDirty || specDirty;
const [customPresets, setCustomPresets] = useState<Preset[]>(() =>
loadSavedCustomPresets(),
);
@ -580,6 +590,32 @@ export function ChatSettingsPanel({
</SelectContent>
</Select>
</div>
{!currentModelIsVision && (
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-xs font-medium">
Speculative Decoding
</div>
<div className="text-[11px] text-muted-foreground">
Speed up generation with no VRAM cost.
</div>
</div>
<Select
value={speculativeType ?? "off"}
onValueChange={(v) => {
setSpeculativeType(v === "off" ? null : v);
}}
>
<SelectTrigger className="h-7 w-[120px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ngram-mod">On</SelectItem>
<SelectItem value="off">Off</SelectItem>
</SelectContent>
</Select>
</div>
)}
{modelSettingsDirty && (
<div className="flex flex-wrap gap-1.5 pt-1">
<button
@ -594,6 +630,7 @@ export function ChatSettingsPanel({
onClick={() => {
setCustomContextLength(null);
setKvCacheDtype(loadedKvCacheDtype);
setSpeculativeType(loadedSpeculativeType);
}}
className="rounded-md border px-2.5 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent"
>

View file

@ -250,6 +250,7 @@ export function useChatModelRuntime() {
const ggufNativeContextLength = statusRes.is_gguf
? (statusRes.native_context_length ?? null)
: null;
const currentSpecType = statusRes.speculative_type ?? null;
useChatRuntimeStore.setState({
supportsReasoning,
reasoningAlwaysOn,
@ -257,6 +258,8 @@ export function useChatModelRuntime() {
ggufContextLength: currentGgufContextLength,
ggufMaxContextLength,
ggufNativeContextLength,
speculativeType: currentSpecType,
loadedSpeculativeType: currentSpecType,
});
// Set reasoning default for Qwen3.5 small models
@ -393,7 +396,7 @@ export function useChatModelRuntime() {
previousWasUnloaded = true;
}
const { chatTemplateOverride, kvCacheDtype, customContextLength, ggufContextLength } = useChatRuntimeStore.getState();
const { chatTemplateOverride, kvCacheDtype, customContextLength, ggufContextLength, speculativeType } = useChatRuntimeStore.getState();
// GGUF: use custom context length, or 0 = model's native context
// Non-GGUF: use the Max Seq Length slider value
const effectiveMaxSeqLength = customContextLength != null
@ -409,6 +412,7 @@ export function useChatModelRuntime() {
trust_remote_code: paramsBeforeLoad.trustRemoteCode ?? false,
chat_template_override: chatTemplateOverride,
cache_type_kv: kvCacheDtype,
speculative_type: speculativeType,
});
// If cancelled while loading, don't update UI to show
@ -431,6 +435,7 @@ export function useChatModelRuntime() {
}
}
const loadedKv = loadResponse.cache_type_kv ?? null;
const loadedSpec = loadResponse.speculative_type ?? null;
const nativeCtx = loadResponse.is_gguf
? (loadResponse.context_length ?? 131072)
: null;
@ -457,6 +462,8 @@ export function useChatModelRuntime() {
codeToolsEnabled: loadResponse.supports_tools ?? false,
kvCacheDtype: loadedKv,
loadedKvCacheDtype: loadedKv,
speculativeType: loadedSpec,
loadedSpeculativeType: loadedSpec,
customContextLength: keepCustomCtx,
defaultChatTemplate: loadResponse.chat_template ?? null,
chatTemplateOverride: null,

View file

@ -679,9 +679,33 @@ function ThreadNewChatSwitch({
const isLoading = useAuiState(({ threads }) => threads.isLoading);
useEffect(() => {
if (!isLoading) {
aui.threads().switchToNewThread();
if (isLoading) {
return;
}
let cancelled = false;
// Clear immediately so the adapter never picks up a stale thread ID
// from a previous chat while we initialize the new one.
useChatRuntimeStore.getState().setActiveThreadId(null);
void (async () => {
try {
aui.threads().switchToNewThread();
const { remoteId } = await aui.threadListItem().initialize();
if (!cancelled) {
useChatRuntimeStore.getState().setActiveThreadId(remoteId);
}
} catch (error) {
if (!cancelled) {
useChatRuntimeStore.getState().setActiveThreadId(null);
}
console.error("Failed to initialize new chat thread", error);
}
})();
return () => {
cancelled = true;
};
}, [aui, isLoading, nonce]);
return null;
@ -730,7 +754,7 @@ export function ChatRuntimeProvider({
return (
<AssistantRuntimeProvider runtime={runtime} aui={aui}>
<ActiveThreadSync enabled={modelType === "base" && !pairId} />
<ActiveThreadSync enabled={modelType === "base" && !pairId && !newThreadNonce} />
{initialThreadId && <ThreadAutoSwitch threadId={initialThreadId} />}
{!initialThreadId && newThreadNonce && (
<ThreadNewChatSwitch nonce={newThreadNonce} />

View file

@ -165,6 +165,8 @@ type ChatRuntimeStore = {
toolCallTimeout: number;
kvCacheDtype: string | null;
loadedKvCacheDtype: string | null;
speculativeType: string | null;
loadedSpeculativeType: string | null;
customContextLength: number | null;
defaultChatTemplate: string | null;
chatTemplateOverride: string | null;
@ -198,6 +200,7 @@ type ChatRuntimeStore = {
setMaxToolCallsPerMessage: (value: number) => void;
setToolCallTimeout: (value: number) => void;
setKvCacheDtype: (dtype: string | null) => void;
setSpeculativeType: (type: string | null) => void;
setCustomContextLength: (v: number | null) => void;
setChatTemplateOverride: (template: string | null) => void;
setPendingAudio: (base64: string, name: string) => void;
@ -230,6 +233,8 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
toolCallTimeout: loadInt(TOOL_CALL_TIMEOUT_KEY, 5),
kvCacheDtype: null,
loadedKvCacheDtype: null,
speculativeType: "ngram-mod",
loadedSpeculativeType: null,
customContextLength: null,
defaultChatTemplate: null,
chatTemplateOverride: null,
@ -302,6 +307,8 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
toolStatus: null,
kvCacheDtype: null,
loadedKvCacheDtype: null,
speculativeType: "ngram-mod",
loadedSpeculativeType: null,
customContextLength: null,
defaultChatTemplate: null,
chatTemplateOverride: null,
@ -327,6 +334,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set) => ({
return { toolCallTimeout };
}),
setKvCacheDtype: (kvCacheDtype) => set({ kvCacheDtype }),
setSpeculativeType: (speculativeType) => set({ speculativeType }),
setCustomContextLength: (customContextLength) => set({ customContextLength }),
setChatTemplateOverride: (chatTemplateOverride) => set({ chatTemplateOverride }),
setPendingAudio: (base64, name) =>

View file

@ -41,6 +41,7 @@ export interface LoadModelRequest {
trust_remote_code?: boolean;
chat_template_override?: string | null;
cache_type_kv?: string | null;
speculative_type?: string | null;
}
export interface ValidateModelResponse {
@ -93,6 +94,7 @@ export interface LoadModelResponse {
supports_tools?: boolean;
cache_type_kv?: string | null;
chat_template?: string | null;
speculative_type?: string | null;
}
export interface UnloadModelRequest {
@ -123,6 +125,7 @@ export interface InferenceStatusResponse {
context_length?: number | null;
max_context_length?: number | null;
native_context_length?: number | null;
speculative_type?: string | null;
}
export interface AudioGenerationResponse {

View file

@ -12,6 +12,7 @@ import {
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
import { isExecutionInProgress } from "../../executions/execution-helpers";
import type { RecipeExecutionRecord } from "../../execution-types";
@ -121,7 +122,21 @@ export function ExecutionDataTab({
</div>
</div>
{execution.dataset.length === 0 ? (
<p className="text-xs text-muted-foreground">No rows returned.</p>
isExecutionInProgress(execution.status) ? (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
<Spinner className="size-5" />
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">
Generating data
</p>
<p className="text-xs text-muted-foreground">
Check the Overview tab for live terminal logs.
</p>
</div>
</div>
) : (
<p className="text-xs text-muted-foreground">No rows returned.</p>
)
) : tableColumns.length === 0 ? (
<p className="text-xs text-muted-foreground">
All columns hidden. Use Columns to show at least one.

View file

@ -116,10 +116,12 @@ export function ExecutionOverviewTab({
/>
</div>
<div className="space-y-1.5 text-xs">
<p className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">LLM columns</span>
<span className="font-semibold">{formatMetricValue(llmColumnCount)}</span>
</p>
{llmColumnCount > 0 && (
<p className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">LLM columns</span>
<span className="font-semibold">{formatMetricValue(llmColumnCount)}</span>
</p>
)}
<p className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">Null rate</span>
<span className="font-semibold">{nullRate?.toFixed(1) ?? "--"}%</span>
@ -164,40 +166,42 @@ export function ExecutionOverviewTab({
</div>
</div>
</div>
<div className="rounded-xl border border-border/60 bg-card/55 p-3">
<div className="mb-2 flex items-center justify-between">
<p className="text-xs text-muted-foreground">Model usage</p>
<HugeiconsIcon icon={Flag02Icon} className="size-4 text-muted-foreground" />
</div>
{modelUsageRows.length === 0 ? (
<p className="text-xs text-muted-foreground">No model usage yet.</p>
) : (
<div className="overflow-hidden rounded-lg border border-border/60 bg-card/50">
<Table>
<TableHeader>
<TableRow>
<TableHead>Model</TableHead>
<TableHead className="text-right">Input</TableHead>
<TableHead className="text-right">Output</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{modelUsageRows.map((usage) => (
<TableRow key={usage.model}>
<TableCell className="max-w-[320px] truncate">{usage.model}</TableCell>
<TableCell className="text-right">
{formatMetricValue(usage.input)}
</TableCell>
<TableCell className="text-right">
{formatMetricValue(usage.output)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{(llmColumnCount > 0 || modelUsageRows.length > 0) && (
<div className="rounded-xl border border-border/60 bg-card/55 p-3">
<div className="mb-2 flex items-center justify-between">
<p className="text-xs text-muted-foreground">Model usage</p>
<HugeiconsIcon icon={Flag02Icon} className="size-4 text-muted-foreground" />
</div>
)}
</div>
{modelUsageRows.length === 0 ? (
<p className="text-xs text-muted-foreground">No model usage yet.</p>
) : (
<div className="overflow-hidden rounded-lg border border-border/60 bg-card/50">
<Table>
<TableHeader>
<TableRow>
<TableHead>Model</TableHead>
<TableHead className="text-right">Input</TableHead>
<TableHead className="text-right">Output</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{modelUsageRows.map((usage) => (
<TableRow key={usage.model}>
<TableCell className="max-w-[320px] truncate">{usage.model}</TableCell>
<TableCell className="text-right">
{formatMetricValue(usage.input)}
</TableCell>
<TableCell className="text-right">
{formatMetricValue(usage.output)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
)}
</div>
)}
<div className="overflow-hidden rounded-xl corner-squircle border">

View file

@ -59,7 +59,7 @@ export function ExecutionsView({
typeof value === "number" && Number.isFinite(value)
? `${value.toLocaleString()} s`
: "--";
const [detailTab, setDetailTab] = useState("overview");
const [detailTab, setDetailTab] = useState("data");
const [hiddenDatasetColumnsByExecution, setHiddenDatasetColumnsByExecution] = useState<
Record<string, string[]>
>({});
@ -341,12 +341,16 @@ export function ExecutionsView({
}, [selectedExecution]);
useEffect(() => {
if (!terminalRef.current) {
setDetailTab("data");
}, [selectedExecution?.id]);
useEffect(() => {
if (detailTab !== "overview" || !terminalRef.current) {
return;
}
shouldStickTerminalToBottomRef.current = true;
terminalRef.current.scrollTop = terminalRef.current.scrollHeight;
}, [selectedExecution?.id]);
}, [detailTab, selectedExecution?.id]);
useEffect(() => {
if (!terminalRef.current) {
@ -440,9 +444,9 @@ export function ExecutionsView({
<Tabs value={detailTab} onValueChange={setDetailTab}>
<div className="flex items-center justify-between gap-2">
<TabsList className="border border-border/60 bg-card/40">
<TabsTrigger value="data">Data</TabsTrigger>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="columns">Columns</TabsTrigger>
<TabsTrigger value="data">Data</TabsTrigger>
<TabsTrigger value="raw">Raw</TabsTrigger>
</TabsList>
<div className="flex items-center gap-2">

View file

@ -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<label>.+)-(?P<target>linux-x64(?:-cpu)?|linux-x64-(?:cuda12|cuda13)-(?:older|newer|portable))\.tar\.gz$"
)
for asset_name in sorted(assets):
match = linux_asset_re.fullmatch(asset_name)
if not match:
continue
inferred_labels.append(match.group("label"))
target = match.group("target")
if target in {"linux-x64", "linux-x64-cpu"}:
artifacts.append(
PublishedLlamaArtifact(
asset_name = asset_name,
install_kind = "linux-cpu",
runtime_line = None,
coverage_class = None,
supported_sms = [],
min_sm = None,
max_sm = None,
bundle_profile = None,
rank = 1000,
)
)
continue
bundle_profile = target.removeprefix("linux-x64-")
profile = DIRECT_LINUX_BUNDLE_PROFILES.get(bundle_profile)
if profile is None:
continue
artifacts.append(
PublishedLlamaArtifact(
asset_name = asset_name,
install_kind = "linux-cuda",
runtime_line = str(profile["runtime_line"]),
coverage_class = str(profile["coverage_class"]),
supported_sms = [str(value) for value in profile["supported_sms"]],
min_sm = int(profile["min_sm"]),
max_sm = int(profile["max_sm"]),
bundle_profile = bundle_profile,
rank = int(profile["rank"]),
)
)
if not artifacts:
return None
upstream_tag = (
release_tag
if is_release_tag_like(release_tag)
else inferred_labels[0]
if len(set(inferred_labels)) == 1 and inferred_labels
else release_tag
)
selection_log = [
f"published_release: repo={repo}",
f"published_release: tag={release_tag}",
f"published_release: upstream_tag={upstream_tag}",
"published_release: direct_asset_scan=linux",
]
return PublishedReleaseBundle(
repo = repo,
release_tag = release_tag,
upstream_tag = upstream_tag,
assets = assets,
manifest_asset_name = DEFAULT_PUBLISHED_MANIFEST_ASSET,
artifacts = artifacts,
selection_log = selection_log,
)
def direct_linux_release_plan(
release: dict[str, Any],
host: HostInfo,
repo: str,
requested_tag: str,
) -> InstallReleasePlan | None:
bundle = parse_direct_linux_release_bundle(repo, release)
if bundle is None:
return None
if not direct_release_matches_request(
release_tag = bundle.release_tag,
llama_tag = bundle.upstream_tag,
requested_tag = requested_tag,
):
return None
attempts: list[AssetChoice] = []
if host.has_usable_nvidia:
selection = linux_cuda_choice_from_release(host, bundle)
if selection is not None:
attempts.extend(selection.attempts)
cpu_choice = published_asset_choice_for_kind(bundle, "linux-cpu")
if cpu_choice is not None:
attempts.append(cpu_choice)
if not attempts:
raise PrebuiltFallback("no compatible Linux prebuilt asset was found")
return InstallReleasePlan(
requested_tag = requested_tag,
llama_tag = bundle.upstream_tag,
release_tag = bundle.release_tag,
attempts = attempts,
approved_checksums = synthetic_checksums_for_release(
repo,
bundle.release_tag,
bundle.upstream_tag,
),
)
def direct_upstream_release_plan(
release: dict[str, Any],
host: HostInfo,
repo: str,
requested_tag: str,
) -> InstallReleasePlan | None:
release_tag = release.get("tag_name")
if not isinstance(release_tag, str) or not release_tag:
return None
if not direct_release_matches_request(
release_tag = release_tag,
llama_tag = release_tag,
requested_tag = requested_tag,
):
return None
assets = release_asset_map(release)
attempts: list[AssetChoice] = []
if host.is_windows and host.is_x86_64:
if host.has_usable_nvidia:
torch_preference = detect_torch_cuda_runtime_preference(host)
attempts.extend(
windows_cuda_attempts(
host,
release_tag,
assets,
torch_preference.runtime_line,
torch_preference.selection_log,
)
)
cpu_asset = f"llama-{release_tag}-bin-win-cpu-x64.zip"
cpu_url = assets.get(cpu_asset)
if cpu_url:
attempts.append(
AssetChoice(
repo = repo,
tag = release_tag,
name = cpu_asset,
url = cpu_url,
source_label = "upstream",
install_kind = "windows-cpu",
)
)
elif host.is_macos and host.is_arm64:
asset_name = f"llama-{release_tag}-bin-macos-arm64.tar.gz"
asset_url = assets.get(asset_name)
if asset_url:
attempts.append(
AssetChoice(
repo = repo,
tag = release_tag,
name = asset_name,
url = asset_url,
source_label = "upstream",
install_kind = "macos-arm64",
)
)
elif host.is_macos and host.is_x86_64:
asset_name = f"llama-{release_tag}-bin-macos-x64.tar.gz"
asset_url = assets.get(asset_name)
if asset_url:
attempts.append(
AssetChoice(
repo = repo,
tag = release_tag,
name = asset_name,
url = asset_url,
source_label = "upstream",
install_kind = "macos-x64",
)
)
elif host.is_linux and host.is_x86_64 and not host.has_usable_nvidia:
asset_name = f"llama-{release_tag}-bin-ubuntu-x64.tar.gz"
asset_url = assets.get(asset_name)
if asset_url:
attempts.append(
AssetChoice(
repo = repo,
tag = release_tag,
name = asset_name,
url = asset_url,
source_label = "upstream",
install_kind = "linux-cpu",
)
)
if not attempts:
raise PrebuiltFallback("no compatible upstream prebuilt asset was found")
return InstallReleasePlan(
requested_tag = requested_tag,
llama_tag = release_tag,
release_tag = release_tag,
attempts = attempts,
approved_checksums = synthetic_checksums_for_release(
repo,
release_tag,
release_tag,
),
)
def resolve_simple_install_release_plans(
llama_tag: str,
host: HostInfo,
published_repo: str,
published_release_tag: str,
*,
max_release_fallbacks: int = DEFAULT_MAX_PREBUILT_RELEASE_FALLBACKS,
) -> tuple[str, list[InstallReleasePlan]]:
repo = published_repo or DEFAULT_PUBLISHED_REPO
requested_tag = normalized_requested_llama_tag(llama_tag)
allow_older_release_fallback = (
requested_tag == "latest" and not published_release_tag
)
release_limit = max(1, max_release_fallbacks)
plans: list[InstallReleasePlan] = []
last_error: PrebuiltFallback | None = None
try:
releases = iter_release_payloads_by_time(
repo, published_release_tag, requested_tag
)
for release in releases:
try:
if host.is_linux and repo == "unslothai/llama.cpp":
plan = direct_linux_release_plan(release, host, repo, requested_tag)
else:
plan = direct_upstream_release_plan(
release, host, repo, requested_tag
)
if plan is None:
continue
except PrebuiltFallback as exc:
last_error = exc
if not allow_older_release_fallback:
raise
release_tag = release.get("tag_name") or "unknown"
log(
"published release skipped for install planning: "
f"{repo}@{release_tag} ({exc})"
)
continue
plans.append(plan)
if not allow_older_release_fallback or len(plans) >= release_limit:
break
except PrebuiltFallback:
raise
except Exception as exc:
raise PrebuiltFallback(
f"failed to inspect published releases in {repo}: {exc}"
) from exc
if plans:
return requested_tag, plans
if last_error is not None:
raise last_error
raise PrebuiltFallback(
f"no installable published llama.cpp releases were found in {repo}"
)
def normalized_requested_llama_tag(requested_tag: str | None) -> str:
if isinstance(requested_tag, str):
normalized = requested_tag.strip()
@ -1435,7 +1887,7 @@ def iter_published_release_bundles(
releases = (
[github_release(repo, published_release_tag)]
if published_release_tag
else github_releases(repo)
else github_releases(repo, max_pages = DEFAULT_GITHUB_RELEASE_SCAN_MAX_PAGES)
)
for release in releases:
if not published_release_tag and (
@ -1669,7 +2121,9 @@ def latest_published_linux_cuda_tag(host: HostInfo, published_repo: str) -> str
def iter_upstream_releases() -> Iterable[dict[str, Any]]:
for release in github_releases(UPSTREAM_REPO):
for release in github_releases(
UPSTREAM_REPO, max_pages = DEFAULT_GITHUB_RELEASE_SCAN_MAX_PAGES
):
if release.get("draft") or release.get("prerelease"):
continue
yield release
@ -2799,7 +3253,7 @@ def hydrate_source_tree(
work_dir: Path,
*,
source_repo: str = UPSTREAM_REPO,
expected_sha256: str,
expected_sha256: str | None,
source_label: str | None = None,
exact_source: bool = False,
) -> None:
@ -3231,10 +3685,6 @@ def install_from_archives(
) -> tuple[Path, Path]:
main_archive = work_dir / choice.name
log(f"downloading {choice.name} from {choice.source_label} release")
if not choice.expected_sha256:
raise PrebuiltFallback(
f"approved checksum was missing for selected asset {choice.name}"
)
download_file_verified(
choice.url,
main_archive,
@ -3962,7 +4412,7 @@ def require_approved_source_hash(
def preferred_source_archive(
checksums: ApprovedReleaseChecksums, llama_tag: str
) -> tuple[str, str, ApprovedArtifactHash, bool]:
) -> tuple[str, str, ApprovedArtifactHash | None, bool]:
exact_source = exact_source_archive_hash(checksums)
exact_repo = repo_slug_from_source(checksums.source_repo) or repo_slug_from_source(
checksums.source_repo_url
@ -3974,7 +4424,7 @@ def preferred_source_archive(
exact_source,
True,
)
legacy = require_approved_source_hash(checksums, llama_tag)
legacy = checksums.artifacts.get(source_archive_logical_name(llama_tag))
return (
UPSTREAM_REPO,
llama_tag,
@ -3990,6 +4440,8 @@ def selected_source_archive_metadata(
_source_repo, _source_ref, source_archive, _exact_source = preferred_source_archive(
checksums, llama_tag
)
if source_archive is None:
return source_archive_logical_name(llama_tag), None
return source_archive.asset_name, source_archive.sha256
@ -4153,8 +4605,6 @@ def expected_install_fingerprint(
choice: AssetChoice,
approved_checksums: ApprovedReleaseChecksums,
) -> str | None:
if not choice.expected_sha256:
return None
source_asset_name, source_sha256 = selected_source_archive_metadata(
approved_checksums,
llama_tag,
@ -4352,7 +4802,7 @@ def validate_prebuilt_choice(
install_dir,
work_dir,
source_repo = source_repo,
expected_sha256 = source_archive.sha256,
expected_sha256 = source_archive.sha256 if source_archive is not None else None,
source_label = (
f"llama.cpp source tree for {source_repo}@{source_ref}"
if exact_source
@ -4477,7 +4927,12 @@ def validate_prebuilt_attempts(
def install_prebuilt(
install_dir: Path, llama_tag: str, published_repo: str, published_release_tag: str
install_dir: Path,
llama_tag: str,
published_repo: str,
published_release_tag: str,
*,
simple_policy: bool = False,
) -> None:
host = detect_host()
choice: AssetChoice | None = None
@ -4491,12 +4946,20 @@ def install_prebuilt(
log(
f"no existing llama.cpp install detected at {install_dir}; performing fresh prebuilt install"
)
requested_tag, release_plans = resolve_install_release_plans(
llama_tag,
host,
published_repo,
published_release_tag,
)
if simple_policy:
requested_tag, release_plans = resolve_simple_install_release_plans(
llama_tag,
host,
published_repo,
published_release_tag,
)
else:
requested_tag, release_plans = resolve_install_release_plans(
llama_tag,
host,
published_repo,
published_release_tag,
)
if release_plans and existing_install_matches_plan(
install_dir, host, release_plans[0]
):
@ -4600,6 +5063,11 @@ def parse_args() -> argparse.Namespace:
"until a usable published llama.cpp release bundle is found."
),
)
parser.add_argument(
"--simple-policy",
action = "store_true",
help = "Use the simplified platform-specific prebuilt selection policy.",
)
resolve_group = parser.add_mutually_exclusive_group()
resolve_group.add_argument(
"--resolve-llama-tag",
@ -4719,6 +5187,7 @@ def main() -> int:
llama_tag = args.llama_tag,
published_repo = args.published_repo,
published_release_tag = args.published_release_tag or "",
simple_policy = args.simple_policy,
)
return EXIT_SUCCESS

View file

@ -34,6 +34,7 @@ $PackageDir = Split-Path -Parent $ScriptDir
$DefaultLlamaPrForce = ""
$DefaultLlamaSource = "https://github.com/ggml-org/llama.cpp"
$DefaultLlamaTag = "latest"
$DefaultLlamaForceCompileRef = "master"
# Verbose can be enabled either by CLI flag or by UNSLOTH_VERBOSE=1.
$script:UnslothVerbose = ($env:UNSLOTH_VERBOSE -eq '1')
@ -81,6 +82,31 @@ function New-UnslothTemporaryFile {
return Get-Item -LiteralPath $tempPath
}
function Get-InstalledLlamaPrebuiltRelease {
param([string]$InstallDir)
$metadataPath = Join-Path $InstallDir "UNSLOTH_PREBUILT_INFO.json"
if (-not (Test-Path $metadataPath)) {
return $null
}
try {
$payload = Get-Content $metadataPath -Raw | ConvertFrom-Json
} catch {
return $null
}
if (-not $payload.published_repo -or -not $payload.release_tag) {
return $null
}
$message = "installed release: $($payload.published_repo)@$($payload.release_tag)"
if ($payload.tag -and $payload.tag -ne $payload.release_tag) {
$message += " (tag $($payload.tag))"
}
return $message
}
# Find nvcc on PATH, CUDA_PATH, or standard toolkit dirs.
# Returns the path to nvcc.exe, or $null if not found.
function Find-Nvcc {
@ -133,7 +159,9 @@ function Find-Nvcc {
# 3. Scan standard toolkit directory
if (Test-Path $toolkitBase) {
$latest = Get-ChildItem -Directory $toolkitBase | Sort-Object Name | Select-Object -Last 1
$latest = Get-ChildItem -Directory $toolkitBase | Where-Object {
$_.Name -match '^v(\d+)\.(\d+)'
} | Sort-Object { [version]($_.Name -replace '^v','') } -Descending | Select-Object -First 1
if ($latest -and (Test-Path (Join-Path $latest.FullName 'bin\nvcc.exe'))) {
return (Join-Path $latest.FullName 'bin\nvcc.exe')
}
@ -1618,6 +1646,7 @@ if ($LlamaSource.EndsWith('.git')) { $LlamaSource = $LlamaSource.Substring(0, $L
$ResolvedSourceUrl = $LlamaSource
$ResolvedSourceRef = $RequestedLlamaTag
$ResolvedSourceRefKind = "tag"
$ResolvedLlamaTag = $RequestedLlamaTag
if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") {
$NeedLlamaSourceBuild = $true
@ -1693,92 +1722,27 @@ if ($LlamaPr) {
$ResolvedSourceRefKind = "pull"
$NeedLlamaSourceBuild = $true
$SkipPrebuiltInstall = $true
} elseif ($SkipPrebuiltInstall) {
# Custom source or other override already forced source build; skip the
# prebuilt release resolution. When building from a custom fork, the fork
# may not carry upstream bNNNN tags.
if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") {
$ResolvedLlamaTag = $RequestedLlamaTag
} elseif ($LlamaSource -eq "https://github.com/ggml-org/llama.cpp") {
$resolveTagArgs = @("--resolve-llama-tag", $RequestedLlamaTag, "--published-repo", $HelperReleaseRepo, "--output-format", "json")
if ($env:UNSLOTH_LLAMA_RELEASE_TAG) { $resolveTagArgs += @("--published-release-tag", $env:UNSLOTH_LLAMA_RELEASE_TAG) }
$fallbackResult = Invoke-LlamaHelper -Arguments $resolveTagArgs
$fallbackOutput = $fallbackResult.Output
$fallbackExit = $fallbackResult.ExitCode
$ResolvedLlamaTag = if ($fallbackExit -eq 0 -and $fallbackOutput) {
try {
(($fallbackOutput | Out-String) | ConvertFrom-Json).llama_tag
} catch {
$RequestedLlamaTag
}
} else {
$RequestedLlamaTag
}
} else {
$ResolvedLlamaTag = $RequestedLlamaTag
}
} else {
$resolveInstallArgs = @("--resolve-install-tag", $RequestedLlamaTag, "--published-repo", $HelperReleaseRepo, "--output-format", "json")
if ($env:UNSLOTH_LLAMA_RELEASE_TAG) { $resolveInstallArgs += @("--published-release-tag", $env:UNSLOTH_LLAMA_RELEASE_TAG) }
$resolveErrorLog = New-UnslothTemporaryFile
$resolveResult = Invoke-LlamaHelper -Arguments $resolveInstallArgs -StderrPath $resolveErrorLog
$resolveOutput = $resolveResult.Output
$resolveExit = $resolveResult.ExitCode
$ResolvedLlamaTag = if ($resolveOutput) {
try {
(($resolveOutput | Out-String) | ConvertFrom-Json).llama_tag
} catch {
""
}
} else { "" }
if ($resolveExit -ne 0 -or [string]::IsNullOrWhiteSpace($ResolvedLlamaTag)) {
Write-Host ""
substep "Failed to resolve a published llama.cpp release via $HelperReleaseRepo" "Yellow"
Write-LlamaFailureLog -Output (Get-Content -Raw $resolveErrorLog)
# Resolve the llama.cpp tag for source-build fallback. Pass --published-repo
# so the resolver prefers the latest usable Unsloth-published upstream tag
# before falling back to the bleeding-edge ggml-org/llama.cpp tag.
$resolveFallbackArgs = @("--resolve-llama-tag", $RequestedLlamaTag, "--published-repo", $HelperReleaseRepo, "--output-format", "json")
if ($env:UNSLOTH_LLAMA_RELEASE_TAG) { $resolveFallbackArgs += @("--published-release-tag", $env:UNSLOTH_LLAMA_RELEASE_TAG) }
$fallbackResult = Invoke-LlamaHelper -Arguments $resolveFallbackArgs
$fallbackOutput = $fallbackResult.Output
$fallbackExit = $fallbackResult.ExitCode
$ResolvedLlamaTag = if ($fallbackExit -eq 0 -and $fallbackOutput) {
try {
(($fallbackOutput | Out-String) | ConvertFrom-Json).llama_tag
} catch {
$RequestedLlamaTag
}
} else {
$RequestedLlamaTag
}
$NeedLlamaSourceBuild = $true
$SkipPrebuiltInstall = $true
}
Remove-Item $resolveErrorLog -Force -ErrorAction SilentlyContinue
}
Write-Host ""
substep "Resolved llama.cpp release tag: $ResolvedLlamaTag"
if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") {
Write-Host ""
substep "UNSLOTH_LLAMA_FORCE_COMPILE=1 -- skipping prebuilt llama.cpp install" "Yellow"
$NeedLlamaSourceBuild = $true
} elseif ($SkipPrebuiltInstall) {
Write-Host ""
substep "Skipping prebuilt install -- falling back to source build" "Yellow"
} else {
Write-Host ""
substep "installing prebuilt llama.cpp bundle (preferred path)..."
if (Test-Path $LlamaCppDir) {
substep "Existing llama.cpp install detected -- validating staged prebuilt update before replacement"
}
if ($SkipPrebuiltInstall) {
substep "Skipping prebuilt install because prebuilt tag resolution failed -- falling back to source build" "Yellow"
} else {
$prebuiltArgs = @(
$prebuiltArgs = @(
"$PSScriptRoot\install_llama_prebuilt.py",
"--install-dir", $LlamaCppDir,
"--llama-tag", $RequestedLlamaTag,
"--published-repo", $HelperReleaseRepo
"--published-repo", $HelperReleaseRepo,
"--simple-policy"
)
if ($env:UNSLOTH_LLAMA_RELEASE_TAG) {
$prebuiltArgs += @("--published-release-tag", $env:UNSLOTH_LLAMA_RELEASE_TAG)
@ -1817,6 +1781,10 @@ if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") {
} else {
step "llama.cpp" "prebuilt installed and validated"
}
$installedRelease = Get-InstalledLlamaPrebuiltRelease -InstallDir $LlamaCppDir
if ($installedRelease) {
substep $installedRelease
}
} elseif ($prebuiltExit -eq 3) {
step "llama.cpp" "install blocked by active llama.cpp process" "Yellow"
Write-LlamaFailureLog -Output $prebuiltOutput
@ -1834,7 +1802,6 @@ if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") {
substep "Prebuilt llama.cpp path unavailable or failed validation -- falling back to source build" "Yellow"
$NeedLlamaSourceBuild = $true
}
}
}
# ==========================================================================
@ -1981,24 +1948,43 @@ if (-not $NeedLlamaSourceBuild) {
}
if (-not $LlamaPr) {
if ($LlamaSource -eq "https://github.com/ggml-org/llama.cpp") {
$resolveSourceArgs = @("--resolve-source-build", $RequestedLlamaTag, "--published-repo", $HelperReleaseRepo, "--output-format", "json")
if ($env:UNSLOTH_LLAMA_RELEASE_TAG) { $resolveSourceArgs += @("--published-release-tag", $env:UNSLOTH_LLAMA_RELEASE_TAG) }
$sourcePlanResult = Invoke-LlamaHelper -Arguments $resolveSourceArgs
$sourcePlanOutput = $sourcePlanResult.Output
$sourcePlanExit = $sourcePlanResult.ExitCode
if ($sourcePlanExit -eq 0 -and $sourcePlanOutput) {
try {
$sourcePlan = ($sourcePlanOutput | Out-String) | ConvertFrom-Json
$ResolvedSourceUrl = $sourcePlan.source_url
$ResolvedSourceRefKind = $sourcePlan.source_ref_kind
$ResolvedSourceRef = $sourcePlan.source_ref
} catch {
$ResolvedSourceUrl = $LlamaSource
if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") {
if ($RequestedLlamaTag -eq "latest") {
$ResolvedSourceRef = if ($env:UNSLOTH_LLAMA_FORCE_COMPILE_REF) {
$env:UNSLOTH_LLAMA_FORCE_COMPILE_REF
} else {
$DefaultLlamaForceCompileRef
}
$ResolvedSourceRefKind = "branch"
} else {
$ResolvedSourceRef = $RequestedLlamaTag
$ResolvedSourceRefKind = "tag"
}
} elseif ($RequestedLlamaTag -eq "latest") {
$resolveTagArgs = @("--resolve-llama-tag", "latest", "--published-repo", "ggml-org/llama.cpp", "--output-format", "json")
$resolveTagResult = Invoke-LlamaHelper -Arguments $resolveTagArgs
$resolveTagOutput = $resolveTagResult.Output
$resolveTagExit = $resolveTagResult.ExitCode
if ($resolveTagExit -eq 0 -and $resolveTagOutput) {
try {
$ResolvedSourceRef = (($resolveTagOutput | Out-String) | ConvertFrom-Json).llama_tag
} catch {
$ResolvedSourceRef = ""
}
} else {
$ResolvedSourceRef = ""
}
if ([string]::IsNullOrWhiteSpace($ResolvedSourceRef)) {
$ResolvedSourceRef = "latest"
}
$ResolvedSourceRefKind = "tag"
} else {
$ResolvedSourceRef = $RequestedLlamaTag
$ResolvedSourceRefKind = "tag"
}
if ([string]::IsNullOrWhiteSpace($ResolvedSourceUrl)) { $ResolvedSourceUrl = $LlamaSource }
if ([string]::IsNullOrWhiteSpace($ResolvedSourceRef)) { $ResolvedSourceRef = $ResolvedLlamaTag }
if ([string]::IsNullOrWhiteSpace($ResolvedSourceRef)) { $ResolvedSourceRef = $RequestedLlamaTag }
}
# -- Step A: Clone or pull llama.cpp --

View file

@ -25,6 +25,7 @@ RULE=$(printf '\342\224\200%.0s' {1..52})
_DEFAULT_LLAMA_PR_FORCE=""
_DEFAULT_LLAMA_SOURCE="https://github.com/ggml-org/llama.cpp"
_DEFAULT_LLAMA_TAG="latest"
_DEFAULT_LLAMA_FORCE_COMPILE_REF="master"
# ── Colors (same palette as startup_banner / install_python_stack) ──
if [ -n "${NO_COLOR:-}" ]; then
@ -121,6 +122,45 @@ print_llama_error_log() {
tail -n 120 "$log_file" | sed 's/^/ | /' >&2
}
installed_llama_prebuilt_release() {
local install_dir=${1:-}
local metadata_path="$install_dir/UNSLOTH_PREBUILT_INFO.json"
[ -f "$metadata_path" ] || return 0
python - "$metadata_path" <<'PY' 2>/dev/null || true
import json
import sys
from pathlib import Path
try:
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
except Exception:
raise SystemExit(0)
if not isinstance(payload, dict):
raise SystemExit(0)
repo = str(payload.get("published_repo") or "").strip()
release_tag = str(payload.get("release_tag") or "").strip()
llama_tag = str(payload.get("tag") or "").strip()
if not repo or not release_tag:
raise SystemExit(0)
message = f"installed release: {repo}@{release_tag}"
if llama_tag and llama_tag != release_tag:
message += f" (tag {llama_tag})"
print(message)
PY
}
print_installed_llama_prebuilt_release() {
local install_dir=${1:-}
local installed_release
installed_release="$(installed_llama_prebuilt_release "$install_dir")"
if [ -n "$installed_release" ]; then
substep "$installed_release"
fi
}
# ── Banner ──
echo ""
printf " ${C_TITLE}%s${C_RST}\n" "🦥 Unsloth Studio Setup"
@ -487,30 +527,27 @@ _NEED_LLAMA_SOURCE_BUILD=false
_LLAMA_CPP_DEGRADED=false
_LLAMA_FORCE_COMPILE="${UNSLOTH_LLAMA_FORCE_COMPILE:-0}"
_REQUESTED_LLAMA_TAG="${UNSLOTH_LLAMA_TAG:-${_DEFAULT_LLAMA_TAG}}"
# Force all installs to use mainline llama.cpp from ggml-org.
_HELPER_RELEASE_REPO="ggml-org/llama.cpp"
_HOST_SYSTEM="$(uname -s 2>/dev/null || true)"
if [ "$_HOST_SYSTEM" = "Darwin" ]; then
_HELPER_RELEASE_REPO="ggml-org/llama.cpp"
else
_HELPER_RELEASE_REPO="unslothai/llama.cpp"
fi
_LLAMA_PR="${UNSLOTH_LLAMA_PR:-}"
_SKIP_PREBUILT_INSTALL=false
_LLAMA_PR_FORCE="${UNSLOTH_LLAMA_PR_FORCE:-${_DEFAULT_LLAMA_PR_FORCE}}"
# Force mainline source -- no env var override for now.
_LLAMA_SOURCE="${_DEFAULT_LLAMA_SOURCE}"
_LLAMA_SOURCE="${_LLAMA_SOURCE%.git}" # normalize: strip trailing .git
_RESOLVED_SOURCE_URL="$_LLAMA_SOURCE"
_RESOLVED_SOURCE_REF="$_REQUESTED_LLAMA_TAG"
_RESOLVED_SOURCE_REF_KIND="tag"
_RESOLVED_LLAMA_TAG="$_REQUESTED_LLAMA_TAG"
if [ "$_LLAMA_FORCE_COMPILE" = "1" ]; then
_NEED_LLAMA_SOURCE_BUILD=true
_SKIP_PREBUILT_INSTALL=true
fi
# Non-default source URL forces source build (fork has different code than prebuilt).
if [ "$_LLAMA_SOURCE" != "https://github.com/ggml-org/llama.cpp" ]; then
step "llama.cpp" "custom source: $_LLAMA_SOURCE -- forcing source build" "$C_WARN"
_NEED_LLAMA_SOURCE_BUILD=true
_SKIP_PREBUILT_INSTALL=true
fi
# Baked-in PR_FORCE promotes to _LLAMA_PR when user hasn't set one.
if [ -z "$_LLAMA_PR" ] && [ -n "$_LLAMA_PR_FORCE" ] && \
[[ "$_LLAMA_PR_FORCE" =~ ^[0-9]+$ ]] && [ "$_LLAMA_PR_FORCE" -gt 0 ]; then
@ -530,149 +567,68 @@ if [ -n "$_LLAMA_PR" ]; then
_RESOLVED_SOURCE_REF_KIND="pull"
_NEED_LLAMA_SOURCE_BUILD=true
_SKIP_PREBUILT_INSTALL=true
elif [ "${_SKIP_PREBUILT_INSTALL:-false}" = true ]; then
# Custom source or other override already forced source build; skip
# the prebuilt release resolution entirely. When building from a custom
# fork, the fork may not carry upstream bNNNN tags, so resolve the tag
# only when the source is the default ggml-org repo.
if [ "$_LLAMA_FORCE_COMPILE" = "1" ]; then
_RESOLVED_LLAMA_TAG="$_REQUESTED_LLAMA_TAG"
elif [ "$_LLAMA_SOURCE" = "https://github.com/ggml-org/llama.cpp" ]; then
_RESOLVE_TAG_ARGS=(--resolve-llama-tag "$_REQUESTED_LLAMA_TAG" --published-repo "$_HELPER_RELEASE_REPO")
_RESOLVE_TAG_ARGS+=(--output-format json)
if [ -n "${UNSLOTH_LLAMA_RELEASE_TAG:-}" ]; then
_RESOLVE_TAG_ARGS+=(--published-release-tag "$UNSLOTH_LLAMA_RELEASE_TAG")
fi
set +e
_RESOLVE_TAG_JSON="$(python "$SCRIPT_DIR/install_llama_prebuilt.py" "${_RESOLVE_TAG_ARGS[@]}" 2>/dev/null)"
_RESOLVE_UPSTREAM_STATUS=$?
set -e
if [ "$_RESOLVE_UPSTREAM_STATUS" -eq 0 ] && [ -n "${_RESOLVE_TAG_JSON:-}" ]; then
_RESOLVED_LLAMA_TAG="$(
printf '%s' "$_RESOLVE_TAG_JSON" | python -c 'import json,sys; print(json.load(sys.stdin).get("llama_tag",""))' 2>/dev/null || true
)"
else
_RESOLVED_LLAMA_TAG=""
fi
if [ -z "$_RESOLVED_LLAMA_TAG" ]; then
_RESOLVED_LLAMA_TAG="$_REQUESTED_LLAMA_TAG"
fi
else
_RESOLVED_LLAMA_TAG="$_REQUESTED_LLAMA_TAG"
fi
else
_RESOLVE_INSTALL_ARGS=(--resolve-install-tag "$_REQUESTED_LLAMA_TAG" --published-repo "$_HELPER_RELEASE_REPO")
_RESOLVE_INSTALL_ARGS+=(--output-format json)
if [ -n "${UNSLOTH_LLAMA_RELEASE_TAG:-}" ]; then
_RESOLVE_INSTALL_ARGS+=(--published-release-tag "$UNSLOTH_LLAMA_RELEASE_TAG")
fi
_RESOLVE_LLAMA_LOG="$(mktemp)"
set +e
_RESOLVE_INSTALL_JSON="$(
python "$SCRIPT_DIR/install_llama_prebuilt.py" \
"${_RESOLVE_INSTALL_ARGS[@]}" 2>"$_RESOLVE_LLAMA_LOG"
)"
_RESOLVE_LLAMA_STATUS=$?
set -e
if [ "$_RESOLVE_LLAMA_STATUS" -eq 0 ]; then
_RESOLVED_LLAMA_TAG="$(
printf '%s' "${_RESOLVE_INSTALL_JSON:-}" | python -c 'import json,sys; print(json.load(sys.stdin).get("llama_tag",""))' 2>/dev/null || true
)"
else
_RESOLVED_LLAMA_TAG=""
fi
if [ -z "$_RESOLVED_LLAMA_TAG" ]; then
step "llama.cpp" "failed to resolve a published llama.cpp release via $_HELPER_RELEASE_REPO" "$C_WARN"
print_llama_error_log "$_RESOLVE_LLAMA_LOG"
set +e
# Resolve the llama.cpp tag for source-build fallback. Pass --published-repo
# so the resolver prefers the latest usable Unsloth-published upstream tag
# before falling back to the bleeding-edge ggml-org/llama.cpp tag.
_RESOLVE_FALLBACK_ARGS=(--resolve-llama-tag "$_REQUESTED_LLAMA_TAG" --published-repo "$_HELPER_RELEASE_REPO")
_RESOLVE_FALLBACK_ARGS+=(--output-format json)
if [ -n "${UNSLOTH_LLAMA_RELEASE_TAG:-}" ]; then
_RESOLVE_FALLBACK_ARGS+=(--published-release-tag "$UNSLOTH_LLAMA_RELEASE_TAG")
fi
_RESOLVE_FALLBACK_JSON="$(python "$SCRIPT_DIR/install_llama_prebuilt.py" "${_RESOLVE_FALLBACK_ARGS[@]}" 2>/dev/null)"
_RESOLVE_UPSTREAM_STATUS=$?
set -e
if [ "$_RESOLVE_UPSTREAM_STATUS" -eq 0 ] && [ -n "${_RESOLVE_FALLBACK_JSON:-}" ]; then
_RESOLVED_LLAMA_TAG="$(
printf '%s' "$_RESOLVE_FALLBACK_JSON" | python -c 'import json,sys; print(json.load(sys.stdin).get("llama_tag",""))' 2>/dev/null || true
)"
else
_RESOLVED_LLAMA_TAG=""
fi
if [ -z "$_RESOLVED_LLAMA_TAG" ]; then
_RESOLVED_LLAMA_TAG="$_REQUESTED_LLAMA_TAG"
fi
_NEED_LLAMA_SOURCE_BUILD=true
_SKIP_PREBUILT_INSTALL=true
fi
rm -f "$_RESOLVE_LLAMA_LOG"
fi
substep "resolved llama.cpp tag: $_RESOLVED_LLAMA_TAG"
verbose_substep "requested llama.cpp tag: $_REQUESTED_LLAMA_TAG (repo: $_HELPER_RELEASE_REPO)"
if [ "$_LLAMA_FORCE_COMPILE" = "1" ]; then
step "llama.cpp" "UNSLOTH_LLAMA_FORCE_COMPILE=1 -- skipping prebuilt" "$C_WARN"
_NEED_LLAMA_SOURCE_BUILD=true
elif [ "${_SKIP_PREBUILT_INSTALL:-false}" = true ]; then
substep "prebuilt install skipped -- falling back to source build"
else
substep "installing prebuilt llama.cpp..."
if [ -d "$LLAMA_CPP_DIR" ]; then
substep "existing install detected -- validating update"
fi
if [ "${_SKIP_PREBUILT_INSTALL:-false}" = true ]; then
substep "prebuilt tag resolution failed -- falling back to source build"
_PREBUILT_CMD=(
python "$SCRIPT_DIR/install_llama_prebuilt.py"
--install-dir "$LLAMA_CPP_DIR"
--llama-tag "$_REQUESTED_LLAMA_TAG"
--published-repo "$_HELPER_RELEASE_REPO"
--simple-policy
)
if [ -n "${UNSLOTH_LLAMA_RELEASE_TAG:-}" ]; then
_PREBUILT_CMD+=(--published-release-tag "$UNSLOTH_LLAMA_RELEASE_TAG")
fi
_PREBUILT_LOG="$(mktemp)"
set +e
if _is_verbose; then
"${_PREBUILT_CMD[@]}" 2>&1 | tee "$_PREBUILT_LOG"
_PREBUILT_STATUS=${PIPESTATUS[0]}
else
_PREBUILT_CMD=(
python "$SCRIPT_DIR/install_llama_prebuilt.py"
--install-dir "$LLAMA_CPP_DIR"
--llama-tag "$_REQUESTED_LLAMA_TAG"
--published-repo "$_HELPER_RELEASE_REPO"
)
if [ -n "${UNSLOTH_LLAMA_RELEASE_TAG:-}" ]; then
_PREBUILT_CMD+=(--published-release-tag "$UNSLOTH_LLAMA_RELEASE_TAG")
fi
_PREBUILT_LOG="$(mktemp)"
set +e
if _is_verbose; then
"${_PREBUILT_CMD[@]}" 2>&1 | tee "$_PREBUILT_LOG"
_PREBUILT_STATUS=${PIPESTATUS[0]}
else
"${_PREBUILT_CMD[@]}" >"$_PREBUILT_LOG" 2>&1
_PREBUILT_STATUS=$?
fi
set -e
"${_PREBUILT_CMD[@]}" >"$_PREBUILT_LOG" 2>&1
_PREBUILT_STATUS=$?
fi
set -e
if [ "$_PREBUILT_STATUS" -eq 0 ]; then
if grep -Fq "already matches" "$_PREBUILT_LOG"; then
step "llama.cpp" "prebuilt up to date and validated"
else
step "llama.cpp" "prebuilt installed and validated"
fi
verbose_substep "llama.cpp install dir: $LLAMA_CPP_DIR"
rm -f "$_PREBUILT_LOG"
elif [ "$_PREBUILT_STATUS" -eq 3 ]; then
step "llama.cpp" "install blocked by active llama.cpp process" "$C_WARN"
print_llama_error_log "$_PREBUILT_LOG"
rm -f "$_PREBUILT_LOG"
if [ -d "$LLAMA_CPP_DIR" ]; then
substep "existing install was restored"
fi
substep "close Studio or other llama.cpp users and retry"
exit 3
if [ "$_PREBUILT_STATUS" -eq 0 ]; then
if grep -Fq "already matches" "$_PREBUILT_LOG"; then
step "llama.cpp" "prebuilt up to date and validated"
else
step "llama.cpp" "prebuilt install failed (continuing)" "$C_WARN"
print_llama_error_log "$_PREBUILT_LOG"
rm -f "$_PREBUILT_LOG"
if [ -d "$LLAMA_CPP_DIR" ]; then
substep "prebuilt update failed; existing install restored"
fi
substep "falling back to source build"
_NEED_LLAMA_SOURCE_BUILD=true
step "llama.cpp" "prebuilt installed and validated"
fi
print_installed_llama_prebuilt_release "$LLAMA_CPP_DIR"
verbose_substep "llama.cpp install dir: $LLAMA_CPP_DIR"
rm -f "$_PREBUILT_LOG"
elif [ "$_PREBUILT_STATUS" -eq 3 ]; then
step "llama.cpp" "install blocked by active llama.cpp process" "$C_WARN"
print_llama_error_log "$_PREBUILT_LOG"
rm -f "$_PREBUILT_LOG"
if [ -d "$LLAMA_CPP_DIR" ]; then
substep "existing install was restored"
fi
substep "close Studio or other llama.cpp users and retry"
exit 3
else
step "llama.cpp" "prebuilt install failed (continuing)" "$C_WARN"
print_llama_error_log "$_PREBUILT_LOG"
rm -f "$_PREBUILT_LOG"
if [ -d "$LLAMA_CPP_DIR" ]; then
substep "prebuilt update failed; existing install restored"
fi
substep "falling back to source build"
_NEED_LLAMA_SOURCE_BUILD=true
fi
fi
@ -746,33 +702,41 @@ else
[ -f "$LLAMA_SERVER_BIN" ] || _LLAMA_CPP_DEGRADED=true
else
if [ -z "$_LLAMA_PR" ]; then
if [ "$_LLAMA_SOURCE" = "https://github.com/ggml-org/llama.cpp" ]; then
_RESOLVE_SOURCE_ARGS=(--resolve-source-build "$_REQUESTED_LLAMA_TAG" --published-repo "$_HELPER_RELEASE_REPO")
_RESOLVE_SOURCE_ARGS+=(--output-format json)
if [ -n "${UNSLOTH_LLAMA_RELEASE_TAG:-}" ]; then
_RESOLVE_SOURCE_ARGS+=(--published-release-tag "$UNSLOTH_LLAMA_RELEASE_TAG")
_RESOLVED_SOURCE_URL="$_LLAMA_SOURCE"
if [ "$_LLAMA_FORCE_COMPILE" = "1" ]; then
if [ "$_REQUESTED_LLAMA_TAG" = "latest" ]; then
_RESOLVED_SOURCE_REF="${UNSLOTH_LLAMA_FORCE_COMPILE_REF:-${_DEFAULT_LLAMA_FORCE_COMPILE_REF}}"
_RESOLVED_SOURCE_REF_KIND="branch"
else
_RESOLVED_SOURCE_REF="$_REQUESTED_LLAMA_TAG"
_RESOLVED_SOURCE_REF_KIND="tag"
fi
elif [ "$_REQUESTED_LLAMA_TAG" = "latest" ]; then
_RESOLVE_TAG_ARGS=(--resolve-llama-tag latest --published-repo "ggml-org/llama.cpp" --output-format json)
set +e
_SOURCE_BUILD_PLAN="$(python "$SCRIPT_DIR/install_llama_prebuilt.py" "${_RESOLVE_SOURCE_ARGS[@]}" 2>/dev/null)"
_RESOLVE_SOURCE_STATUS=$?
_RESOLVE_TAG_JSON="$(python "$SCRIPT_DIR/install_llama_prebuilt.py" "${_RESOLVE_TAG_ARGS[@]}" 2>/dev/null)"
_RESOLVE_TAG_STATUS=$?
set -e
if [ "$_RESOLVE_SOURCE_STATUS" -eq 0 ] && [ -n "$_SOURCE_BUILD_PLAN" ]; then
_RESOLVED_SOURCE_URL="$(
printf '%s' "$_SOURCE_BUILD_PLAN" | python -c 'import json,sys; print(json.load(sys.stdin).get("source_url",""))' 2>/dev/null || true
)"
_RESOLVED_SOURCE_REF_KIND="$(
printf '%s' "$_SOURCE_BUILD_PLAN" | python -c 'import json,sys; print(json.load(sys.stdin).get("source_ref_kind",""))' 2>/dev/null || true
)"
if [ "$_RESOLVE_TAG_STATUS" -eq 0 ] && [ -n "${_RESOLVE_TAG_JSON:-}" ]; then
_RESOLVED_SOURCE_REF="$(
printf '%s' "$_SOURCE_BUILD_PLAN" | python -c 'import json,sys; print(json.load(sys.stdin).get("source_ref",""))' 2>/dev/null || true
printf '%s' "$_RESOLVE_TAG_JSON" | python -c 'import json,sys; print(json.load(sys.stdin).get("llama_tag",""))' 2>/dev/null || true
)"
else
_RESOLVED_SOURCE_REF=""
fi
if [ -z "$_RESOLVED_SOURCE_REF" ]; then
_RESOLVED_SOURCE_REF="latest"
fi
_RESOLVED_SOURCE_REF_KIND="tag"
else
_RESOLVED_SOURCE_REF="$_REQUESTED_LLAMA_TAG"
_RESOLVED_SOURCE_REF_KIND="tag"
fi
if [ -z "$_RESOLVED_SOURCE_URL" ]; then
_RESOLVED_SOURCE_URL="$_LLAMA_SOURCE"
fi
if [ -z "$_RESOLVED_SOURCE_REF" ]; then
_RESOLVED_SOURCE_REF="$_RESOLVED_LLAMA_TAG"
_RESOLVED_SOURCE_REF="$_REQUESTED_LLAMA_TAG"
fi
fi
verbose_substep "source build repo: $_RESOLVED_SOURCE_URL"

View file

@ -14,10 +14,12 @@ Run: pytest tests/studio/install/test_pr4562_bugfixes.py -v
"""
import importlib.util
import json
import os
import subprocess
import sys
import textwrap
import urllib.parse
from pathlib import Path
import pytest
@ -347,6 +349,47 @@ class TestResolveRequestedLlamaTag:
}
class TestFetchJsonRetries:
def test_fetch_json_retries_invalid_github_api_json(
self, monkeypatch: pytest.MonkeyPatch
):
calls = {"count": 0}
def fake_download_bytes(url, **kwargs):
calls["count"] += 1
if calls["count"] == 1:
return b'{"incomplete":"payload'
return json.dumps([{"tag_name": "b8635"}]).encode("utf-8")
monkeypatch.setattr(MOD, "download_bytes", fake_download_bytes)
monkeypatch.setattr(MOD, "sleep_backoff", lambda _attempt: None)
payload = MOD.fetch_json(
"https://api.github.com/repos/ggml-org/llama.cpp/releases?per_page=100&page=1"
)
assert isinstance(payload, list)
assert payload[0]["tag_name"] == "b8635"
assert calls["count"] == 2
def test_github_releases_honors_max_pages(self, monkeypatch: pytest.MonkeyPatch):
seen_pages: list[int] = []
def fake_fetch_json(url: str):
parsed = urllib.parse.urlparse(url)
params = urllib.parse.parse_qs(parsed.query)
page = int(params["page"][0])
seen_pages.append(page)
return [{"tag_name": f"b{page:04d}"} for _ in range(100)]
monkeypatch.setattr(MOD, "fetch_json", fake_fetch_json)
releases = MOD.github_releases("ggml-org/llama.cpp", max_pages = 2)
assert seen_pages == [1, 2]
assert len(releases) == 200
# =========================================================================
# TEST GROUP C: setup.sh logic (bash subprocess tests)
# =========================================================================
@ -643,25 +686,35 @@ class TestSourceCodePatterns:
'_RESOLVED_SOURCE_REF" != "latest"' in content
), "Should guard against literal 'latest' tag"
def test_setup_sh_source_build_uses_helper_resolution(self):
"""Shell source fallback should consult the helper for repo/ref planning."""
def test_setup_sh_source_build_uses_helper_latest_tag_only(self):
"""Shell source fallback should only use helper latest-tag resolution."""
content = SETUP_SH.read_text()
assert "--resolve-source-build" in content
assert "--resolve-source-build" not in content
assert "--resolve-install-tag" not in content
assert (
'--resolve-llama-tag latest --published-repo "ggml-org/llama.cpp"'
in content
)
assert "--output-format json" in content
assert "_RESOLVED_SOURCE_URL" in content
assert "_RESOLVED_SOURCE_REF_KIND" in content
assert "_RESOLVED_SOURCE_REF" in content
def test_setup_sh_latest_resolution_uses_helper_only(self):
"""Shell fallback should rely on helper output, not raw GitHub API tag_name."""
def test_setup_sh_prebuilt_install_uses_simple_policy_only(self):
"""Shell prebuilt path should use the simplified helper install entrypoint."""
content = SETUP_SH.read_text()
assert "--resolve-install-tag" in content
assert "--resolve-llama-tag" in content
assert 'tail -n 1 "$_RESOLVE_LLAMA_LOG"' not in content
assert "json.load" in content
assert "--simple-policy" in content
assert "--resolve-install-tag" not in content
assert "_HELPER_RELEASE_REPO}/releases/latest" not in content
assert "ggml-org/llama.cpp/releases/latest" not in content
def test_setup_sh_reports_installed_prebuilt_release(self):
"""Shell wrapper should report the installed prebuilt release from metadata."""
content = SETUP_SH.read_text()
assert "UNSLOTH_PREBUILT_INFO.json" in content
assert "installed release:" in content
assert 'print_installed_llama_prebuilt_release "$LLAMA_CPP_DIR"' in content
def test_setup_sh_macos_arm64_uses_metal_flags(self):
"""Apple Silicon source builds should explicitly enable Metal like upstream."""
content = SETUP_SH.read_text()
@ -759,20 +812,34 @@ class TestSourceCodePatterns:
f"Found 'git pull' in llama.cpp build section at line {i+1}"
)
def test_setup_ps1_latest_resolution_uses_helper_only(self):
"""PS1 fallback should rely on helper output, not raw GitHub API tag_name."""
def test_setup_ps1_prebuilt_install_uses_simple_policy_only(self):
"""PS1 prebuilt path should use the simplified helper install entrypoint."""
content = SETUP_PS1.read_text()
assert "--resolve-install-tag" in content
assert "--resolve-llama-tag" in content
assert '--output-format", "json"' in content
assert "ConvertFrom-Json" in content
assert '"--simple-policy"' in content
assert "--resolve-install-tag" not in content
assert "$HelperReleaseRepo/releases/latest" not in content
assert "ggml-org/llama.cpp/releases/latest" not in content
def test_setup_ps1_source_build_uses_helper_resolution(self):
"""PS1 source fallback should consult the helper for repo/ref planning."""
def test_setup_ps1_reports_installed_prebuilt_release(self):
"""PS1 wrapper should report the installed prebuilt release from metadata."""
content = SETUP_PS1.read_text()
assert "--resolve-source-build" in content
assert "Get-InstalledLlamaPrebuiltRelease" in content
assert "UNSLOTH_PREBUILT_INFO.json" in content
assert "installed release:" in content
assert (
"$installedRelease = Get-InstalledLlamaPrebuiltRelease -InstallDir $LlamaCppDir"
in content
)
def test_setup_ps1_source_build_uses_helper_latest_tag_only(self):
"""PS1 source fallback should only use helper latest-tag resolution."""
content = SETUP_PS1.read_text()
assert "--resolve-source-build" not in content
assert "--resolve-install-tag" not in content
assert (
'"--resolve-llama-tag", "latest", "--published-repo", "ggml-org/llama.cpp"'
in content
)
assert '--output-format", "json"' in content
assert "$ResolvedSourceUrl" in content
assert "$ResolvedSourceRefKind" in content
@ -794,18 +861,25 @@ class TestSourceCodePatterns:
"""Helper resolution should suppress terminating NativeCommandError on PS 5.1."""
content = SETUP_PS1.read_text()
helper_idx = content.index("function Invoke-LlamaHelper")
block = content[helper_idx : helper_idx + 1200]
block = content[helper_idx : helper_idx + 2200]
assert "$previousErrorActionPreference = $ErrorActionPreference" in block
assert '$ErrorActionPreference = "Continue"' in block
assert "$ErrorActionPreference = $previousErrorActionPreference" in block
def test_setup_ps1_uses_local_tempfile_helper(self):
"""PS1 should not depend on New-TemporaryFile being available."""
"""PS1 should not depend on New-TemporaryFile being available anywhere."""
content = SETUP_PS1.read_text()
assert "function New-UnslothTemporaryFile" in content
assert "$resolveErrorLog = New-UnslothTemporaryFile" in content
assert "$resolveErrorLog = New-TemporaryFile" not in content
def test_setup_ps1_find_nvcc_uses_version_sort_for_latest_toolkit(self):
"""The unconstrained nvcc fallback should not sort toolkit dirs lexicographically."""
content = SETUP_PS1.read_text()
assert "Sort-Object Name | Select-Object -Last 1" not in content
assert (
"Sort-Object { [version]($_.Name -replace '^v','') } -Descending" in content
)
def test_binary_env_linux_has_binary_parent(self):
"""The Linux branch of binary_env should include binary_path.parent."""
content = MODULE_PATH.read_text()

View file

@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
__version__ = "2026.4.1"
__version__ = "2026.4.2"
__all__ = [
"SUPPORTS_BFLOAT16",

View file

@ -114,6 +114,17 @@ FORCE_FLOAT32 = [
global DISABLE_COMPILE_MODEL_NAMES
# Must be alphabetically sorted for each entry
def _strip_unsloth_bnb_4bit_suffix(model_name: str) -> str:
"""Remove Unsloth 4bit suffixes without lowercasing (HF cache dirs are case-sensitive)."""
s = model_name
for suffix in ("-unsloth-bnb-4bit", "-bnb-4bit"):
if len(s) >= len(suffix) and s.lower().endswith(suffix.lower()):
s = s[: -len(suffix)]
return s
DISABLE_COMPILE_MODEL_NAMES = [
"aya_vision",
"modernbert",
@ -404,8 +415,7 @@ class FastLanguageModel(FastLlamaModel):
if not ALLOW_PREQUANTIZED_MODELS and model_name.lower().endswith(
("-unsloth-bnb-4bit", "-bnb-4bit")
):
model_name = model_name.lower().removesuffix("-unsloth-bnb-4bit")
model_name = model_name.lower().removesuffix("-bnb-4bit")
model_name = _strip_unsloth_bnb_4bit_suffix(model_name)
# Change -BF16 to all False for 4bit, 8bit etc
if model_name.lower().endswith("-bf16"):
load_in_4bit = False
@ -551,8 +561,7 @@ class FastLanguageModel(FastLlamaModel):
if not ALLOW_PREQUANTIZED_MODELS and model_name.lower().endswith(
("-unsloth-bnb-4bit", "-bnb-4bit")
):
model_name = model_name.lower().removesuffix("-unsloth-bnb-4bit")
model_name = model_name.lower().removesuffix("-bnb-4bit")
model_name = _strip_unsloth_bnb_4bit_suffix(model_name)
# Change -BF16 to all False for 4bit, 8bit etc
if model_name.lower().endswith("-bf16"):
load_in_4bit = False
@ -1019,8 +1028,7 @@ class FastModel(FastBaseModel):
if not ALLOW_PREQUANTIZED_MODELS and model_name.lower().endswith(
("-unsloth-bnb-4bit", "-bnb-4bit")
):
model_name = model_name.lower().removesuffix("-unsloth-bnb-4bit")
model_name = model_name.lower().removesuffix("-bnb-4bit")
model_name = _strip_unsloth_bnb_4bit_suffix(model_name)
# Change -BF16 to all False for 4bit, 8bit etc
if model_name.lower().endswith("-bf16"):
load_in_4bit = False
@ -1320,8 +1328,7 @@ class FastModel(FastBaseModel):
if not ALLOW_PREQUANTIZED_MODELS and model_name.lower().endswith(
("-unsloth-bnb-4bit", "-bnb-4bit")
):
model_name = model_name.lower().removesuffix("-unsloth-bnb-4bit")
model_name = model_name.lower().removesuffix("-bnb-4bit")
model_name = _strip_unsloth_bnb_4bit_suffix(model_name)
# Change -BF16 to all False for 4bit, 8bit etc
if model_name.lower().endswith("-bf16"):
load_in_4bit = False
@ -1533,14 +1540,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

View file

@ -162,7 +162,7 @@ def __get_model_name(
# Support returning original full -bnb-4bit name if specified specifically
# since we'll map it to the dynamic version instead
if lower_model_name.endswith("-bnb-4bit"):
return lower_model_name
return model_name
new_model_name = FLOAT_TO_INT_MAPPER[lower_model_name]
# logger.warning_once(