From 1430bbc6040ad755720632dfc342bcc1f8ee9e05 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Tue, 10 Mar 2026 17:39:35 +0000 Subject: [PATCH 01/12] fix: uncheck train_on_completions for audio models Pure audio models (orpheus, sparktts, whisper, sesame-csm) now always have trainOnCompletions auto-unchecked when selected. Gemma3n (audio_vlm) only unchecks when the dataset is audio. - Add is_audio to frontend ModelConfigResponse (backend already returns it) - Add isAudioModel state to training config store - Auto-set trainOnCompletions=false for pure audio models on model load - Auto-set trainOnCompletions=false for audio VLMs when dataset is audio - Respect manual user override via existing _trainOnCompletionsManuallySet flag --- .../src/features/training/api/models-api.ts | 2 +- .../training/stores/training-config-store.ts | 20 ++++++++++++++++++- .../src/features/training/types/config.ts | 1 + 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/studio/frontend/src/features/training/api/models-api.ts b/studio/frontend/src/features/training/api/models-api.ts index f40f29fda2..cbf203d05a 100644 --- a/studio/frontend/src/features/training/api/models-api.ts +++ b/studio/frontend/src/features/training/api/models-api.ts @@ -61,8 +61,8 @@ export interface ModelConfigResponse { model_name?: string | null; config?: BackendModelConfig | null; is_vision: boolean; + is_audio: boolean; is_lora: boolean; - is_audio?: boolean; base_model?: string | null; model_type?: "text" | "vision" | "audio" | "embeddings" | null; } diff --git a/studio/frontend/src/features/training/stores/training-config-store.ts b/studio/frontend/src/features/training/stores/training-config-store.ts index 502889ee1e..d029761f73 100644 --- a/studio/frontend/src/features/training/stores/training-config-store.ts +++ b/studio/frontend/src/features/training/stores/training-config-store.ts @@ -36,6 +36,7 @@ const initialState: TrainingConfigState = { uploadedFile: null, isCheckingVision: false, isVisionModel: false, + isAudioModel: false, isLoadingModelDefaults: false, modelDefaultsError: null, modelDefaultsAppliedFor: null, @@ -58,6 +59,7 @@ let _trainOnCompletionsManuallySet = false; const NON_PERSISTED_STATE_KEYS: ReadonlySet = new Set([ "modelType", "isCheckingVision", + "isAudioModel", "isLoadingModelDefaults", "modelDefaultsError", "modelDefaultsAppliedFor", @@ -127,6 +129,16 @@ export const useTrainingConfigStore = create()( patch.trainOnCompletions = false; } + const isAudio = !!modelDetails.is_audio; + // Pure audio model → always uncheck trainOnCompletions. + if (isAudio && !modelDetails.is_vision) { + patch.trainOnCompletions = false; + } + // Audio-capable vision model (e.g. gemma3n) + audio dataset → uncheck. + if (isAudio && modelDetails.is_vision && get().isDatasetAudio) { + patch.trainOnCompletions = false; + } + // Use backend-provided model_type when available, otherwise // infer from is_vision (temporary until backend ships model_type). const inferredModelType: ModelType = modelDetails.model_type @@ -136,6 +148,7 @@ export const useTrainingConfigStore = create()( ...patch, modelType: inferredModelType, isVisionModel: modelDetails.is_vision, + isAudioModel: isAudio, isLoadingModelDefaults: false, isCheckingVision: false, modelDefaultsError: null, @@ -194,10 +207,13 @@ export const useTrainingConfigStore = create()( isCheckingDataset: false, }; if (!_trainOnCompletionsManuallySet) { - const { isVisionModel } = get(); + const { isVisionModel, isAudioModel } = get(); if (isVisionModel && isImage) { updates.trainOnCompletions = false; } + if (isAudioModel && isAudio) { + updates.trainOnCompletions = false; + } } set(updates); }) @@ -233,6 +249,7 @@ export const useTrainingConfigStore = create()( selectedModel: null, isCheckingVision: false, isVisionModel: false, + isAudioModel: false, isDatasetAudio: false, isLoadingModelDefaults: false, modelDefaultsError: null, @@ -249,6 +266,7 @@ export const useTrainingConfigStore = create()( set({ isCheckingVision: false, isVisionModel: false, + isAudioModel: false, isDatasetAudio: false, isLoadingModelDefaults: false, modelDefaultsError: null, diff --git a/studio/frontend/src/features/training/types/config.ts b/studio/frontend/src/features/training/types/config.ts index bc8305430b..5a427dd153 100644 --- a/studio/frontend/src/features/training/types/config.ts +++ b/studio/frontend/src/features/training/types/config.ts @@ -60,6 +60,7 @@ export interface TrainingConfigState { logFrequency: number; isCheckingVision: boolean; isVisionModel: boolean; + isAudioModel: boolean; isLoadingModelDefaults: boolean; modelDefaultsError: string | null; modelDefaultsAppliedFor: string | null; From d9f2d0826765af2c6b6676c0a0b0b68c895560c9 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Tue, 10 Mar 2026 19:00:56 +0000 Subject: [PATCH 02/12] fix: reset isAudioModel on model config fetch failure Clear stale isAudioModel in the fallback path when getModelConfig fails, preventing a previously-selected audio model's flag from leaking into the next model selection. --- .../src/features/training/stores/training-config-store.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/studio/frontend/src/features/training/stores/training-config-store.ts b/studio/frontend/src/features/training/stores/training-config-store.ts index d029761f73..3a182b73ec 100644 --- a/studio/frontend/src/features/training/stores/training-config-store.ts +++ b/studio/frontend/src/features/training/stores/training-config-store.ts @@ -161,6 +161,7 @@ export const useTrainingConfigStore = create()( set({ isLoadingModelDefaults: false, + isAudioModel: false, modelDefaultsError: error instanceof Error ? error.message @@ -174,12 +175,13 @@ export const useTrainingConfigStore = create()( set({ modelType: isVision ? "vision" : "text", isVisionModel: isVision, + isAudioModel: false, isCheckingVision: false, }); }) .catch(() => { if (get().selectedModel !== modelName) return; - set({ isCheckingVision: false }); + set({ isCheckingVision: false, isAudioModel: false }); }); }); }; From 846cc2cf2a4ebac1938035a89462de7e8c7e68a0 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Tue, 10 Mar 2026 19:02:49 +0000 Subject: [PATCH 03/12] fix: always force-uncheck trainOnCompletions for pure audio models in dataset check Separate pure-audio from audio-VLM logic in runDatasetCheck so pure audio models are always forced to trainOnCompletions=false regardless of dataset type, while audio VLMs (gemma3n) only uncheck when the dataset is audio. --- .../src/features/training/stores/training-config-store.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/studio/frontend/src/features/training/stores/training-config-store.ts b/studio/frontend/src/features/training/stores/training-config-store.ts index 3a182b73ec..30d03e7fc5 100644 --- a/studio/frontend/src/features/training/stores/training-config-store.ts +++ b/studio/frontend/src/features/training/stores/training-config-store.ts @@ -213,7 +213,12 @@ export const useTrainingConfigStore = create()( if (isVisionModel && isImage) { updates.trainOnCompletions = false; } - if (isAudioModel && isAudio) { + // Pure audio model → always uncheck regardless of dataset. + if (isAudioModel && !isVisionModel) { + updates.trainOnCompletions = false; + } + // Audio-capable vision model (e.g. gemma3n) + audio dataset → uncheck. + if (isAudioModel && isVisionModel && isAudio) { updates.trainOnCompletions = false; } } From defa761fb2ec7ebb3626d6befb1abdc6c02779e5 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Tue, 10 Mar 2026 19:04:10 +0000 Subject: [PATCH 04/12] fix: download all GGUF shards for split models (e.g. 7B Q8_0) LlamaCppBackend.load_model() only downloaded the first matching GGUF file. For split models (e.g. 7B Q8_0 with 3 shards), llama-server needs all shards present. Now collects and downloads all matching files. --- studio/backend/core/inference/llama_cpp.py | 25 +++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 37a57e4c39..b394284532 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -249,17 +249,22 @@ class LlamaCppBackend: ) # Determine the filename from the variant (e.g., "Q4_K_M" -> find matching file) + # For split GGUFs (e.g., *-00001-of-00003.gguf) we must download ALL shards. gguf_filename = None + gguf_extra_shards: list[str] = [] if hf_variant: # Try common naming patterns try: from huggingface_hub import list_repo_files files = list_repo_files(hf_repo, token=hf_token) variant_lower = hf_variant.lower() - for f in files: - if f.endswith(".gguf") and variant_lower in f.lower(): - gguf_filename = f - break + matching = sorted( + f for f in files + if f.endswith(".gguf") and variant_lower in f.lower() + ) + if matching: + gguf_filename = matching[0] # first shard (or single file) + gguf_extra_shards = matching[1:] # remaining shards if split except Exception as e: logger.warning(f"Could not list repo files: {e}") @@ -269,13 +274,23 @@ class LlamaCppBackend: repo_name = hf_repo.split("/")[-1].replace("-GGUF", "") gguf_filename = f"{repo_name}-{hf_variant}.gguf" - logger.info(f"Downloading GGUF: {hf_repo}/{gguf_filename}") + logger.info(f"Downloading GGUF: {hf_repo}/{gguf_filename}" + + (f" (+{len(gguf_extra_shards)} shards)" if gguf_extra_shards else "")) try: local_path = hf_hub_download( repo_id=hf_repo, filename=gguf_filename, token=hf_token, ) + # Download remaining shards for split GGUFs — llama-server + # auto-discovers them when they are in the same directory. + for shard in gguf_extra_shards: + logger.info(f"Downloading GGUF shard: {shard}") + hf_hub_download( + repo_id=hf_repo, + filename=shard, + token=hf_token, + ) except Exception as e: raise RuntimeError( f"Failed to download GGUF file '{gguf_filename}' from {hf_repo}: {e}" From d635846b8da4809159b1f5f2a591b97bc162df6d Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Tue, 10 Mar 2026 19:13:03 +0000 Subject: [PATCH 05/12] fix: use exact variant matching and shard-prefix discovery for split GGUFs Substring matching (e.g. "Q8_0" in filename) could match superset variants like "IQ8_0", causing wrong quantizations to be downloaded. Now uses word-boundary regex for variant matching and discovers split shards by shared filename prefix rather than treating all variant matches as shards. --- studio/backend/core/inference/llama_cpp.py | 28 +++++++++++++++++----- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index b394284532..934062f56b 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -255,16 +255,32 @@ class LlamaCppBackend: if hf_variant: # Try common naming patterns try: + import re from huggingface_hub import list_repo_files files = list_repo_files(hf_repo, token=hf_token) variant_lower = hf_variant.lower() - matching = sorted( - f for f in files - if f.endswith(".gguf") and variant_lower in f.lower() + # Use word-boundary matching so "Q8_0" doesn't also + # match "IQ8_0" or other superset variant names. + boundary = re.compile( + r'(? Date: Tue, 10 Mar 2026 20:27:11 +0100 Subject: [PATCH 06/12] chore/fix(studio): add placeholder dropdowns for dataset subset and splits in disabled state --- .../studio/sections/dataset-section.tsx | 13 +++++ .../hf-dataset-subset-split-selectors.tsx | 53 +++++++++++++++++-- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/studio/frontend/src/features/studio/sections/dataset-section.tsx b/studio/frontend/src/features/studio/sections/dataset-section.tsx index c61565cbd9..7ff9e675ab 100644 --- a/studio/frontend/src/features/studio/sections/dataset-section.tsx +++ b/studio/frontend/src/features/studio/sections/dataset-section.tsx @@ -641,6 +641,19 @@ export function DatasetSection() { datasetEvalSplit={datasetEvalSplit} setDatasetEvalSplit={setDatasetEvalSplit} /> + ) : !selectedDatasetName ? ( + ) : datasetSource === "upload" && selectedLocalDataset ? (
diff --git a/studio/frontend/src/features/training/components/hf-dataset-subset-split-selectors.tsx b/studio/frontend/src/features/training/components/hf-dataset-subset-split-selectors.tsx index 0dab4685be..b177a41999 100644 --- a/studio/frontend/src/features/training/components/hf-dataset-subset-split-selectors.tsx +++ b/studio/frontend/src/features/training/components/hf-dataset-subset-split-selectors.tsx @@ -56,6 +56,8 @@ export function HfDatasetSubsetSplitSelectors({ } = useHfDatasetSplits(enabled ? datasetName : null, datasetSubset, { accessToken, }); + const showPlaceholderDropdowns = + variant === "studio" && !enabled && !datasetName; // Auto-select subset and split in one pass to avoid racing effects useEffect(() => { @@ -83,12 +85,48 @@ export function HfDatasetSubsetSplitSelectors({ setDatasetSplit, ]); - if (!enabled || !datasetName) return null; - const showDropdowns = !isLoading && !error && hfSubsets.length > 0; return ( <> + {showPlaceholderDropdowns && ( + <> +
+ + +
+ + + )} + {isLoading && (
@@ -217,8 +260,9 @@ function SelectorDropdown({ onChange(v === "_none" ? null : v)} + disabled={disabled} > From b84202e8db2f0f2e91ab28e2c0ed5a5d0d1e15a0 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Tue, 10 Mar 2026 19:28:26 +0000 Subject: [PATCH 07/12] fix: restrict shard siblings to exact basename and total count startswith(prefix) could match unrelated split variants whose names extend the selected file's prefix (e.g. model-Q8_0-v2-00001-of-...). Now builds an exact regex from the chosen file's base prefix and shard total so only true siblings are downloaded. --- studio/backend/core/inference/llama_cpp.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 934062f56b..dac8d380c3 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -271,15 +271,19 @@ class LlamaCppBackend: if gguf_files: gguf_filename = gguf_files[0] # For split GGUFs (e.g. model-Q8_0-00001-of-00003.gguf) - # discover siblings by shared prefix instead of - # trusting all variant matches to be shards. - shard_pat = re.compile(r'^(.*)-\d{5}-of-\d{5}\.gguf$') + # discover siblings by exact basename + total match + # so "model-Q8_0-v2-*" isn't pulled in as a sibling. + shard_pat = re.compile(r'^(.*)-\d{5}-of-(\d{5})\.gguf$') m = shard_pat.match(gguf_filename) if m: prefix = m.group(1) + total = m.group(2) + sibling_pat = re.compile( + r'^' + re.escape(prefix) + r'-\d{5}-of-' + re.escape(total) + r'\.gguf$' + ) gguf_extra_shards = [ f for f in gguf_files[1:] - if f.startswith(prefix + "-") and shard_pat.match(f) + if sibling_pat.match(f) ] except Exception as e: logger.warning(f"Could not list repo files: {e}") From 970a029108d2606c9005becf958909675f97f9cc Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Tue, 10 Mar 2026 19:50:53 +0000 Subject: [PATCH 08/12] fix: stream HF dataset when manual slice is specified Instead of downloading the full dataset and then slicing, use streaming mode to only fetch the rows needed (up to slice_end + 1) when a manual dataset slice is configured. --- studio/backend/core/training/trainer.py | 27 ++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/studio/backend/core/training/trainer.py b/studio/backend/core/training/trainer.py index 31e05d0bda..0c3aaa6aee 100644 --- a/studio/backend/core/training/trainer.py +++ b/studio/backend/core/training/trainer.py @@ -1866,10 +1866,31 @@ class UnslothTrainer: elif dataset_source: # Load from Hugging Face - load_kwargs = {"path": dataset_source, "split": train_split or "train"} + split_name = train_split or "train" + load_kwargs = {"path": dataset_source, "split": split_name} if subset: load_kwargs["name"] = subset - dataset = load_dataset(**load_kwargs) + + if dataset_slice_end is not None: + # Manual slice — stream only the rows we need instead of + # downloading the entire dataset. + rows_to_stream = dataset_slice_end + 1 + print( + f"[dataset-slice] Manual slice specified " + f"(start={dataset_slice_start}, end={dataset_slice_end}), " + f"streaming {rows_to_stream} rows\n" + ) + stream = load_dataset(**load_kwargs, streaming=True) + dataset = Dataset.from_list(list(stream.take(rows_to_stream))) + print( + f"[dataset-slice] Downloaded {len(dataset)} rows " + f"(requested {rows_to_stream})\n" + ) + self._update_progress( + status_message=f"Streamed {len(dataset)} rows from HuggingFace" + ) + else: + dataset = load_dataset(**load_kwargs) # Check if stopped during dataset loading if self.should_stop: @@ -1877,7 +1898,7 @@ class UnslothTrainer: return None self._update_progress(status_message=f"Loaded dataset from HuggingFace: {dataset_source}") - print(f"Loaded dataset from Hugging Face: {dataset_source}\n") + print(f"Loaded dataset from Hugging Face: {dataset_source} ({len(dataset)} rows)\n") # Resolve eval split from a separate HF split (explicit or auto-detected) if eval_enabled: From 226f251589f9efa41afbcb7685b1f9b4400ce0a9 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Tue, 10 Mar 2026 20:12:42 +0000 Subject: [PATCH 09/12] fix: guard against negative dataset_slice_end before streaming Fall back to full download when dataset_slice_end is negative, avoiding an empty stream.take(0) that would produce a broken dataset. --- studio/backend/core/training/trainer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/studio/backend/core/training/trainer.py b/studio/backend/core/training/trainer.py index 0c3aaa6aee..c72a54af00 100644 --- a/studio/backend/core/training/trainer.py +++ b/studio/backend/core/training/trainer.py @@ -1871,7 +1871,7 @@ class UnslothTrainer: if subset: load_kwargs["name"] = subset - if dataset_slice_end is not None: + if dataset_slice_end is not None and dataset_slice_end >= 0: # Manual slice — stream only the rows we need instead of # downloading the entire dataset. rows_to_stream = dataset_slice_end + 1 From 5dcbf86d0988a0cdbfefa13fb41d65d37789e37a Mon Sep 17 00:00:00 2001 From: imagineer99 Date: Tue, 10 Mar 2026 20:13:34 +0000 Subject: [PATCH 10/12] fix: reject negative manual dataset slices Prevent negative Train Split Start/End values in the dataset advanced UI and sanitize payload mapping so negative slice values are never sent to the backend. Made-with: Cursor --- .../studio/sections/dataset-section.tsx | 17 +++++++++++++++-- .../src/features/training/api/mappers.ts | 2 +- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/studio/frontend/src/features/studio/sections/dataset-section.tsx b/studio/frontend/src/features/studio/sections/dataset-section.tsx index 7ff9e675ab..4f20133b5c 100644 --- a/studio/frontend/src/features/studio/sections/dataset-section.tsx +++ b/studio/frontend/src/features/studio/sections/dataset-section.tsx @@ -89,6 +89,13 @@ function formatUpdatedDate(timestamp: number | null): string { return new Date(timestamp * 1000).toLocaleDateString(); } +function normalizeSliceInput(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + if (!/^\d+$/.test(trimmed)) return null; + return trimmed; +} + export function DatasetSection() { const { dataset, @@ -783,11 +790,14 @@ export function DatasetSection() { - setDatasetSliceStart(e.target.value || null) + setDatasetSliceStart(normalizeSliceInput(e.target.value)) } />
@@ -815,11 +825,14 @@ export function DatasetSection() { - setDatasetSliceEnd(e.target.value || null) + setDatasetSliceEnd(normalizeSliceInput(e.target.value)) } />
diff --git a/studio/frontend/src/features/training/api/mappers.ts b/studio/frontend/src/features/training/api/mappers.ts index fed17a538c..53869fdd59 100644 --- a/studio/frontend/src/features/training/api/mappers.ts +++ b/studio/frontend/src/features/training/api/mappers.ts @@ -12,7 +12,7 @@ function parseSliceValue(value: string | null): number | null { const trimmed = value.trim(); if (!trimmed) return null; const num = Number(trimmed); - if (!Number.isFinite(num) || !Number.isInteger(num)) return null; + if (!Number.isFinite(num) || !Number.isInteger(num) || num < 0) return null; return num; } From 21ef22a9ff6162e1c5b33f49ada76262d7b9dc81 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Tue, 10 Mar 2026 20:21:23 +0000 Subject: [PATCH 11/12] fix: skip streaming when dataset_slice_start > dataset_slice_end Prevents training on the wrong row range when start exceeds end by falling back to full download where existing clamping handles it. --- studio/backend/core/training/trainer.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/studio/backend/core/training/trainer.py b/studio/backend/core/training/trainer.py index c72a54af00..2e37cad189 100644 --- a/studio/backend/core/training/trainer.py +++ b/studio/backend/core/training/trainer.py @@ -1871,7 +1871,10 @@ class UnslothTrainer: if subset: load_kwargs["name"] = subset - if dataset_slice_end is not None and dataset_slice_end >= 0: + _slice_start = dataset_slice_start or 0 + if (dataset_slice_end is not None + and dataset_slice_end >= 0 + and dataset_slice_end >= _slice_start): # Manual slice — stream only the rows we need instead of # downloading the entire dataset. rows_to_stream = dataset_slice_end + 1 From d572c43814e240fd857ae75d657f4bac98fec1c1 Mon Sep 17 00:00:00 2001 From: imagineer99 Date: Tue, 10 Mar 2026 20:57:12 +0000 Subject: [PATCH 12/12] fix: increase tooltip z-index to appear above dropdowns --- studio/frontend/src/components/ui/tooltip.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/studio/frontend/src/components/ui/tooltip.tsx b/studio/frontend/src/components/ui/tooltip.tsx index 94fba54cc2..30eb1e7b80 100644 --- a/studio/frontend/src/components/ui/tooltip.tsx +++ b/studio/frontend/src/components/ui/tooltip.tsx @@ -1,6 +1,6 @@ -// SPDX-License-Identifier: AGPL-3.0-only - See /studio/LICENSE.AGPL-3.0 -// Copyright © 2025 Unsloth AI - +// SPDX-License-Identifier: AGPL-3.0-only - See /studio/LICENSE.AGPL-3.0 +// Copyright © 2025 Unsloth AI + import { Tooltip as TooltipPrimitive } from "radix-ui"; import type * as React from "react"; @@ -46,14 +46,14 @@ function TooltipContent({ {children} - + );