Merge pull request #270 from unslothai/fix/gguf-export-relocation

Fix GGUF exports saving to wrong directory and missing from chat model selector
This commit is contained in:
Roland Tannous 2026-02-26 11:48:15 +04:00 committed by GitHub
commit 8944d79c61
9 changed files with 98 additions and 18 deletions

View file

@ -2,9 +2,11 @@
"""
Export backend - handles model exporting in various formats
"""
import glob
import json
import logging
import os
import shutil
from pathlib import Path
from typing import Optional, Tuple, List
from peft import PeftModel, PeftModelForCausalLM
@ -409,9 +411,15 @@ class ExportBackend:
# On WSL, patch out sudo check before llama.cpp build
_apply_wsl_sudo_patch()
# Snapshot existing .gguf files in cwd before conversion.
# unsloth's convert_to_gguf writes output files relative to
# cwd (repo root), so we diff afterwards and relocate them.
cwd = os.getcwd()
pre_existing_ggufs = set(glob.glob(os.path.join(cwd, "*.gguf")))
# Pass absolute path — no os.chdir needed.
# unsloth saves model files into this directory, while
# check_llama_cpp("llama.cpp") resolves against cwd (repo root)
# unsloth saves intermediate HF model files into model_save_path,
# while check_llama_cpp("llama.cpp") resolves against cwd (repo root)
# where setup.sh already built llama.cpp with quantizer.
model_save_path = os.path.join(abs_save_dir, "model")
self.current_model.save_pretrained_gguf(
@ -420,6 +428,32 @@ class ExportBackend:
quantization_method=quant_method
)
# Relocate GGUF artifacts into the export directory.
# convert_to_gguf writes .gguf files to cwd (repo root)
# because --outfile is a relative path like "model.Q4_K_M.gguf".
new_ggufs = set(glob.glob(os.path.join(cwd, "*.gguf"))) - pre_existing_ggufs
for src in sorted(new_ggufs):
dest = os.path.join(abs_save_dir, os.path.basename(src))
shutil.move(src, dest)
logger.info(f"Relocated GGUF: {os.path.basename(src)}{abs_save_dir}/")
# Flatten any .gguf files from subdirectories into abs_save_dir.
# save_pretrained_gguf may create subdirs (e.g. model_gguf/)
# with a name different from model_save_path.
for sub in list(Path(abs_save_dir).iterdir()):
if not sub.is_dir():
continue
for src in sub.glob("*.gguf"):
dest = os.path.join(abs_save_dir, src.name)
shutil.move(str(src), dest)
logger.info(f"Relocated GGUF: {src.name}{abs_save_dir}/")
# Clean up the subdirectory (intermediate HF files, etc.)
shutil.rmtree(str(sub), ignore_errors=True)
logger.info(f"Cleaned up subdirectory: {sub.name}")
# Write export metadata so the Chat page can identify the base model
self._write_export_metadata(abs_save_dir)
logger.info(f"GGUF model saved successfully in {abs_save_dir}")
# Push to hub if requested

View file

@ -63,7 +63,7 @@ class LoRAInfo(BaseModel):
adapter_path: str = Field(..., description="Path to the LoRA adapter or exported model")
base_model: Optional[str] = Field(None, description="Base model identifier")
source: Optional[str] = Field(None, description="'training' or 'exported'")
export_type: Optional[str] = Field(None, description="'lora' or 'merged' (for exports)")
export_type: Optional[str] = Field(None, description="'lora', 'merged', or 'gguf' (for exports)")
class LoRAScanResponse(BaseModel):

View file

