@@ -230,18 +375,24 @@ export function HubModelPicker({
recommendedIds.map((id) => {
const vram = recommendedVramMap.get(id);
return (
-
- onSelect(id, { source: "hub", isLora: false })
- }
- vramStatus={vram?.status ?? null}
- vramEst={vram?.est}
- gpuGb={gpu.available ? gpu.memoryTotalGb : undefined}
- />
+
+ handleModelClick(id)}
+ vramStatus={isGgufRepo(id) ? null : vram?.status ?? null}
+ vramEst={isGgufRepo(id) ? undefined : vram?.est}
+ gpuGb={gpu.available ? gpu.memoryTotalGb : undefined}
+ />
+ {expandedGguf === id && (
+
+ )}
+
);
})
)}
@@ -259,18 +410,24 @@ export function HubModelPicker({
hfIds.map((id) => {
const vram = vramMap.get(id);
return (
-
- onSelect(id, { source: "hub", isLora: false })
- }
- vramStatus={vram?.status ?? null}
- vramEst={vram?.est}
- gpuGb={gpu.available ? gpu.memoryTotalGb : undefined}
- />
+
+ handleModelClick(id)}
+ vramStatus={isGgufRepo(id) ? null : vram?.status ?? null}
+ vramEst={isGgufRepo(id) ? undefined : vram?.est}
+ gpuGb={gpu.available ? gpu.memoryTotalGb : undefined}
+ />
+ {expandedGguf === id && (
+
+ )}
+
);
})
)}
@@ -382,4 +539,3 @@ export function LoraModelPicker({
);
}
-
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 dcf110bfb7..a94d3dd931 100644
--- a/studio/frontend/src/components/assistant-ui/model-selector/types.ts
+++ b/studio/frontend/src/components/assistant-ui/model-selector/types.ts
@@ -15,5 +15,6 @@ export interface LoraModelOption extends ModelOption {
export interface ModelSelectorChangeMeta {
source: "hub" | "lora";
isLora: boolean;
+ ggufVariant?: string;
}
diff --git a/studio/frontend/src/features/chat/api/chat-api.ts b/studio/frontend/src/features/chat/api/chat-api.ts
index 72baf9a6f6..5d5a9551ef 100644
--- a/studio/frontend/src/features/chat/api/chat-api.ts
+++ b/studio/frontend/src/features/chat/api/chat-api.ts
@@ -1,5 +1,6 @@
import { authFetch } from "@/features/auth";
import type {
+ GgufVariantsResponse,
InferenceStatusResponse,
ListLorasResponse,
ListModelsResponse,
@@ -74,6 +75,16 @@ export async function unloadModel(payload: UnloadModelRequest): Promise
{
await parseJsonOrThrow(response);
}
+export async function listGgufVariants(
+ repoId: string,
+ hfToken?: string,
+): Promise {
+ const params = new URLSearchParams({ repo_id: repoId });
+ if (hfToken) params.set("hf_token", hfToken);
+ const response = await authFetch(`/api/models/gguf-variants?${params}`);
+ return parseJsonOrThrow(response);
+}
+
function parseSseEvent(rawEvent: string): string[] {
const dataLines: string[] = [];
for (const line of rawEvent.split(/\r?\n/)) {
diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx
index c363704d61..7c57bdf0a4 100644
--- a/studio/frontend/src/features/chat/chat-page.tsx
+++ b/studio/frontend/src/features/chat/chat-page.tsx
@@ -300,7 +300,7 @@ export function ChatPage(): ReactElement {
}, [inferenceParams.checkpoint, lorasFromStore]);
const handleCheckpointChange = useCallback(
- (value: string, meta?: { isLora: boolean }) => {
+ (value: string, meta?: { isLora: boolean; ggufVariant?: string }) => {
const currentCheckpoint =
useChatRuntimeStore.getState().params.checkpoint;
if (!value || value === currentCheckpoint) return;
@@ -309,7 +309,11 @@ export function ChatPage(): ReactElement {
if (currentCheckpoint) {
await ejectModel();
}
- await selectModel({ id: value, isLora: meta?.isLora });
+ await selectModel({
+ id: value,
+ isLora: meta?.isLora,
+ ggufVariant: meta?.ggufVariant,
+ });
})();
},
[selectModel, ejectModel],
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 030fe55bff..24a6b3c01f 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
@@ -20,6 +20,7 @@ const DEFAULT_MODEL_MAX_SEQ_LENGTH = 2048;
type SelectedModelInput = {
id: string;
isLora?: boolean;
+ ggufVariant?: string;
};
const LORA_SUFFIX_RE = /_(\d{9,})$/;
@@ -159,6 +160,8 @@ export function useChatModelRuntime() {
const explicitIsLora =
typeof selection === "string" ? undefined : selection.isLora;
+ const ggufVariant =
+ typeof selection === "string" ? undefined : selection.ggufVariant;
const model = models.find((entry) => entry.id === modelId);
const lora = loras.find((entry) => entry.id === modelId);
const isLora =
@@ -181,6 +184,7 @@ export function useChatModelRuntime() {
max_seq_length: DEFAULT_MODEL_MAX_SEQ_LENGTH,
load_in_4bit: true,
is_lora: isLora,
+ gguf_variant: ggufVariant ?? null,
});
const currentParams = useChatRuntimeStore.getState().params;
diff --git a/studio/frontend/src/features/chat/types/api.ts b/studio/frontend/src/features/chat/types/api.ts
index bc9268e2c2..f0a10a6cae 100644
--- a/studio/frontend/src/features/chat/types/api.ts
+++ b/studio/frontend/src/features/chat/types/api.ts
@@ -28,6 +28,20 @@ export interface LoadModelRequest {
max_seq_length: number;
load_in_4bit: boolean;
is_lora: boolean;
+ gguf_variant?: string | null;
+}
+
+export interface GgufVariantDetail {
+ filename: string;
+ quant: string;
+ size_bytes: number;
+}
+
+export interface GgufVariantsResponse {
+ repo_id: string;
+ variants: GgufVariantDetail[];
+ has_vision: boolean;
+ default_variant: string | null;
}
export interface LoadModelResponse {
From 2ebeba8588f376529a221a390d19fef4a6139a79 Mon Sep 17 00:00:00 2001
From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com>
Date: Tue, 24 Feb 2026 19:21:01 +0400
Subject: [PATCH 07/12] Switch GGUF backend from /v1/completions to
/v1/chat/completions
Fixes two bugs:
1. Chat template tags (<|im_start|>, <|im_end|>) leaking into output
because /v1/completions treated them as literal text
2. Image hallucination because image_b64 was never passed to llama-server
Now llama-server handles chat templates natively and receives images
as OpenAI-format multimodal content parts for vision models.
---
studio/backend/core/inference/llama_cpp.py | 133 +++++++--------------
studio/backend/routes/inference.py | 12 +-
2 files changed, 51 insertions(+), 94 deletions(-)
diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py
index b5c7d3c50d..30fe660404 100644
--- a/studio/backend/core/inference/llama_cpp.py
+++ b/studio/backend/core/inference/llama_cpp.py
@@ -2,7 +2,7 @@
llama-server inference backend for GGUF models.
Manages a llama-server subprocess and proxies chat completions
-through its /v1/completions endpoint.
+through its OpenAI-compatible /v1/chat/completions endpoint.
"""
import atexit
import json
@@ -27,7 +27,7 @@ class LlamaCppBackend:
Lifecycle:
1. load_model() — starts llama-server with the GGUF file
- 2. generate_chat_completion() — formats prompt, proxies to /v1/completions, streams back
+ 2. generate_chat_completion() — proxies to /v1/chat/completions, streams back
3. unload_model() — terminates llama-server subprocess
"""
@@ -41,7 +41,6 @@ class LlamaCppBackend:
self._is_vision: bool = False
self._healthy = False
self._lock = threading.Lock()
- self._chat_template: Optional[str] = None
atexit.register(self._cleanup)
@@ -218,13 +217,6 @@ class LlamaCppBackend:
self._healthy = True
- # Read chat template from local GGUF metadata (skip in HF mode —
- # llama-server handles template application internally)
- if gguf_path:
- self._chat_template = self._read_gguf_chat_template(gguf_path)
- else:
- self._chat_template = None
-
logger.info(
f"llama-server ready on port {self._port} "
f"for model '{model_identifier}'"
@@ -243,7 +235,6 @@ class LlamaCppBackend:
self._is_vision = False
self._port = None
self._healthy = False
- self._chat_template = None
return True
def _kill_process(self):
@@ -298,92 +289,49 @@ class LlamaCppBackend:
logger.error(f"llama-server health check timed out after {timeout}s")
return False
- # ── Chat template ─────────────────────────────────────────────
+ # ── Message building (OpenAI format) ──────────────────────────
@staticmethod
- def _read_gguf_chat_template(gguf_path: str) -> Optional[str]:
+ def _build_openai_messages(
+ messages: list[dict],
+ image_b64: Optional[str] = None,
+ ) -> list[dict]:
"""
- Try to read the chat_template from GGUF file metadata.
+ Build OpenAI-format messages, optionally injecting an image_url
+ content part into the last user message for vision models.
- Uses the gguf Python library if available.
- Returns the Jinja2 template string, or None.
+ If no image is provided, returns messages as-is.
"""
- try:
- from gguf import GGUFReader
+ if not image_b64:
+ return messages
- reader = GGUFReader(gguf_path)
- for field_name in reader.fields:
- if field_name == "tokenizer.chat_template":
- field = reader.fields[field_name]
- # Field data is an array of bytes
- template_bytes = bytes(field.parts[field.data[0]])
- template = template_bytes.decode("utf-8")
- logger.info(f"Read chat template from GGUF metadata ({len(template)} chars)")
- return template
- except ImportError:
- logger.debug("gguf library not available, cannot read chat template from GGUF metadata")
- except Exception as e:
- logger.warning(f"Could not read chat template from GGUF: {e}")
+ # Find the last user message and convert to multimodal content parts
+ result = [msg.copy() for msg in messages]
+ last_user_idx = None
+ for i, msg in enumerate(result):
+ if msg["role"] == "user":
+ last_user_idx = i
- return None
+ if last_user_idx is not None:
+ text_content = result[last_user_idx].get("content", "")
+ result[last_user_idx]["content"] = [
+ {"type": "text", "text": text_content},
+ {
+ "type": "image_url",
+ "image_url": {
+ "url": f"data:image/png;base64,{image_b64}",
+ },
+ },
+ ]
- def format_prompt(self, messages: list[dict], system_prompt: str = "") -> str:
- """
- Format chat messages into a raw prompt string for /v1/completions.
-
- Attempts to:
- 1. Render the GGUF's embedded chat_template with Jinja2
- 2. Fallback to ChatML format
- """
- # Build full message list with system prompt
- full_messages = []
- if system_prompt:
- full_messages.append({"role": "system", "content": system_prompt})
- full_messages.extend(messages)
-
- # Try Jinja2 rendering if we have a template
- if self._chat_template:
- try:
- return self._render_jinja_template(full_messages)
- except Exception as e:
- logger.warning(f"Jinja2 template rendering failed, falling back to ChatML: {e}")
-
- # Fallback: ChatML format
- return self._format_chatml(full_messages)
-
- def _render_jinja_template(self, messages: list[dict]) -> str:
- """Render messages using the GGUF's Jinja2 chat template."""
- from jinja2 import BaseLoader, Environment
-
- env = Environment(loader=BaseLoader(), keep_trailing_newline=True)
- # Add common template globals
- env.globals["raise_exception"] = lambda msg: (_ for _ in ()).throw(ValueError(msg))
-
- template = env.from_string(self._chat_template)
- rendered = template.render(
- messages=messages,
- add_generation_prompt=True,
- bos_token="",
- eos_token="",
- )
- return rendered
-
- @staticmethod
- def _format_chatml(messages: list[dict]) -> str:
- """Format messages using ChatML template (universal fallback)."""
- parts = []
- for msg in messages:
- role = msg.get("role", "user")
- content = msg.get("content", "")
- parts.append(f"<|im_start|>{role}\n{content}<|im_end|>")
- parts.append("<|im_start|>assistant")
- return "\n".join(parts) + "\n"
+ return result
# ── Generation (proxy to llama-server) ────────────────────────
def generate_chat_completion(
self,
- prompt: str,
+ messages: list[dict],
+ image_b64: Optional[str] = None,
temperature: float = 0.7,
top_p: float = 0.9,
top_k: int = 40,
@@ -394,30 +342,32 @@ class LlamaCppBackend:
cancel_event: Optional[threading.Event] = None,
) -> Generator[str, None, None]:
"""
- Send a completion request to llama-server and stream tokens back.
+ Send a chat completion request to llama-server and stream tokens back.
- Uses /v1/completions (NOT /v1/chat/completions) so we control
- the prompt format entirely.
+ Uses /v1/chat/completions — llama-server handles chat template
+ application and vision (multimodal image_url parts) natively.
Yields cumulative text (matching InferenceBackend's convention).
"""
if not self.is_loaded:
raise RuntimeError("llama-server is not loaded")
+ openai_messages = self._build_openai_messages(messages, image_b64)
+
payload = {
- "prompt": prompt,
+ "messages": openai_messages,
"stream": True,
"temperature": temperature,
"top_p": top_p,
"top_k": top_k if top_k >= 0 else 0,
"min_p": min_p,
- "n_predict": max_tokens,
+ "max_tokens": max_tokens,
"repeat_penalty": repetition_penalty,
}
if stop:
payload["stop"] = stop
- url = f"{self.base_url}/v1/completions"
+ url = f"{self.base_url}/v1/chat/completions"
cumulative = ""
try:
@@ -450,7 +400,8 @@ class LlamaCppBackend:
data = json.loads(line[6:])
choices = data.get("choices", [])
if choices:
- token = choices[0].get("text", "")
+ delta = choices[0].get("delta", {})
+ token = delta.get("content", "")
if token:
cumulative += token
yield cumulative
diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py
index b7a7a33b43..bc450add0d 100644
--- a/studio/backend/routes/inference.py
+++ b/studio/backend/routes/inference.py
@@ -438,7 +438,7 @@ async def openai_chat_completions(payload: ChatCompletionRequest, request: Reque
detail="At least one non-system message is required.",
)
- # ── GGUF path: format prompt → proxy to llama-server ──────
+ # ── GGUF path: proxy to llama-server /v1/chat/completions ──
if using_gguf:
# Reject images if this GGUF model doesn't support vision
image_b64 = extracted_image_b64 or payload.image_base64
@@ -448,7 +448,12 @@ async def openai_chat_completions(payload: ChatCompletionRequest, request: Reque
detail="Image provided but current GGUF model does not support vision.",
)
- prompt = llama_backend.format_prompt(chat_messages, system_prompt)
+ # Build message list with system prompt prepended
+ gguf_messages = []
+ if system_prompt:
+ gguf_messages.append({"role": "system", "content": system_prompt})
+ gguf_messages.extend(chat_messages)
+
cancel_event = threading.Event()
completion_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
@@ -456,7 +461,8 @@ async def openai_chat_completions(payload: ChatCompletionRequest, request: Reque
def gguf_generate():
return llama_backend.generate_chat_completion(
- prompt=prompt,
+ messages=gguf_messages,
+ image_b64=image_b64,
temperature=payload.temperature,
top_p=payload.top_p,
top_k=payload.top_k,
From 9e280eb105139ed79b017a1499987de1c97b3d8d Mon Sep 17 00:00:00 2001
From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com>
Date: Wed, 25 Feb 2026 03:30:54 +0400
Subject: [PATCH 08/12] Fix GGUF export cwd confusion: remove os.chdir, use
absolute paths
Remove os.chdir(save_directory) from export.py which was causing all of
unsloth-zoo's relative-path internals (check_llama_cpp, use_local_gguf,
_download_convert_hf_to_gguf) to resolve against the export directory
instead of the repo root. This caused llama.cpp to be cloned inside each
export dir and destroyed the repo root's llama-server build on cleanup.
Now passes absolute paths to save_pretrained_gguf so unsloth resolves
llama.cpp from the repo root where setup.sh already built it.
Also builds llama-quantize in setup.sh (needed by unsloth-zoo's export
pipeline) and symlinks it to llama.cpp root for check_llama_cpp().
---
setup.sh | 20 +++++++--
studio/backend/core/export/export.py | 62 ++++++++++------------------
2 files changed, 38 insertions(+), 44 deletions(-)
diff --git a/setup.sh b/setup.sh
index 36165dac65..0d8821d50a 100755
--- a/setup.sh
+++ b/setup.sh
@@ -206,10 +206,11 @@ else
fi
fi
-# ── 8. Build llama-server for GGUF inference ──
+# ── 8. Build llama.cpp binaries for GGUF inference + export ──
# Builds in-tree at $REPO/llama.cpp/. This directory is shared with
-# unsloth-zoo's GGUF export pipeline — if converter/quantize are missing,
-# unsloth-zoo will rebuild them on first export. We only build llama-server here.
+# unsloth-zoo's GGUF export pipeline. We build:
+# - llama-server: for GGUF model inference
+# - llama-quantize: for GGUF export quantization (symlinked to root for check_llama_cpp())
LLAMA_SERVER_BIN="$SCRIPT_DIR/llama.cpp/build/bin/llama-server"
if [ -f "$LLAMA_SERVER_BIN" ]; then
echo ""
@@ -272,12 +273,25 @@ else
run_quiet "build llama-server" cmake --build "$LLAMA_CPP_DIR/build" --config Release --target llama-server -j"$NCPU" || BUILD_OK=false
fi
+ # Also build llama-quantize (needed by unsloth-zoo's GGUF export pipeline)
+ if [ "$BUILD_OK" = true ]; then
+ run_quiet "build llama-quantize" cmake --build "$LLAMA_CPP_DIR/build" --config Release --target llama-quantize -j"$NCPU" || true
+ # Symlink to llama.cpp root — check_llama_cpp() looks for the binary there
+ QUANTIZE_BIN="$LLAMA_CPP_DIR/build/bin/llama-quantize"
+ if [ -f "$QUANTIZE_BIN" ]; then
+ ln -sf build/bin/llama-quantize "$LLAMA_CPP_DIR/llama-quantize"
+ fi
+ fi
+
if [ "$BUILD_OK" = true ]; then
if [ -f "$LLAMA_SERVER_BIN" ]; then
echo "✅ llama-server built at $LLAMA_SERVER_BIN"
else
echo "⚠️ llama-server binary not found after build — GGUF inference won't be available"
fi
+ if [ -f "$LLAMA_CPP_DIR/llama-quantize" ]; then
+ echo "✅ llama-quantize available for GGUF export"
+ fi
else
echo "⚠️ llama-server build failed — GGUF inference won't be available, but everything else works"
fi
diff --git a/studio/backend/core/export/export.py b/studio/backend/core/export/export.py
index da5b11c60d..865900cb65 100644
--- a/studio/backend/core/export/export.py
+++ b/studio/backend/core/export/export.py
@@ -378,53 +378,33 @@ class ExportBackend:
# Save locally if requested
if save_directory:
- logger.info(f"Saving GGUF model locally to: {save_directory}")
+ # Resolve to absolute path so unsloth's relative-path internals
+ # (check_llama_cpp, use_local_gguf, _download_convert_hf_to_gguf)
+ # all resolve against the repo root cwd, NOT the export directory.
+ abs_save_dir = os.path.abspath(save_directory)
+ logger.info(f"Saving GGUF model locally to: {abs_save_dir}")
# Create the directory if it doesn't exist
- os.makedirs(save_directory, exist_ok=True)
+ os.makedirs(abs_save_dir, exist_ok=True)
- # Get the base filename for the GGUF file
- import shutil
- original_dir = os.getcwd()
+ # On WSL, patch out sudo check before llama.cpp build
+ _apply_wsl_sudo_patch()
- try:
- # Change to target directory
- os.chdir(save_directory)
- logger.info(f"Changed directory to: {save_directory}")
+ # Enable verbose logging so subprocess errors are printed
+ os.environ["UNSLOTH_ENABLE_LOGGING"] = "1"
- # On WSL, patch out sudo check before llama.cpp build
- _apply_wsl_sudo_patch()
+ # 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)
+ # 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(
+ model_save_path,
+ self.current_tokenizer,
+ quantization_method=quant_method
+ )
- # Now save (will save in current directory)
- self.current_model.save_pretrained_gguf(
- "model", # Base filename
- self.current_tokenizer,
- quantization_method=quant_method
- )
-
- logger.info(f"GGUF model saved successfully in {save_directory}")
-
- # Check if llama.cpp directory was created here
- llama_cpp_in_target = os.path.join(save_directory, "llama.cpp")
- llama_cpp_in_original = os.path.join(original_dir, "llama.cpp")
-
- if os.path.exists(llama_cpp_in_target):
- logger.info(f"Found llama.cpp directory in {save_directory}")
-
- # Remove llama.cpp from original directory if it exists
- if os.path.exists(llama_cpp_in_original):
- logger.info(f"Removing existing llama.cpp in {original_dir}")
- shutil.rmtree(llama_cpp_in_original)
-
- # Move llama.cpp back to original directory
- logger.info(f"Moving llama.cpp to {original_dir}")
- shutil.move(llama_cpp_in_target, llama_cpp_in_original)
- logger.info(f"Successfully moved llama.cpp back to original directory")
-
- finally:
- # Always change back to original directory
- os.chdir(original_dir)
- logger.info(f"Changed back to original directory: {original_dir}")
+ logger.info(f"GGUF model saved successfully in {abs_save_dir}")
# Push to hub if requested
if push_to_hub:
From d9434fee4aab46766c8a0a1aa6a3a8dc9f09d8f0 Mon Sep 17 00:00:00 2001
From: Roland Tannous
Date: Wed, 25 Feb 2026 10:29:05 +0000
Subject: [PATCH 09/12] fix: use raw github URL for vision.py patch + add VLM
processor diagnostic logging
---
setup.sh | 4 ++++
studio/backend/core/training/trainer.py | 9 +++++++++
2 files changed, 13 insertions(+)
diff --git a/setup.sh b/setup.sh
index 0d8821d50a..a911d9ed7a 100755
--- a/setup.sh
+++ b/setup.sh
@@ -169,6 +169,10 @@ if [ "$IS_COLAB" = true ]; then
LLAMA_CPP_DST="$(pip show unsloth-zoo | grep -i '^Location:' | awk '{print $2}')/unsloth_zoo/llama_cpp.py"
curl -sSL "https://raw.githubusercontent.com/unslothai/unsloth-zoo/refs/heads/main/unsloth_zoo/llama_cpp.py" \
-o "$LLAMA_CPP_DST"
+ # Patch: override vision.py with fix from unsloth PR: https://github.com/unslothai/unsloth/pull/4091 until next pypi release
+ VISION_DST="$(pip show unsloth | grep -i '^Location:' | awk '{print $2}')/unsloth/vision.py"
+ curl -sSL "https://raw.githubusercontent.com/unslothai/unsloth/80e0108a684c882965a02a8ed851e3473c1145ab/unsloth/models/vision.py" \
+ -o "$VISION_DST"
echo " Installing studio dependencies..."
run_quiet "pip install studio" pip install -r "$SCRIPT_DIR/studio/backend/requirements/studio.txt"
echo "✅ Python dependencies installed"
diff --git a/studio/backend/core/training/trainer.py b/studio/backend/core/training/trainer.py
index 804baa33c0..c56b4a8a89 100644
--- a/studio/backend/core/training/trainer.py
+++ b/studio/backend/core/training/trainer.py
@@ -173,6 +173,15 @@ class UnslothTrainer:
token=hf_token,
)
logger.info("Loaded vision model")
+
+ # Diagnostic: check if FastVisionModel returned a real Processor or a raw tokenizer
+ from transformers import ProcessorMixin
+ tok = self.tokenizer
+ has_image_proc = isinstance(tok, ProcessorMixin) or hasattr(tok, "image_processor")
+ print(f"\n[VLM Diagnostic] FastVisionModel returned: {type(tok).__name__}")
+ print(f"[VLM Diagnostic] Is ProcessorMixin: {isinstance(tok, ProcessorMixin)}")
+ print(f"[VLM Diagnostic] Has image_processor: {hasattr(tok, 'image_processor')}")
+ print(f"[VLM Diagnostic] Usable as vision processor: {has_image_proc}\n")
else:
# Load text model - returns (model, tokenizer)
self.model, self.tokenizer = FastLanguageModel.from_pretrained(
From b0533503f2962d07bc88560867cf672fadf390a5 Mon Sep 17 00:00:00 2001
From: Roland Tannous
Date: Wed, 25 Feb 2026 11:39:17 +0000
Subject: [PATCH 10/12] added vision.py patch for vision processor from PR#260
---
setup.sh | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/setup.sh b/setup.sh
index a911d9ed7a..a6d227de2b 100755
--- a/setup.sh
+++ b/setup.sh
@@ -170,7 +170,7 @@ if [ "$IS_COLAB" = true ]; then
curl -sSL "https://raw.githubusercontent.com/unslothai/unsloth-zoo/refs/heads/main/unsloth_zoo/llama_cpp.py" \
-o "$LLAMA_CPP_DST"
# Patch: override vision.py with fix from unsloth PR: https://github.com/unslothai/unsloth/pull/4091 until next pypi release
- VISION_DST="$(pip show unsloth | grep -i '^Location:' | awk '{print $2}')/unsloth/vision.py"
+ VISION_DST="$(pip show unsloth | grep -i '^Location:' | awk '{print $2}')/unsloth/models/vision.py"
curl -sSL "https://raw.githubusercontent.com/unslothai/unsloth/80e0108a684c882965a02a8ed851e3473c1145ab/unsloth/models/vision.py" \
-o "$VISION_DST"
echo " Installing studio dependencies..."
@@ -193,6 +193,10 @@ else
LLAMA_CPP_DST="$(pip show unsloth-zoo | grep -i '^Location:' | awk '{print $2}')/unsloth_zoo/llama_cpp.py"
curl -sSL "https://raw.githubusercontent.com/unslothai/unsloth-zoo/refs/heads/main/unsloth_zoo/llama_cpp.py" \
-o "$LLAMA_CPP_DST"
+ # Patch: override vision.py with fix from unsloth PR: https://github.com/unslothai/unsloth/pull/4091 until next pypi release
+ VISION_DST="$(pip show unsloth | grep -i '^Location:' | awk '{print $2}')/unsloth/models/vision.py"
+ curl -sSL "https://raw.githubusercontent.com/unslothai/unsloth/80e0108a684c882965a02a8ed851e3473c1145ab/unsloth/models/vision.py" \
+ -o "$VISION_DST"
echo " Installing studio dependencies..."
run_quiet "pip install studio" pip install -r "$SCRIPT_DIR/studio/backend/requirements/studio.txt"
echo "✅ Python dependencies installed"
From 5c3a01899c6b967db05805b0c81917f6be10a9e9 Mon Sep 17 00:00:00 2001
From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com>
Date: Wed, 25 Feb 2026 15:47:45 +0400
Subject: [PATCH 11/12] Filter GGUF models from training page model selectors
GGUF models can't be fine-tuned, so hide them from the training/studio
page while keeping them available for inference on the chat page.
- Add "gguf" to EXCLUDED_TAGS in HF model search hook
- Filter local models with .gguf extension or -GGUF in ID
---
.../studio/sections/model-section.tsx | 21 ++++++++++++++-----
.../frontend/src/hooks/use-hf-model-search.ts | 1 +
2 files changed, 17 insertions(+), 5 deletions(-)
diff --git a/studio/frontend/src/features/studio/sections/model-section.tsx b/studio/frontend/src/features/studio/sections/model-section.tsx
index 0c4a71a8c3..5040e5bd77 100644
--- a/studio/frontend/src/features/studio/sections/model-section.tsx
+++ b/studio/frontend/src/features/studio/sections/model-section.tsx
@@ -170,14 +170,25 @@ export function ModelSection() {
return ids;
}, [hfResults, selectedModel]);
+ // Filter out GGUF models — they can't be used for training
+ const trainableLocalModels = useMemo(
+ () =>
+ localModels.filter((m) => {
+ if (m.path.endsWith(".gguf")) return false;
+ if (m.id.toLowerCase().includes("-gguf")) return false;
+ return true;
+ }),
+ [localModels],
+ );
+
const localMetaById = useMemo(() => {
const map = new Map();
- for (const model of localModels) map.set(model.id, model);
+ for (const model of trainableLocalModels) map.set(model.id, model);
return map;
- }, [localModels]);
+ }, [trainableLocalModels]);
const localResultIds = useMemo(() => {
- const ids = localModels.map((model) => model.id);
+ const ids = trainableLocalModels.map((model) => model.id);
const manual = localModelInput.trim();
if (manual && !ids.includes(manual)) {
ids.unshift(manual);
@@ -341,8 +352,8 @@ export function ModelSection() {
{localModelsError}
) : (
- {localModels.length > 0
- ? `${localModels.length} local/cached models found`
+ {trainableLocalModels.length > 0
+ ? `${trainableLocalModels.length} local/cached models found`
: "No local models found. Enter path manually."}
)}
diff --git a/studio/frontend/src/hooks/use-hf-model-search.ts b/studio/frontend/src/hooks/use-hf-model-search.ts
index 0029f13317..6ba70a4d5c 100644
--- a/studio/frontend/src/hooks/use-hf-model-search.ts
+++ b/studio/frontend/src/hooks/use-hf-model-search.ts
@@ -11,6 +11,7 @@ export interface HfModelResult {
}
const EXCLUDED_TAGS = new Set([
+ "gguf",
"gptq",
"awq",
"exl2",
From 52738383f9410edf78fb17858970f35f1ebb37dc Mon Sep 17 00:00:00 2001
From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com>
Date: Wed, 25 Feb 2026 16:00:24 +0400
Subject: [PATCH 12/12] Remove UNSLOTH_ENABLE_LOGGING from export pipeline
---
studio/backend/core/export/export.py | 3 ---
1 file changed, 3 deletions(-)
diff --git a/studio/backend/core/export/export.py b/studio/backend/core/export/export.py
index 865900cb65..dbe11ece52 100644
--- a/studio/backend/core/export/export.py
+++ b/studio/backend/core/export/export.py
@@ -390,9 +390,6 @@ class ExportBackend:
# On WSL, patch out sudo check before llama.cpp build
_apply_wsl_sudo_patch()
- # Enable verbose logging so subprocess errors are printed
- os.environ["UNSLOTH_ENABLE_LOGGING"] = "1"
-
# 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)