From f0176c7f0ceeb0658e962d50f7d3ff2895715036 Mon Sep 17 00:00:00 2001 From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Date: Wed, 25 Feb 2026 18:54:39 +0400 Subject: [PATCH 1/5] Relocate GGUF exports into exports/ directory --- studio/backend/core/export/export.py | 33 +++++++++++++++++-- .../src/features/export/export-page.tsx | 7 +++- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/studio/backend/core/export/export.py b/studio/backend/core/export/export.py index cb2cb01c22..079ea0277d 100644 --- a/studio/backend/core/export/export.py +++ b/studio/backend/core/export/export.py @@ -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,27 @@ 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}/") + + # Also check model_save_path for any .gguf files + if os.path.isdir(model_save_path): + for src in glob.glob(os.path.join(model_save_path, "*.gguf")): + 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}/") + + # Clean up intermediate HF model files (safetensors, config, etc.) + # since we only need the final .gguf output + shutil.rmtree(model_save_path, ignore_errors=True) + logger.info("Cleaned up intermediate HF model files") + logger.info(f"GGUF model saved successfully in {abs_save_dir}") # Push to hub if requested diff --git a/studio/frontend/src/features/export/export-page.tsx b/studio/frontend/src/features/export/export-page.tsx index 9d3e5053de..fa43c5eb98 100644 --- a/studio/frontend/src/features/export/export-page.tsx +++ b/studio/frontend/src/features/export/export-page.tsx @@ -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}` From b91b979bf8ec335783081aa9fd180bf848bc097e Mon Sep 17 00:00:00 2001 From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Date: Wed, 25 Feb 2026 19:01:47 +0400 Subject: [PATCH 2/5] Add GGUF tag for exported models in chat page selector --- studio/backend/models/models.py | 2 +- studio/backend/utils/models/model_config.py | 45 ++++++++++++++++--- .../assistant-ui/model-selector/pickers.tsx | 11 +++-- .../assistant-ui/model-selector/types.ts | 2 +- .../frontend/src/features/chat/types/api.ts | 2 +- .../src/features/chat/types/runtime.ts | 2 +- 6 files changed, 50 insertions(+), 14 deletions(-) diff --git a/studio/backend/models/models.py b/studio/backend/models/models.py index 39034f8ce2..8c7d0c037d 100644 --- a/studio/backend/models/models.py +++ b/studio/backend/models/models.py @@ -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): diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py index 98e84c7d6c..a37deffff2 100644 --- a/studio/backend/utils/models/model_config.py +++ b/studio/backend/utils/models/model_config.py @@ -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,20 @@ 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")) + export_meta = checkpoint_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 = 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 diff --git a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx index 02205248ea..26f0e6e028 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx @@ -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 ( onSelect(adapter.id, { source: isExported ? "exported" : "lora", - isLora: !isMerged, + isLora: !isMerged && !isGguf, })} /> ); diff --git a/studio/frontend/src/components/assistant-ui/model-selector/types.ts b/studio/frontend/src/components/assistant-ui/model-selector/types.ts index 43f5e935b3..0e8cf5fb4d 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/types.ts +++ b/studio/frontend/src/components/assistant-ui/model-selector/types.ts @@ -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 { diff --git a/studio/frontend/src/features/chat/types/api.ts b/studio/frontend/src/features/chat/types/api.ts index 5dd0cd7a6b..d0b37f5cce 100644 --- a/studio/frontend/src/features/chat/types/api.ts +++ b/studio/frontend/src/features/chat/types/api.ts @@ -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 { diff --git a/studio/frontend/src/features/chat/types/runtime.ts b/studio/frontend/src/features/chat/types/runtime.ts index 953f4ebbaa..710898713b 100644 --- a/studio/frontend/src/features/chat/types/runtime.ts +++ b/studio/frontend/src/features/chat/types/runtime.ts @@ -35,5 +35,5 @@ export interface ChatLoraSummary { baseModel: string; updatedAt?: number; source?: "training" | "exported"; - exportType?: "lora" | "merged"; + exportType?: "lora" | "merged" | "gguf"; } From f7476463299c35e360659b6ac5c0cd2009e4c0d4 Mon Sep 17 00:00:00 2001 From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Date: Thu, 26 Feb 2026 10:59:39 +0400 Subject: [PATCH 3/5] Add gguf to toLoraSummary inline type --- .../frontend/src/features/chat/hooks/use-chat-model-runtime.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts index c3464f9071..a0ea17e304 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts @@ -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 = From a715bca52f201a545da489c27992319bc77c8c89 Mon Sep 17 00:00:00 2001 From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:24:32 +0400 Subject: [PATCH 4/5] Write export metadata for GGUF exports to fix Unknown base model --- studio/backend/core/export/export.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/studio/backend/core/export/export.py b/studio/backend/core/export/export.py index 079ea0277d..39e956eb6f 100644 --- a/studio/backend/core/export/export.py +++ b/studio/backend/core/export/export.py @@ -449,6 +449,9 @@ class ExportBackend: shutil.rmtree(model_save_path, ignore_errors=True) logger.info("Cleaned up intermediate HF model files") + # 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 From c06adc387882be2c9628d82318e3daee5a0cca2a Mon Sep 17 00:00:00 2001 From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:35:04 +0400 Subject: [PATCH 5/5] Flatten GGUF subdirs in export and fix metadata lookup in scanner --- studio/backend/core/export/export.py | 24 +++++++++++---------- studio/backend/utils/models/model_config.py | 19 ++++++++++------ 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/studio/backend/core/export/export.py b/studio/backend/core/export/export.py index 39e956eb6f..bc4e267f75 100644 --- a/studio/backend/core/export/export.py +++ b/studio/backend/core/export/export.py @@ -437,17 +437,19 @@ class ExportBackend: shutil.move(src, dest) logger.info(f"Relocated GGUF: {os.path.basename(src)} → {abs_save_dir}/") - # Also check model_save_path for any .gguf files - if os.path.isdir(model_save_path): - for src in glob.glob(os.path.join(model_save_path, "*.gguf")): - 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}/") - - # Clean up intermediate HF model files (safetensors, config, etc.) - # since we only need the final .gguf output - shutil.rmtree(model_save_path, ignore_errors=True) - logger.info("Cleaned up intermediate HF model files") + # 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) diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py index a37deffff2..8bb9e60ed2 100644 --- a/studio/backend/utils/models/model_config.py +++ b/studio/backend/utils/models/model_config.py @@ -714,13 +714,18 @@ def scan_exported_models(exports_dir: str = "./exports") -> List[Tuple[str, str, elif has_gguf: export_type = "gguf" gguf_list = list(checkpoint_dir.glob("*.gguf")) - export_meta = checkpoint_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 + # 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)