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:
commit
8944d79c61
9 changed files with 98 additions and 18 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -35,5 +35,5 @@ export interface ChatLoraSummary {
|
|||
baseModel: string;
|
||||
updatedAt?: number;
|
||||
source?: "training" | "exported";
|
||||
exportType?: "lora" | "merged";
|
||||
exportType?: "lora" | "merged" | "gguf";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue