From 71d698d182f12e6c198429dcccdad0d11a985352 Mon Sep 17 00:00:00 2001 From: imagineer99 Date: Mon, 23 Feb 2026 15:44:06 +0000 Subject: [PATCH 01/25] feat: sort and filter dataset search results by model type relevance --- .../components/steps/dataset-step.tsx | 3 + .../studio/sections/dataset-section.tsx | 3 + .../src/hooks/use-hf-dataset-search.ts | 194 +++++++++++++++++- 3 files changed, 195 insertions(+), 5 deletions(-) diff --git a/studio/frontend/src/features/onboarding/components/steps/dataset-step.tsx b/studio/frontend/src/features/onboarding/components/steps/dataset-step.tsx index 484b10ddb4..bdb2d8e69d 100644 --- a/studio/frontend/src/features/onboarding/components/steps/dataset-step.tsx +++ b/studio/frontend/src/features/onboarding/components/steps/dataset-step.tsx @@ -77,6 +77,7 @@ export function DatasetStep() { setDatasetSplit, uploadedFile, setUploadedFile, + modelType, } = useTrainingConfigStore( useShallow((s) => ({ hfToken: s.hfToken, @@ -93,6 +94,7 @@ export function DatasetStep() { setDatasetSplit: s.setDatasetSplit, uploadedFile: s.uploadedFile, setUploadedFile: s.setUploadedFile, + modelType: s.modelType, })), ); @@ -106,6 +108,7 @@ export function DatasetStep() { fetchMore, error: hfSearchError, } = useHfDatasetSearch(debouncedQuery, { + modelType, accessToken: hfToken || undefined, }); diff --git a/studio/frontend/src/features/studio/sections/dataset-section.tsx b/studio/frontend/src/features/studio/sections/dataset-section.tsx index d142ae5f54..320c248e22 100644 --- a/studio/frontend/src/features/studio/sections/dataset-section.tsx +++ b/studio/frontend/src/features/studio/sections/dataset-section.tsx @@ -67,6 +67,7 @@ export function DatasetSection() { datasetSplit, setDatasetSplit, hfToken, + modelType, } = useTrainingConfigStore( useShallow((s) => ({ dataset: s.dataset, @@ -78,6 +79,7 @@ export function DatasetSection() { datasetSplit: s.datasetSplit, setDatasetSplit: s.setDatasetSplit, hfToken: s.hfToken, + modelType: s.modelType, })), ); @@ -105,6 +107,7 @@ export function DatasetSection() { fetchMore, error: hfSearchError, } = useHfDatasetSearch(debouncedQuery, { + modelType, accessToken: hfToken || undefined, }); diff --git a/studio/frontend/src/hooks/use-hf-dataset-search.ts b/studio/frontend/src/hooks/use-hf-dataset-search.ts index ac94a427a6..9236a60486 100644 --- a/studio/frontend/src/hooks/use-hf-dataset-search.ts +++ b/studio/frontend/src/hooks/use-hf-dataset-search.ts @@ -1,5 +1,6 @@ import { listDatasets } from "@huggingface/hub"; -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; +import type { ModelType } from "@/types/training"; import { useHfPaginatedSearch } from "./use-hf-paginated-search"; interface DatasetInfoSplit { @@ -47,6 +48,7 @@ export interface HfDatasetResult { likes: number; totalExamples?: number; sizeCategory?: string; + taskCategories: string[]; } function mapDataset(raw: unknown): HfDatasetResult { @@ -54,32 +56,214 @@ function mapDataset(raw: unknown): HfDatasetResult { name: string; downloads: number; likes: number; + tags?: string[]; cardData?: unknown; }; const card = ds.cardData as CardDataWithInfo | undefined; + const taskCategories = (ds.tags ?? []) + .filter((t) => t.startsWith("task_categories:")) + .map((t) => t.slice("task_categories:".length)); return { id: ds.name, downloads: ds.downloads, likes: ds.likes, totalExamples: extractTotalExamples(card), sizeCategory: card?.size_categories?.[0], + taskCategories, }; } +function withTrendingSort( + input: Parameters[0], + init?: Parameters[1], +): ReturnType { + const rawUrl = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + const url = new URL(rawUrl); + + if (!url.searchParams.has("sort")) { + url.searchParams.set("sort", "trendingScore"); + } + if (!url.searchParams.has("direction")) { + url.searchParams.set("direction", "-1"); + } + + return fetch(url, init); +} + +const RELEVANT_TASK_CATEGORIES: Record> = { + text: new Set([ + "text-generation", + "text2text-generation", + "question-answering", + "summarization", + "conversational", + ]), + vision: new Set([ + "image-text-to-text", + "visual-question-answering", + "image-to-text", + "image-captioning", + ]), + tts: new Set([ + "text-to-speech", + "text-to-audio", + "automatic-speech-recognition", + ]), + embeddings: new Set([ + "feature-extraction", + "sentence-similarity", + "text-retrieval", + ]), +}; + +const INCOMPATIBLE_TASK_CATEGORIES: Record> = { + text: new Set([ + "text-to-3d", + "image-to-3d", + "text-to-image", + "image-to-image", + "image-to-video", + "text-to-video", + "image-classification", + "image-feature-extraction", + "image-text-to-image", + "zero-shot-image-classification", + "keypoint-detection", + "object-detection", + "image-segmentation", + "depth-estimation", + "text-to-speech", + "text-to-audio", + "audio-classification", + "audio-to-audio", + "automatic-speech-recognition", + "video-classification", + "robotics", + "reinforcement-learning", + "tabular-classification", + "tabular-regression", + "time-series-forecasting", + "visual-document-retrieval", + ]), + vision: new Set([ + "text-to-3d", + "image-to-3d", + "text-to-speech", + "text-to-audio", + "audio-classification", + "audio-to-audio", + "automatic-speech-recognition", + "robotics", + "reinforcement-learning", + "tabular-classification", + "tabular-regression", + "time-series-forecasting", + ]), + tts: new Set([ + "text-to-3d", + "image-to-3d", + "text-to-image", + "image-to-image", + "image-to-video", + "text-to-video", + "image-classification", + "image-feature-extraction", + "image-text-to-image", + "zero-shot-image-classification", + "keypoint-detection", + "object-detection", + "image-segmentation", + "depth-estimation", + "video-classification", + "robotics", + "reinforcement-learning", + "tabular-classification", + "tabular-regression", + "time-series-forecasting", + "visual-document-retrieval", + ]), + embeddings: new Set([ + "text-to-3d", + "image-to-3d", + "text-to-image", + "image-to-image", + "image-to-video", + "text-to-video", + "image-classification", + "image-feature-extraction", + "image-text-to-image", + "zero-shot-image-classification", + "keypoint-detection", + "object-detection", + "image-segmentation", + "depth-estimation", + "text-to-speech", + "text-to-audio", + "audio-classification", + "audio-to-audio", + "automatic-speech-recognition", + "video-classification", + "robotics", + "reinforcement-learning", + "tabular-classification", + "tabular-regression", + "time-series-forecasting", + "visual-document-retrieval", + ]), +}; + +function classifyDataset( + dataset: HfDatasetResult, + modelType: ModelType, +): -1 | 0 | 1 { + const { taskCategories } = dataset; + if (taskCategories.length === 0) return 0; + + const relevant = RELEVANT_TASK_CATEGORIES[modelType]; + const incompatible = INCOMPATIBLE_TASK_CATEGORIES[modelType]; + + if (taskCategories.some((t) => relevant.has(t))) return 1; + if (taskCategories.every((t) => incompatible.has(t))) return -1; + return 0; +} + export function useHfDatasetSearch( query: string, - options?: { accessToken?: string }, + options?: { modelType?: ModelType | null; accessToken?: string }, ) { - const { accessToken } = options ?? {}; + const { modelType, accessToken } = options ?? {}; const createIter = useCallback( () => listDatasets({ search: query.trim() ? { query } : {}, - additionalFields: ["cardData"], + additionalFields: ["cardData", "tags"], + fetch: withTrendingSort, ...(accessToken ? { credentials: { accessToken } } : {}), }) as AsyncGenerator, [query, accessToken], ); - return useHfPaginatedSearch(createIter, mapDataset); + const search = useHfPaginatedSearch(createIter, mapDataset); + + const results = useMemo(() => { + if (!modelType) return search.results; + + const boosted: HfDatasetResult[] = []; + const neutral: HfDatasetResult[] = []; + + for (const ds of search.results) { + const rank = classifyDataset(ds, modelType); + if (rank === 1) boosted.push(ds); + else if (rank !== -1) neutral.push(ds); + } + + return [...boosted, ...neutral]; + }, [search.results, modelType]); + + return { ...search, results }; } From 32d5cd71981a90ce9aed4bc2181fa6dddae2c616 Mon Sep 17 00:00:00 2001 From: samit Date: Mon, 23 Feb 2026 17:37:23 -0800 Subject: [PATCH 02/25] resolved unbound variable error --- setup.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.sh b/setup.sh index 1f55e141fd..df5b0fddcd 100755 --- a/setup.sh +++ b/setup.sh @@ -69,13 +69,14 @@ if [ "$NEED_NODE" = true ]; then # Load nvm (source ~/.bashrc won't work inside a script) export NVM_DIR="$HOME/.nvm" + set +u [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # ── 3. Install Node LTS ── echo "Installing Node LTS..." run_quiet "nvm install" nvm install --lts nvm use --lts > /dev/null 2>&1 - + set -u # ── 4. Verify versions ── NODE_MAJOR=$(node -v | sed 's/v//' | cut -d. -f1) NPM_MAJOR=$(npm -v | cut -d. -f1) From a3daae1c40f2cb59e3866ea21ac5caaad4100ac2 Mon Sep 17 00:00:00 2001 From: Leo Borcherding Date: Tue, 24 Feb 2026 14:37:00 -0600 Subject: [PATCH 03/25] fix: replace datetime.UTC with timezone.utc for Python 3.9+ compatibility - Replace datetime.UTC with datetime.timezone.utc in authentication.py and storage.py - Fixes ImportError on Python versions < 3.11 - timezone.utc works on Python 3.9+ Resolves #237 --- studio/backend/auth/authentication.py | 6 +++--- studio/backend/auth/storage.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/studio/backend/auth/authentication.py b/studio/backend/auth/authentication.py index 6ea668db3e..e725834630 100644 --- a/studio/backend/auth/authentication.py +++ b/studio/backend/auth/authentication.py @@ -1,5 +1,5 @@ import secrets -from datetime import UTC, datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Optional from fastapi import Depends, HTTPException, status @@ -34,7 +34,7 @@ def create_access_token( Tokens are valid across restarts because SECRET_KEY is stored in SQLite. """ to_encode = {"sub": subject} - expire = datetime.now(UTC) + ( + expire = datetime.now(timezone.utc) + ( expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) ) to_encode.update({"exp": expire}) @@ -48,7 +48,7 @@ def create_refresh_token(subject: str) -> str: Refresh tokens are opaque (not JWTs) and expire after REFRESH_TOKEN_EXPIRE_DAYS. """ token = secrets.token_urlsafe(48) - expires_at = datetime.now(UTC) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS) + expires_at = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS) save_refresh_token(token, subject, expires_at.isoformat()) return token diff --git a/studio/backend/auth/storage.py b/studio/backend/auth/storage.py index faea6266e3..e5a486bca2 100644 --- a/studio/backend/auth/storage.py +++ b/studio/backend/auth/storage.py @@ -3,7 +3,7 @@ SQLite storage for authentication data (user credentials + JWT secret). """ import hashlib import sqlite3 -from datetime import UTC, datetime +from datetime import datetime, timezone from pathlib import Path from typing import Optional, Tuple @@ -218,7 +218,7 @@ def verify_refresh_token(token: str) -> Optional[str]: # Clean up any expired tokens while we're here conn.execute( "DELETE FROM refresh_tokens WHERE expires_at < ?", - (datetime.now(UTC).isoformat(),), + (datetime.now(timezone.utc).isoformat(),), ) conn.commit() @@ -235,7 +235,7 @@ def verify_refresh_token(token: str) -> Optional[str]: # Check expiry expires_at = datetime.fromisoformat(row["expires_at"]) - if datetime.now(UTC) > expires_at: + if datetime.now(timezone.utc) > expires_at: conn.execute("DELETE FROM refresh_tokens WHERE id = ?", (row["id"],)) conn.commit() return None From dbf5acf486ec5d77d3e37958cbdf60411ad75660 Mon Sep 17 00:00:00 2001 From: imagineer99 Date: Wed, 25 Feb 2026 03:00:40 +0000 Subject: [PATCH 04/25] feat: filter pretraining datasets from search results --- .../src/hooks/use-hf-dataset-search.ts | 100 ++++++++++-------- 1 file changed, 58 insertions(+), 42 deletions(-) diff --git a/studio/frontend/src/hooks/use-hf-dataset-search.ts b/studio/frontend/src/hooks/use-hf-dataset-search.ts index 9236a60486..b00dcee1ed 100644 --- a/studio/frontend/src/hooks/use-hf-dataset-search.ts +++ b/studio/frontend/src/hooks/use-hf-dataset-search.ts @@ -49,6 +49,7 @@ export interface HfDatasetResult { totalExamples?: number; sizeCategory?: string; taskCategories: string[]; + plainTags: string[]; } function mapDataset(raw: unknown): HfDatasetResult { @@ -60,9 +61,11 @@ function mapDataset(raw: unknown): HfDatasetResult { cardData?: unknown; }; const card = ds.cardData as CardDataWithInfo | undefined; - const taskCategories = (ds.tags ?? []) + const tags = ds.tags ?? []; + const taskCategories = tags .filter((t) => t.startsWith("task_categories:")) .map((t) => t.slice("task_categories:".length)); + const plainTags = tags.filter((t) => !t.includes(":")); return { id: ds.name, downloads: ds.downloads, @@ -70,6 +73,7 @@ function mapDataset(raw: unknown): HfDatasetResult { totalExamples: extractTotalExamples(card), sizeCategory: card?.size_categories?.[0], taskCategories, + plainTags, }; } @@ -95,7 +99,9 @@ function withTrendingSort( return fetch(url, init); } -const RELEVANT_TASK_CATEGORIES: Record> = { +type DatasetRelevance = "incompatible" | "neutral" | "boosted"; + +const BOOSTED_TASK_CATEGORIES: Record> = { text: new Set([ "text-generation", "text2text-generation", @@ -121,10 +127,28 @@ const RELEVANT_TASK_CATEGORIES: Record> = { ]), }; -const INCOMPATIBLE_TASK_CATEGORIES: Record> = { +const INCOMPATIBLE_TASKS_ALL_MODELS = new Set([ + "text-to-3d", + "image-to-3d", + "robotics", + "reinforcement-learning", + "tabular-classification", + "tabular-regression", + "time-series-forecasting", +]); + +const PRETRAINING_PLAIN_TAGS = new Set(["pretraining", "pre-training"]); + +const PRETRAINING_SIZE_CATEGORIES = new Set([ + "100M1T", +]); + +const INCOMPATIBLE_TASKS_BY_MODEL: Record> = { text: new Set([ - "text-to-3d", - "image-to-3d", "text-to-image", "image-to-image", "image-to-video", @@ -143,30 +167,16 @@ const INCOMPATIBLE_TASK_CATEGORIES: Record> = { "audio-to-audio", "automatic-speech-recognition", "video-classification", - "robotics", - "reinforcement-learning", - "tabular-classification", - "tabular-regression", - "time-series-forecasting", "visual-document-retrieval", ]), vision: new Set([ - "text-to-3d", - "image-to-3d", "text-to-speech", "text-to-audio", "audio-classification", "audio-to-audio", "automatic-speech-recognition", - "robotics", - "reinforcement-learning", - "tabular-classification", - "tabular-regression", - "time-series-forecasting", ]), tts: new Set([ - "text-to-3d", - "image-to-3d", "text-to-image", "image-to-image", "image-to-video", @@ -180,16 +190,9 @@ const INCOMPATIBLE_TASK_CATEGORIES: Record> = { "image-segmentation", "depth-estimation", "video-classification", - "robotics", - "reinforcement-learning", - "tabular-classification", - "tabular-regression", - "time-series-forecasting", "visual-document-retrieval", ]), embeddings: new Set([ - "text-to-3d", - "image-to-3d", "text-to-image", "image-to-image", "image-to-video", @@ -208,28 +211,41 @@ const INCOMPATIBLE_TASK_CATEGORIES: Record> = { "audio-to-audio", "automatic-speech-recognition", "video-classification", - "robotics", - "reinforcement-learning", - "tabular-classification", - "tabular-regression", - "time-series-forecasting", "visual-document-retrieval", ]), }; -function classifyDataset( +function isPretrainingDataset(dataset: HfDatasetResult): boolean { + if (dataset.plainTags.some((t) => PRETRAINING_PLAIN_TAGS.has(t.toLowerCase()))) + return true; + if ( + dataset.sizeCategory && + PRETRAINING_SIZE_CATEGORIES.has(dataset.sizeCategory) + ) + return true; + return false; +} + +function rankDatasetRelevance( dataset: HfDatasetResult, modelType: ModelType, -): -1 | 0 | 1 { +): DatasetRelevance { + if (isPretrainingDataset(dataset)) return "incompatible"; + const { taskCategories } = dataset; - if (taskCategories.length === 0) return 0; + if (taskCategories.length === 0) return "neutral"; - const relevant = RELEVANT_TASK_CATEGORIES[modelType]; - const incompatible = INCOMPATIBLE_TASK_CATEGORIES[modelType]; + const boosted = BOOSTED_TASK_CATEGORIES[modelType]; + const modelIncompat = INCOMPATIBLE_TASKS_BY_MODEL[modelType]; - if (taskCategories.some((t) => relevant.has(t))) return 1; - if (taskCategories.every((t) => incompatible.has(t))) return -1; - return 0; + if (taskCategories.some((t) => boosted.has(t))) return "boosted"; + if ( + taskCategories.every( + (t) => INCOMPATIBLE_TASKS_ALL_MODELS.has(t) || modelIncompat.has(t), + ) + ) + return "incompatible"; + return "neutral"; } export function useHfDatasetSearch( @@ -257,9 +273,9 @@ export function useHfDatasetSearch( const neutral: HfDatasetResult[] = []; for (const ds of search.results) { - const rank = classifyDataset(ds, modelType); - if (rank === 1) boosted.push(ds); - else if (rank !== -1) neutral.push(ds); + const relevance = rankDatasetRelevance(ds, modelType); + if (relevance === "boosted") boosted.push(ds); + else if (relevance !== "incompatible") neutral.push(ds); } return [...boosted, ...neutral]; From bfb140303277bafe5ba1fade10586be8e55dd1ec 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 05/25] 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 c21cf2ffcf5e2c15990be8c8860e41145c969703 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 06/25] 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 6e535ed0ebf9c7c55aa9dde5c8f32f29560c3553 Mon Sep 17 00:00:00 2001 From: imagineer99 Date: Thu, 26 Feb 2026 06:27:52 +0000 Subject: [PATCH 07/25] fix: filter OCR datasets from non-vision hub results --- .../src/hooks/use-hf-dataset-search.ts | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/studio/frontend/src/hooks/use-hf-dataset-search.ts b/studio/frontend/src/hooks/use-hf-dataset-search.ts index b00dcee1ed..32879fd015 100644 --- a/studio/frontend/src/hooks/use-hf-dataset-search.ts +++ b/studio/frontend/src/hooks/use-hf-dataset-search.ts @@ -138,6 +138,7 @@ const INCOMPATIBLE_TASKS_ALL_MODELS = new Set([ ]); const PRETRAINING_PLAIN_TAGS = new Set(["pretraining", "pre-training"]); +const OCR_PLAIN_TAGS = new Set(["ocr", "document-ocr"]); const PRETRAINING_SIZE_CATEGORIES = new Set([ "100M1T", ]); +const OCR_OR_VISION_TEXT_TASKS = new Set([ + "image-to-text", + "image-captioning", + "visual-question-answering", + "document-question-answering", +]); + const INCOMPATIBLE_TASKS_BY_MODEL: Record> = { text: new Set([ "text-to-image", @@ -232,6 +240,16 @@ function rankDatasetRelevance( ): DatasetRelevance { if (isPretrainingDataset(dataset)) return "incompatible"; + // Keep OCR / vision-text corpora out of non-vision defaults. + if (modelType !== "vision") { + if ( + dataset.plainTags.some((t) => OCR_PLAIN_TAGS.has(t.toLowerCase())) || + dataset.taskCategories.some((t) => OCR_OR_VISION_TEXT_TASKS.has(t)) + ) { + return "incompatible"; + } + } + const { taskCategories } = dataset; if (taskCategories.length === 0) return "neutral"; @@ -248,6 +266,13 @@ function rankDatasetRelevance( return "neutral"; } +function isOcrOrVisionTextDataset(dataset: HfDatasetResult): boolean { + return ( + dataset.plainTags.some((t) => OCR_PLAIN_TAGS.has(t.toLowerCase())) || + dataset.taskCategories.some((t) => OCR_OR_VISION_TEXT_TASKS.has(t)) + ); +} + export function useHfDatasetSearch( query: string, options?: { modelType?: ModelType | null; accessToken?: string }, @@ -267,12 +292,17 @@ export function useHfDatasetSearch( const search = useHfPaginatedSearch(createIter, mapDataset); const results = useMemo(() => { - if (!modelType) return search.results; + const hideOcr = modelType !== "vision"; + const baseResults = hideOcr + ? search.results.filter((ds) => !isOcrOrVisionTextDataset(ds)) + : search.results; + + if (!modelType) return baseResults; const boosted: HfDatasetResult[] = []; const neutral: HfDatasetResult[] = []; - for (const ds of search.results) { + for (const ds of baseResults) { const relevance = rankDatasetRelevance(ds, modelType); if (relevance === "boosted") boosted.push(ds); else if (relevance !== "incompatible") neutral.push(ds); From 852dff564ed28684b30d611cdd2f3c8830d392ab Mon Sep 17 00:00:00 2001 From: imagineer99 Date: Thu, 26 Feb 2026 06:32:45 +0000 Subject: [PATCH 08/25] feat: added datasets of size 5M and 10M to pretraining size category --- studio/frontend/src/hooks/use-hf-dataset-search.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/studio/frontend/src/hooks/use-hf-dataset-search.ts b/studio/frontend/src/hooks/use-hf-dataset-search.ts index 32879fd015..ec64ffd7e6 100644 --- a/studio/frontend/src/hooks/use-hf-dataset-search.ts +++ b/studio/frontend/src/hooks/use-hf-dataset-search.ts @@ -141,6 +141,8 @@ const PRETRAINING_PLAIN_TAGS = new Set(["pretraining", "pre-training"]); const OCR_PLAIN_TAGS = new Set(["ocr", "document-ocr"]); const PRETRAINING_SIZE_CATEGORIES = new Set([ + "5M Date: Thu, 26 Feb 2026 10:59:39 +0400 Subject: [PATCH 09/25] 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 90f012a444279314d5ace74b376e0f1959ad5b0a 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 10/25] 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 ed18f9b9dd51d4a4943f8b0cefbe858f0cce7a91 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 11/25] 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) From e81516320d4669c326e39c76f0c154b1e9dbf4e8 Mon Sep 17 00:00:00 2001 From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:54:16 +0400 Subject: [PATCH 12/25] fix(setup): restrict Python to >=3.11 and <3.14 Adds lower bound (>= 3.11) and tightens upper bound (< 3.14) for Python version discovery in setup.sh. Extracts bounds into MIN_PY_MINOR / MAX_PY_MINOR variables for easy future updates. --- setup.sh | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/setup.sh b/setup.sh index 55fa57c83f..8da67e5667 100755 --- a/setup.sh +++ b/setup.sh @@ -105,7 +105,9 @@ echo "✅ Frontend built to studio/frontend/dist" echo "" echo "Setting up Python environment..." -# ── 6a. Discover best Python <= 3.12.x ── +# ── 6a. Discover best Python >= 3.11 and < 3.14 (i.e. 3.11.x, 3.12.x, or 3.13.x) ── +MIN_PY_MINOR=11 # minimum minor version (>= 3.11) +MAX_PY_MINOR=13 # maximum minor version (< 3.14) BEST_PY="" BEST_MAJOR=0 BEST_MINOR=0 @@ -115,7 +117,7 @@ for candidate in $(compgen -c python3 2>/dev/null | grep -E '^python3(\.[0-9]+)? if ! command -v "$candidate" &>/dev/null; then continue fi - # Get version string, e.g. "Python 3.11.5" + # Get version string, e.g. "Python 3.12.5" ver_str=$("$candidate" --version 2>&1 | awk '{print $2}') py_major=$(echo "$ver_str" | cut -d. -f1) py_minor=$(echo "$ver_str" | cut -d. -f2) @@ -125,8 +127,13 @@ for candidate in $(compgen -c python3 2>/dev/null | grep -E '^python3(\.[0-9]+)? continue fi - # Skip versions above 3.12 - if [ "$py_minor" -gt 12 ] 2>/dev/null; then + # Skip versions below 3.12 (require > 3.11) + if [ "$py_minor" -lt "$MIN_PY_MINOR" ] 2>/dev/null; then + continue + fi + + # Skip versions above 3.13 (require < 3.14) + if [ "$py_minor" -gt "$MAX_PY_MINOR" ] 2>/dev/null; then continue fi @@ -139,7 +146,7 @@ for candidate in $(compgen -c python3 2>/dev/null | grep -E '^python3(\.[0-9]+)? done if [ -z "$BEST_PY" ]; then - echo "❌ ERROR: No Python version <= 3.12.x found on this system." + echo "❌ ERROR: No Python version between 3.${MIN_PY_MINOR} and 3.${MAX_PY_MINOR} found on this system." echo " Detected Python 3 installations:" for candidate in $(compgen -c python3 2>/dev/null | grep -E '^python3(\.[0-9]+)?$' | sort -u); do if command -v "$candidate" &>/dev/null; then @@ -147,13 +154,13 @@ if [ -z "$BEST_PY" ]; then fi done echo "" - echo " Please install Python <= 3.12.x for maximum compatibility." + echo " Please install Python 3.${MIN_PY_MINOR} or 3.${MAX_PY_MINOR}." echo " For example: sudo apt install python3.12 python3.12-venv" exit 1 fi BEST_VER=$("$BEST_PY" --version 2>&1 | awk '{print $2}') -echo "✅ Using $BEST_PY ($BEST_VER) — compatible (≤ 3.12.x)" +echo "✅ Using $BEST_PY ($BEST_VER) — compatible (3.${MIN_PY_MINOR}.x – 3.${MAX_PY_MINOR}.x)" REQ_ROOT="$SCRIPT_DIR/studio/backend/requirements" SINGLE_ENV_CONSTRAINTS="$REQ_ROOT/single-env/constraints.txt" From 1de4b7324405c067f4f9e4f2cd64760813d1ece8 Mon Sep 17 00:00:00 2001 From: Shine1i Date: Thu, 26 Feb 2026 10:03:47 +0100 Subject: [PATCH 13/25] fix: recipe studio dialog combobox click-select + simplify model provider form --- .../frontend/src/components/ui/combobox.tsx | 28 ++-- .../components/inline/inline-model.tsx | 26 ++-- .../recipe-studio/dialogs/llm/general-tab.tsx | 4 +- .../dialogs/models/model-provider-dialog.tsx | 135 ++++++++++-------- .../utils/payload/builders-model.ts | 2 +- 5 files changed, 103 insertions(+), 92 deletions(-) diff --git a/studio/frontend/src/components/ui/combobox.tsx b/studio/frontend/src/components/ui/combobox.tsx index 9c1e970c57..8ccc40c95f 100644 --- a/studio/frontend/src/components/ui/combobox.tsx +++ b/studio/frontend/src/components/ui/combobox.tsx @@ -162,20 +162,20 @@ function ComboboxContent({ - + align={align} + alignOffset={alignOffset} + anchor={anchor} + className="isolate z-[120] pointer-events-auto" + > + ); diff --git a/studio/frontend/src/features/recipe-studio/components/inline/inline-model.tsx b/studio/frontend/src/features/recipe-studio/components/inline/inline-model.tsx index 5316b0f297..2c2c2ffd41 100644 --- a/studio/frontend/src/features/recipe-studio/components/inline/inline-model.tsx +++ b/studio/frontend/src/features/recipe-studio/components/inline/inline-model.tsx @@ -14,19 +14,6 @@ export function InlineModel(props: InlineModelProps): ReactElement { if (props.config.kind === "model_provider") { return (
- - - props.onUpdate({ - // biome-ignore lint/style/useNamingConvention: api schema - provider_type: event.target.value, - }) - } - /> - props.onUpdate({ endpoint: event.target.value })} /> + + + props.onUpdate({ + // biome-ignore lint/style/useNamingConvention: api schema + api_key: event.target.value, + }) + } + /> +
); } diff --git a/studio/frontend/src/features/recipe-studio/dialogs/llm/general-tab.tsx b/studio/frontend/src/features/recipe-studio/dialogs/llm/general-tab.tsx index 41153626b0..a0ab8e23fc 100644 --- a/studio/frontend/src/features/recipe-studio/dialogs/llm/general-tab.tsx +++ b/studio/frontend/src/features/recipe-studio/dialogs/llm/general-tab.tsx @@ -147,7 +147,7 @@ export function LlmGeneralTab({ />