@ -640,14 +640,15 @@ def scan_trained_loras(outputs_dir: str = "./outputs") -> List[Tuple[str, str]]:
def scan_exported_models(exports_dir: str = "./exports") -> List[Tuple[str, str, str, Optional[str]]]:
"""
Scan exports folder for exported models (merged, LoRA, base).
Skips GGUF-only exports (not loadable by Unsloth inference backend).
Scan exports folder for exported models (merged, LoRA, GGUF).
The exports directory is two levels deep: {run}/{checkpoint}/
Supports two directory layouts:
- Two-level: {run}/{checkpoint}/ (merged & LoRA exports)
- Flat: {name}-finetune-gguf/ (GGUF exports)
Returns:
List of tuples: [(display_name, model_path, export_type, base_model), ...]
export_type: "lora" | "merged"
export_type: "lora" | "merged" | "gguf"
"""
results = []
exports_path = Path(exports_dir)
@ -659,6 +660,26 @@ def scan_exported_models(exports_dir: str = "./exports") -> List[Tuple[str, str,
for run_dir in exports_path.iterdir():
if not run_dir.is_dir():
continue
# Check for flat GGUF export (e.g. exports/gemma-3-4b-it-finetune-gguf/)
gguf_files = list(run_dir.glob("*.gguf"))
if gguf_files:
base_model = None
export_meta = run_dir / "export_metadata.json"
try:
if export_meta.exists():
meta = json.loads(export_meta.read_text())
base_model = meta.get("base_model")
except Exception:
pass
display_name = run_dir.name
model_path = str(gguf_files[0]) # path to the .gguf file
results.append((display_name, model_path, "gguf", base_model))
logger.debug(f"Found GGUF export: {display_name}")
continue
# Two-level: {run}/{checkpoint}/
for checkpoint_dir in run_dir.iterdir():
if not checkpoint_dir.is_dir():
continue
@ -683,7 +704,6 @@ def scan_exported_models(exports_dir: str = "./exports") -> List[Tuple[str, str,
pass
elif config_file.exists() and has_weights:
export_type = "merged"
# Read base model from export_metadata.json (written at export time)
export_meta = checkpoint_dir / "export_metadata.json"
try:
if export_meta.exists():
@ -692,7 +712,25 @@ def scan_exported_models(exports_dir: str = "./exports") -> List[Tuple[str, str,
except Exception:
pass
elif has_gguf:
# GGUF-only — not loadable by current inference backend
export_type = "gguf"
gguf_list = list(checkpoint_dir.glob("*.gguf"))
# Check checkpoint_dir first, then fall back to parent run_dir
# (export.py writes metadata to the top-level export directory)
for meta_dir in (checkpoint_dir, run_dir):
export_meta = meta_dir / "export_metadata.json"
try:
if export_meta.exists():
meta = json.loads(export_meta.read_text())
base_model = meta.get("base_model")
if base_model:
break
except Exception:
pass
display_name = f"{run_dir.name} / {checkpoint_dir.name}"
model_path = str(gguf_list[0]) if gguf_list else str(checkpoint_dir)
results.append((display_name, model_path, export_type, base_model))
logger.debug(f"Found GGUF export: {display_name}")
continue
else:
continue

View file

@ -525,9 +525,12 @@ export function LoraModelPicker({
{adapters.map((adapter) => {
const isExported = adapter.source === "exported";
const isMerged = adapter.exportType === "merged";
const tag = isExported
? isMerged ? "Merged" : "LoRA"
: "LoRA";
const isGguf = adapter.exportType === "gguf";
const tag = isGguf
? "GGUF"
: isExported
? isMerged ? "Merged" : "LoRA"
: "LoRA";
const meta = isExported ? `${tag} · Exported` : tag;
return (
<ModelRow
@ -537,7 +540,7 @@ export function LoraModelPicker({
selected={value === adapter.id}
onClick={() => onSelect(adapter.id, {
source: isExported ? "exported" : "lora",
isLora: !isMerged,
isLora: !isMerged && !isGguf,
})}
/>
);

View file

@ -11,7 +11,7 @@ export interface LoraModelOption extends ModelOption {
baseModel?: string;
updatedAt?: number;
source?: "training" | "exported";
exportType?: "lora" | "merged";
exportType?: "lora" | "merged" | "gguf";
}
export interface ModelSelectorChangeMeta {

View file

@ -75,7 +75,7 @@ function toLoraSummary(lora: {
adapter_path: string;
base_model?: string | null;
source?: "training" | "exported" | null;
export_type?: "lora" | "merged" | null;
export_type?: "lora" | "merged" | "gguf" | null;
}): ChatLoraSummary {
const idTail = lora.adapter_path.split("/").filter(Boolean).at(-1) ?? "";
const updatedAt =

View file

@ -16,7 +16,7 @@ export interface BackendLoraInfo {
adapter_path: string;
base_model?: string | null;
source?: "training" | "exported" | null;
export_type?: "lora" | "merged" | null;
export_type?: "lora" | "merged" | "gguf" | null;
}
export interface ListLorasResponse {

View file

@ -35,5 +35,5 @@ export interface ChatLoraSummary {
baseModel: string;
updatedAt?: number;
source?: "training" | "exported";
exportType?: "lora" | "merged";
exportType?: "lora" | "merged" | "gguf";
}

View file

@ -155,7 +155,12 @@ export function ExportPage() {
setExportError(null);
setExportSuccess(false);
const saveDir = `./exports/${selectedModelIdx ?? "model"}/${checkpoint}`;
// For GGUF, use a flat folder like "exports/gemma-3-4b-it-finetune-gguf"
// For other formats, nest under training-run/checkpoint
const saveDir =
exportMethod === "gguf"
? `./exports/${(baseModelName.split("/").pop() ?? selectedModelIdx ?? "model")}-finetune-gguf`
: `./exports/${selectedModelIdx ?? "model"}/${checkpoint}`;
const pushToHub = destination === "hub";
const repoId = pushToHub && hfUsername && modelName
? `${hfUsername}/${modelName}`