From 46cc71f310eae13fc4bfc419ce623851df3bb1b8 Mon Sep 17 00:00:00 2001 From: sshah229 Date: Tue, 10 Feb 2026 16:16:17 -0700 Subject: [PATCH 01/29] added the pydantic models and routes for export --- studio/backend/main.py | 1 + studio/backend/models/__init__.py | 21 ++ studio/backend/models/export.py | 141 ++++++++++++++ studio/backend/routes/__init__.py | 10 +- studio/backend/routes/export.py | 310 ++++++++++++++++++++++++++++++ 5 files changed, 482 insertions(+), 1 deletion(-) create mode 100644 studio/backend/models/export.py create mode 100644 studio/backend/routes/export.py diff --git a/studio/backend/main.py b/studio/backend/main.py index e957c668e3..34c61bed6b 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -67,6 +67,7 @@ app.include_router(training_router, prefix="/api/train", tags=["training"]) app.include_router(models_router, prefix="/api/models", tags=["models"]) app.include_router(inference_router, prefix="/api/inference", tags=["inference"]) app.include_router(datasets_router, prefix="/api/datasets", tags=["datasets"]) +app.include_router(export_router, prefix="/api/export", tags=["export"]) # ============ Health and System Endpoints ============ diff --git a/studio/backend/models/__init__.py b/studio/backend/models/__init__.py index 584fb9f7c2..8d947b1b9c 100644 --- a/studio/backend/models/__init__.py +++ b/studio/backend/models/__init__.py @@ -19,6 +19,17 @@ from .auth import ( RefreshTokenRequest, AuthStatusResponse, ) +from .export import ( + CheckpointInfo, + CheckpointListResponse, + LoadCheckpointRequest, + ExportStatusResponse, + ExportOperationResponse, + ExportMergedModelRequest, + ExportBaseModelRequest, + ExportGGUFRequest, + ExportLoRAAdapterRequest, +) from .users import Token from .datasets import ( CheckFormatRequest, @@ -55,6 +66,16 @@ __all__ = [ "AuthLoginRequest", "RefreshTokenRequest", "AuthStatusResponse", + # Export schemas + "CheckpointInfo", + "CheckpointListResponse", + "LoadCheckpointRequest", + "ExportStatusResponse", + "ExportOperationResponse", + "ExportMergedModelRequest", + "ExportBaseModelRequest", + "ExportGGUFRequest", + "ExportLoRAAdapterRequest", "Token", # Dataset schemas "CheckFormatRequest", diff --git a/studio/backend/models/export.py b/studio/backend/models/export.py new file mode 100644 index 0000000000..d108c00751 --- /dev/null +++ b/studio/backend/models/export.py @@ -0,0 +1,141 @@ +""" +Pydantic schemas for Export API. +""" +from pydantic import BaseModel, Field +from typing import List, Optional, Literal, Dict, Any + + +class CheckpointInfo(BaseModel): + """Information about a discovered checkpoint directory.""" + + display_name: str = Field(..., description="User-friendly checkpoint name (folder name)") + path: str = Field(..., description="Full path to the checkpoint directory") + + +class CheckpointListResponse(BaseModel): + """Response for listing available checkpoints in an outputs directory.""" + + outputs_dir: str = Field(..., description="Directory that was scanned") + checkpoints: List[CheckpointInfo] = Field( + default_factory=list, + description="List of discovered checkpoints", + ) + + +class LoadCheckpointRequest(BaseModel): + """Request for loading a checkpoint into the export backend.""" + + checkpoint_path: str = Field(..., description="Path to the checkpoint directory") + max_seq_length: int = Field( + 2048, + ge=128, + le=32768, + description="Maximum sequence length for loading the model", + ) + load_in_4bit: bool = Field( + True, + description="Whether to load the model in 4-bit quantization", + ) + + +class ExportStatusResponse(BaseModel): + """Current export backend status.""" + + current_checkpoint: Optional[str] = Field( + None, + description="Path to the currently loaded checkpoint, if any", + ) + is_vision: bool = Field( + False, + description="True if the loaded checkpoint is a vision model", + ) + is_peft: bool = Field( + False, + description="True if the loaded checkpoint is a PEFT (LoRA) model", + ) + + +class ExportOperationResponse(BaseModel): + """Generic response for export operations.""" + + success: bool = Field(..., description="True if the operation succeeded") + message: str = Field(..., description="Human-readable status or error message") + details: Optional[Dict[str, Any]] = Field( + default=None, + description="Optional extra details about the operation", + ) + + +class ExportCommonOptions(BaseModel): + """Common options for export operations that save locally and/or push to Hub.""" + + save_directory: str = Field( + ..., + description="Local directory where the exported artifacts will be written", + ) + push_to_hub: bool = Field( + False, + description="If True, also push the exported model to the Hugging Face Hub", + ) + repo_id: Optional[str] = Field( + None, + description="Hugging Face Hub repository ID (username/model-name)", + ) + hf_token: Optional[str] = Field( + None, + description="Hugging Face access token used for Hub operations", + ) + private: bool = Field( + False, + description="If True, create a private repository on the Hub (where applicable)", + ) + + +class ExportMergedModelRequest(ExportCommonOptions): + """Request for exporting a merged PEFT model.""" + + format_type: Literal["16-bit (FP16)", "4-bit (FP4)"] = Field( + "16-bit (FP16)", + description="Export precision / format for the merged model", + ) + + +class ExportBaseModelRequest(ExportCommonOptions): + """Request for exporting a non-PEFT (base) model.""" + + # Uses fields from ExportCommonOptions only + pass + + +class ExportGGUFRequest(BaseModel): + """Request for exporting the current model to GGUF format.""" + + save_directory: str = Field( + ..., + description="Directory where GGUF files will be saved", + ) + quantization_method: str = Field( + "Q4_K_M", + description='GGUF quantization method (e.g. "Q4_K_M")', + ) + push_to_hub: bool = Field( + False, + description="If True, also push GGUF artifacts to the Hugging Face Hub", + ) + repo_id: Optional[str] = Field( + None, + description="Hugging Face Hub repository ID for GGUF upload", + ) + hf_token: Optional[str] = Field( + None, + description="Hugging Face token for GGUF upload", + ) + + +class ExportLoRAAdapterRequest(ExportCommonOptions): + """Request for exporting only the LoRA adapter (not merged).""" + + # Uses fields from ExportCommonOptions only + pass + + diff --git a/studio/backend/routes/__init__.py b/studio/backend/routes/__init__.py index 5a16125a64..04c7ff7f1a 100644 --- a/studio/backend/routes/__init__.py +++ b/studio/backend/routes/__init__.py @@ -7,5 +7,13 @@ from routes.models import router as models_router from routes.inference import router as inference_router from routes.datasets import router as datasets_router from routes.auth import router as auth_router +from routes.export import router as export_router -__all__ = ["training_router", "models_router", "inference_router", "datasets_router", "auth_router"] \ No newline at end of file +__all__ = [ + "training_router", + "models_router", + "inference_router", + "datasets_router", + "auth_router", + "export_router", +] \ No newline at end of file diff --git a/studio/backend/routes/export.py b/studio/backend/routes/export.py new file mode 100644 index 0000000000..b72dea48b8 --- /dev/null +++ b/studio/backend/routes/export.py @@ -0,0 +1,310 @@ +""" +Export API routes: checkpoint discovery and model export operations. +""" + +import sys +from pathlib import Path +from fastapi import APIRouter, Depends, HTTPException, Query +import logging + +# Add backend directory to path +backend_path = Path(__file__).parent.parent.parent +if str(backend_path) not in sys.path: + sys.path.insert(0, str(backend_path)) + +# Auth +from auth.authentication import get_current_subject + +# Import backend functions +try: + from core.export import get_export_backend +except ImportError: + parent_backend = backend_path.parent / "backend" + if str(parent_backend) not in sys.path: + sys.path.insert(0, str(parent_backend)) + from core.export import get_export_backend + +# Import Pydantic models +from models import ( + CheckpointInfo, + CheckpointListResponse, + LoadCheckpointRequest, + ExportStatusResponse, + ExportOperationResponse, + ExportMergedModelRequest, + ExportBaseModelRequest, + ExportGGUFRequest, + ExportLoRAAdapterRequest, +) + +router = APIRouter() +logger = logging.getLogger(__name__) + +# Configure logger +if not logger.handlers: + handler = logging.StreamHandler() + handler.setLevel(logging.INFO) + formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + handler.setFormatter(formatter) + logger.addHandler(handler) + logger.setLevel(logging.INFO) + + +@router.get("/checkpoints", response_model=CheckpointListResponse) +async def list_checkpoints( + outputs_dir: str = Query( + default="./outputs", + description="Directory to scan for checkpoints", + ), + current_subject: str = Depends(get_current_subject), +): + """ + List available checkpoints in the outputs directory. + + Wraps ExportBackend.scan_checkpoints. + """ + try: + backend = get_export_backend() + raw_checkpoints = backend.scan_checkpoints(outputs_dir=outputs_dir) + + checkpoints = [ + CheckpointInfo(display_name=display_name, path=path) + for display_name, path in raw_checkpoints + ] + + return CheckpointListResponse( + outputs_dir=outputs_dir, + checkpoints=checkpoints, + ) + except Exception as e: + logger.error(f"Error listing checkpoints: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail=f"Failed to list checkpoints: {str(e)}", + ) + + +@router.post("/load-checkpoint", response_model=ExportOperationResponse) +async def load_checkpoint( + request: LoadCheckpointRequest, + current_subject: str = Depends(get_current_subject), +): + """ + Load a checkpoint into the export backend. + + Wraps ExportBackend.load_checkpoint. + """ + try: + backend = get_export_backend() + success, message = backend.load_checkpoint( + checkpoint_path=request.checkpoint_path, + max_seq_length=request.max_seq_length, + load_in_4bit=request.load_in_4bit, + ) + + if not success: + raise HTTPException(status_code=400, detail=message) + + return ExportOperationResponse(success=True, message=message) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error loading checkpoint: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail=f"Failed to load checkpoint: {str(e)}", + ) + + +@router.post("/cleanup", response_model=ExportOperationResponse) +async def cleanup_export_memory( + current_subject: str = Depends(get_current_subject), +): + """ + Cleanup export-related models from memory (GPU/CPU). + + Wraps ExportBackend.cleanup_memory. + """ + try: + backend = get_export_backend() + success = backend.cleanup_memory() + + if not success: + raise HTTPException( + status_code=500, + detail="Memory cleanup failed. See server logs for details.", + ) + + return ExportOperationResponse( + success=True, + message="Memory cleanup completed successfully", + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error during export memory cleanup: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail=f"Failed to cleanup export memory: {str(e)}", + ) + + +@router.get("/status", response_model=ExportStatusResponse) +async def get_export_status( + current_subject: str = Depends(get_current_subject), +): + """ + Get current export backend status (loaded checkpoint, model type, PEFT flag). + """ + try: + backend = get_export_backend() + return ExportStatusResponse( + current_checkpoint=backend.current_checkpoint, + is_vision=bool(getattr(backend, "is_vision", False)), + is_peft=bool(getattr(backend, "is_peft", False)), + ) + except Exception as e: + logger.error(f"Error getting export status: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail=f"Failed to get export status: {str(e)}", + ) + + +@router.post("/export/merged", response_model=ExportOperationResponse) +async def export_merged_model( + request: ExportMergedModelRequest, + current_subject: str = Depends(get_current_subject), +): + """ + Export a merged PEFT model (e.g., 16-bit or 4-bit) and optionally push to Hub. + + Wraps ExportBackend.export_merged_model. + """ + try: + backend = get_export_backend() + success, message = backend.export_merged_model( + save_directory=request.save_directory, + format_type=request.format_type, + push_to_hub=request.push_to_hub, + repo_id=request.repo_id, + hf_token=request.hf_token, + private=request.private, + ) + + if not success: + raise HTTPException(status_code=400, detail=message) + + return ExportOperationResponse(success=True, message=message) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error exporting merged model: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail=f"Failed to export merged model: {str(e)}", + ) + + +@router.post("/export/base", response_model=ExportOperationResponse) +async def export_base_model( + request: ExportBaseModelRequest, + current_subject: str = Depends(get_current_subject), +): + """ + Export a non-PEFT base model and optionally push to Hub. + + Wraps ExportBackend.export_base_model. + """ + try: + backend = get_export_backend() + success, message = backend.export_base_model( + save_directory=request.save_directory, + push_to_hub=request.push_to_hub, + repo_id=request.repo_id, + hf_token=request.hf_token, + private=request.private, + ) + + if not success: + raise HTTPException(status_code=400, detail=message) + + return ExportOperationResponse(success=True, message=message) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error exporting base model: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail=f"Failed to export base model: {str(e)}", + ) + + +@router.post("/export/gguf", response_model=ExportOperationResponse) +async def export_gguf( + request: ExportGGUFRequest, + current_subject: str = Depends(get_current_subject), +): + """ + Export the current model to GGUF format and optionally push to Hub. + + Wraps ExportBackend.export_gguf. + """ + try: + backend = get_export_backend() + success, message = backend.export_gguf( + save_directory=request.save_directory, + quantization_method=request.quantization_method, + push_to_hub=request.push_to_hub, + repo_id=request.repo_id, + hf_token=request.hf_token, + ) + + if not success: + raise HTTPException(status_code=400, detail=message) + + return ExportOperationResponse(success=True, message=message) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error exporting GGUF model: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail=f"Failed to export GGUF model: {str(e)}", + ) + + +@router.post("/export/lora", response_model=ExportOperationResponse) +async def export_lora_adapter( + request: ExportLoRAAdapterRequest, + current_subject: str = Depends(get_current_subject), +): + """ + Export only the LoRA adapter (if the loaded model is PEFT). + + Wraps ExportBackend.export_lora_adapter. + """ + try: + backend = get_export_backend() + success, message = backend.export_lora_adapter( + save_directory=request.save_directory, + push_to_hub=request.push_to_hub, + repo_id=request.repo_id, + hf_token=request.hf_token, + private=request.private, + ) + + if not success: + raise HTTPException(status_code=400, detail=message) + + return ExportOperationResponse(success=True, message=message) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error exporting LoRA adapter: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail=f"Failed to export LoRA adapter: {str(e)}", + ) + + From e4b073985fcca99dd3f79c6b88e417269cb25906 Mon Sep 17 00:00:00 2001 From: sshah229 Date: Wed, 11 Feb 2026 18:51:53 -0700 Subject: [PATCH 02/29] added router in main --- studio/backend/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/studio/backend/main.py b/studio/backend/main.py index 34c61bed6b..118c59ee7f 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -13,7 +13,7 @@ from pathlib import Path from datetime import datetime # Import routers -from routes import training_router, models_router, inference_router, datasets_router, auth_router +from routes import training_router, models_router, inference_router, datasets_router, auth_router, export_router from auth import storage from utils.hardware import detect_hardware import utils.hardware.hardware as _hw_module From 8403bac48d75a360f4f666cbb278b8f48c1cd193 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Sat, 14 Feb 2026 14:52:13 +0000 Subject: [PATCH 03/29] feat(inference): add use_adapter field for per-request adapter toggling in compare mode --- studio/backend/core/inference/inference.py | 173 +++++++++++++++------ studio/backend/models/inference.py | 10 ++ studio/backend/routes/inference.py | 16 +- 3 files changed, 150 insertions(+), 49 deletions(-) diff --git a/studio/backend/core/inference/inference.py b/studio/backend/core/inference/inference.py index 0fd1905ff4..f8c0213b9b 100644 --- a/studio/backend/core/inference/inference.py +++ b/studio/backend/core/inference/inference.py @@ -8,7 +8,7 @@ from peft import PeftModel, PeftModelForCausalLM import sys import torch -from typing import Optional, Generator, Tuple +from typing import Optional, Union, Generator, Tuple from utils.models import ModelConfig, get_base_model_from_lora from utils.paths import is_model_cached from utils.utils import format_error_message @@ -448,6 +448,64 @@ class InferenceBackend: return False pass + def _apply_adapter_state(self, use_adapter: Optional[Union[bool, str]]) -> None: + """ + Apply adapter state before generation. Must be called under _generation_lock. + + Args: + use_adapter: None = no change, False = disable (base model), + True = enable current adapter, str = enable specific adapter. + """ + if use_adapter is None: + return + + base = self.active_model_name + if not base or base not in self.models: + return + + model_info = self.models[base] + + if use_adapter is False: + # Disable all adapters → pure base model generation + logger.info(f"Compare mode: disabling adapters on '{base}' (base model generation)") + self.disable_adapters(base) + + elif use_adapter is True: + # Enable the most recently loaded adapter + loaded = model_info.get("loaded_adapters", {}) + if loaded: + adapter_name = list(loaded.keys())[-1] + logger.info(f"Compare mode: enabling adapter '{adapter_name}' on '{base}'") + self.set_active_adapter(base, adapter_name) + else: + logger.warning("use_adapter=true but no adapters are loaded on the model") + + elif isinstance(use_adapter, str): + # Enable a specific named adapter + logger.info(f"Compare mode: enabling specific adapter '{use_adapter}' on '{base}'") + self.set_active_adapter(base, use_adapter) + + def generate_with_adapter_control( + self, + use_adapter: Optional[Union[bool, str]] = None, + **gen_kwargs, + ) -> Generator[str, None, None]: + """ + Thread-safe generation with optional adapter toggling. + + Acquires the generation lock, applies adapter state, then generates. + This ensures adapter toggle + generation are atomic — critical for + compare mode where base and LoRA panes fire concurrently. + + Args: + use_adapter: Adapter control (None/False/True/str). See _apply_adapter_state. + **gen_kwargs: Forwarded to generate_chat_response. + """ + with self._generation_lock: + self._apply_adapter_state(use_adapter) + # Delegate to the lock-free generation path + yield from self._generate_chat_response_inner(**gen_kwargs) + def generate_chat_response(self, messages: list, system_prompt: str, @@ -459,11 +517,33 @@ class InferenceBackend: repetition_penalty: float = 1.1) -> Generator[str, None, None]: """ Generate response for text or vision models. + Acquires the generation lock. For adapter-controlled generation, + use generate_with_adapter_control() instead. + """ + with self._generation_lock: + yield from self._generate_chat_response_inner( + messages=messages, + system_prompt=system_prompt, + image=image, + temperature=temperature, + top_p=top_p, + top_k=top_k, + max_new_tokens=max_new_tokens, + repetition_penalty=repetition_penalty, + ) - 1. Messages are already in ChatML format (role/content) - 2. Apply get_chat_template() if model in mapper - 3. Apply tokenizer.apply_chat_template() - 4. Generate + def _generate_chat_response_inner(self, + messages: list, + system_prompt: str = "", + image=None, + temperature: float = 0.7, + top_p: float = 0.9, + top_k: int = 40, + max_new_tokens: int = 256, + repetition_penalty: float = 1.1) -> Generator[str, None, None]: + """ + Inner generation logic (no lock). Called by both generate_chat_response + and generate_with_adapter_control. """ if not self.active_model_name: yield "Error: No active model" @@ -473,55 +553,54 @@ class InferenceBackend: is_vision = model_info.get("is_vision", False) tokenizer = model_info.get("tokenizer") or model_info.get("processor") - with self._generation_lock: - if is_vision: - # Vision model generation - yield from self._generate_vision_response( - messages, system_prompt, image, - temperature, top_p, top_k, max_new_tokens, repetition_penalty - ) - else: - # Text model: Use training pipeline approach - # Messages are already in ChatML format from eval.py + if is_vision: + # Vision model generation + yield from self._generate_vision_response( + messages, system_prompt, image, + temperature, top_p, top_k, max_new_tokens, repetition_penalty + ) + else: + # Text model: Use training pipeline approach + # Messages are already in ChatML format from eval.py - # Step 1: Apply get_chat_template if model is in mapper - try: - from utils.datasets import MODEL_TO_TEMPLATE_MAPPER, get_tokenizer_chat_template + # Step 1: Apply get_chat_template if model is in mapper + try: + from utils.datasets import MODEL_TO_TEMPLATE_MAPPER, get_tokenizer_chat_template - model_name_lower = self.active_model_name.lower() + model_name_lower = self.active_model_name.lower() - # Check if model has a registered template - if model_name_lower in MODEL_TO_TEMPLATE_MAPPER: - template_name = MODEL_TO_TEMPLATE_MAPPER[model_name_lower] - logger.info(f"Applying chat template '{template_name}' for {self.active_model_name}") + # Check if model has a registered template + if model_name_lower in MODEL_TO_TEMPLATE_MAPPER: + template_name = MODEL_TO_TEMPLATE_MAPPER[model_name_lower] + logger.info(f"Applying chat template '{template_name}' for {self.active_model_name}") - # This modifies the tokenizer with the correct template - tokenizer = get_chat_template( - tokenizer, - self.active_model_name - ) - else: - logger.info(f"No registered template for {self.active_model_name}, using tokenizer default") - except Exception as e: - logger.warning(f"Could not apply get_chat_template: {e}") - - # Step 2: Format with tokenizer.apply_chat_template() - try: - formatted_prompt = tokenizer.apply_chat_template( - messages, - tokenize=False, - add_generation_prompt=True + # This modifies the tokenizer with the correct template + tokenizer = get_chat_template( + tokenizer, + self.active_model_name ) - logger.debug(f"Formatted prompt: {formatted_prompt[:200]}...") - except Exception as e: - logger.error(f"Error applying chat template: {e}") - # Fallback to manual formatting - formatted_prompt = self.format_chat_prompt(messages, system_prompt) + else: + logger.info(f"No registered template for {self.active_model_name}, using tokenizer default") + except Exception as e: + logger.warning(f"Could not apply get_chat_template: {e}") - # Step 3: Generate - yield from self.generate_stream( - formatted_prompt, temperature, top_p, top_k, max_new_tokens, repetition_penalty + # Step 2: Format with tokenizer.apply_chat_template() + try: + formatted_prompt = tokenizer.apply_chat_template( + messages, + tokenize=False, + add_generation_prompt=True ) + logger.debug(f"Formatted prompt: {formatted_prompt[:200]}...") + except Exception as e: + logger.error(f"Error applying chat template: {e}") + # Fallback to manual formatting + formatted_prompt = self.format_chat_prompt(messages, system_prompt) + + # Step 3: Generate + yield from self.generate_stream( + formatted_prompt, temperature, top_p, top_k, max_new_tokens, repetition_penalty + ) def _generate_vision_response(self, messages, system_prompt, image, temperature, top_p, top_k, max_new_tokens, diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index 64791b06f9..ada8bdd539 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -130,6 +130,16 @@ class ChatCompletionRequest(BaseModel): top_k: int = Field(40, ge=1, le=100, description="[x-unsloth] Top-k sampling") repetition_penalty: float = Field(1.1, ge=1.0, le=2.0, description="[x-unsloth] Repetition penalty") image_base64: Optional[str] = Field(None, description="[x-unsloth] Base64-encoded image for vision models") + use_adapter: Optional[Union[bool, str]] = Field( + None, + description=( + "[x-unsloth] Adapter control for compare mode. " + "null = no change (default), " + "false = disable adapters (base model), " + "true = enable the current adapter, " + "string = enable a specific adapter by name." + ), + ) # ── Streaming response chunks ──────────────────────────────────── diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 74cf37138f..9e57946565 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -365,6 +365,18 @@ async def openai_chat_completions(request: ChatCompletionRequest): repetition_penalty=request.repetition_penalty, ) + # ── Choose generation path (adapter-controlled or standard) ── + if request.use_adapter is not None: + # Compare mode: toggle adapter state atomically with generation + def generate(): + return backend.generate_with_adapter_control( + use_adapter=request.use_adapter, **gen_kwargs + ) + else: + # Standard path: no adapter toggling + def generate(): + return backend.generate_chat_response(**gen_kwargs) + model_name = backend.active_model_name or request.model completion_id = f"chatcmpl-{uuid.uuid4().hex[:12]}" created = int(time.time()) @@ -388,7 +400,7 @@ async def openai_chat_completions(request: ChatCompletionRequest): # Content chunks — generate_chat_response yields cumulative # text, so we diff to get incremental deltas. prev_text = "" - for cumulative in backend.generate_chat_response(**gen_kwargs): + for cumulative in generate(): new_text = cumulative[len(prev_text):] prev_text = cumulative if not new_text: @@ -439,7 +451,7 @@ async def openai_chat_completions(request: ChatCompletionRequest): else: try: full_text = "" - for token in backend.generate_chat_response(**gen_kwargs): + for token in generate(): full_text = token # generate_stream yields cumulative text response = ChatCompletion( From 0b305fd822854a6850848f42f73fd8c8456030f6 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Sat, 14 Feb 2026 16:57:24 +0000 Subject: [PATCH 04/29] _apply_adapter_state now calls revert_to_base_model and activate_lora_adapter properly --- studio/backend/core/inference/inference.py | 36 ++++++++++++++-------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/studio/backend/core/inference/inference.py b/studio/backend/core/inference/inference.py index f8c0213b9b..a4ab80ffe2 100644 --- a/studio/backend/core/inference/inference.py +++ b/studio/backend/core/inference/inference.py @@ -452,6 +452,10 @@ class InferenceBackend: """ Apply adapter state before generation. Must be called under _generation_lock. + Uses revert_to_base_model() / activate_lora_adapter() which work correctly + for models loaded by Unsloth as complete PeftModels (via model.unload() / + model.load_adapter()), matching the proven pattern from the Gradio eval page. + Args: use_adapter: None = no change, False = disable (base model), True = enable current adapter, str = enable specific adapter. @@ -466,24 +470,30 @@ class InferenceBackend: model_info = self.models[base] if use_adapter is False: - # Disable all adapters → pure base model generation - logger.info(f"Compare mode: disabling adapters on '{base}' (base model generation)") - self.disable_adapters(base) + # Revert to pure base model by unloading adapter weights + logger.info(f"Compare mode: reverting '{base}' to base model for generation") + self.revert_to_base_model(base) elif use_adapter is True: - # Enable the most recently loaded adapter - loaded = model_info.get("loaded_adapters", {}) - if loaded: - adapter_name = list(loaded.keys())[-1] - logger.info(f"Compare mode: enabling adapter '{adapter_name}' on '{base}'") - self.set_active_adapter(base, adapter_name) + # Activate the LoRA adapter from the original model path + lora_path = model_info.get("model_path") + if lora_path and model_info.get("is_lora"): + logger.info(f"Compare mode: activating LoRA adapter from '{lora_path}' on '{base}'") + self.activate_lora_adapter(base, lora_path) else: - logger.warning("use_adapter=true but no adapters are loaded on the model") + # Fallback for dynamically attached adapters + loaded = model_info.get("loaded_adapters", {}) + if loaded: + adapter_name = list(loaded.keys())[-1] + logger.info(f"Compare mode: enabling adapter '{adapter_name}' on '{base}'") + self.set_active_adapter(base, adapter_name) + else: + logger.warning("use_adapter=true but no adapter path/adapters on model") elif isinstance(use_adapter, str): - # Enable a specific named adapter - logger.info(f"Compare mode: enabling specific adapter '{use_adapter}' on '{base}'") - self.set_active_adapter(base, use_adapter) + # Activate a specific adapter by path + logger.info(f"Compare mode: activating specific adapter '{use_adapter}' on '{base}'") + self.activate_lora_adapter(base, use_adapter) def generate_with_adapter_control( self, From 9bee0a3f63f378b384e996e762ccfbd8b3bfbfc8 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Sat, 14 Feb 2026 17:03:52 +0000 Subject: [PATCH 05/29] exclude default from model.delete_adapter --- studio/backend/core/inference/inference.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/studio/backend/core/inference/inference.py b/studio/backend/core/inference/inference.py index a4ab80ffe2..ed7c786913 100644 --- a/studio/backend/core/inference/inference.py +++ b/studio/backend/core/inference/inference.py @@ -193,6 +193,8 @@ class InferenceBackend: logger.info("Found lingering adapter configurations. Deleting them now...") # Create a static list of keys before iterating and deleting for name in list(model.peft_config.keys()): + if name == "default": + continue logger.info(f"Deleting adapter config: '{name}'") model.delete_adapter(name) From b930a17b1dd236ba7abc7cd5f96f475c035b69da Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Sat, 14 Feb 2026 17:09:07 +0000 Subject: [PATCH 06/29] added logging --- studio/backend/core/inference/inference.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/studio/backend/core/inference/inference.py b/studio/backend/core/inference/inference.py index ed7c786913..20484f7bb4 100644 --- a/studio/backend/core/inference/inference.py +++ b/studio/backend/core/inference/inference.py @@ -177,6 +177,9 @@ class InferenceBackend: return False model = self.models[base_model_name].get("model") + logger.info(f"[DEBUG] revert_to_base_model called. Model type BEFORE: {model.__class__.__name__}") + logger.info(f"[DEBUG] Is PeftModel? {isinstance(model, (PeftModel, PeftModelForCausalLM))}") + logger.info(f"[DEBUG] Has peft_config? {hasattr(model, 'peft_config')}, keys={list(getattr(model, 'peft_config', {}).keys())}") try: # Step 1: Unload the adapter weights. This returns the base model object. @@ -186,10 +189,14 @@ class InferenceBackend: unwrapped_base_model = model.unload() self.models[base_model_name]["model"] = unwrapped_base_model model = unwrapped_base_model # Continue with the unwrapped model + logger.info(f"[DEBUG] Model type AFTER unload: {model.__class__.__name__}") + else: + logger.info(f"[DEBUG] Model is NOT a PeftModel, skipping unload.") # Step 2: Delete any lingering adapter configurations from the object. # This is the crucial step you identified. if hasattr(model, 'peft_config') and model.peft_config: + logger.info(f"[DEBUG] Lingering peft_config keys: {list(model.peft_config.keys())}") logger.info("Found lingering adapter configurations. Deleting them now...") # Create a static list of keys before iterating and deleting for name in list(model.peft_config.keys()): @@ -198,7 +205,7 @@ class InferenceBackend: logger.info(f"Deleting adapter config: '{name}'") model.delete_adapter(name) - logger.info("Model has been successfully reverted to a clean base state.") + logger.info(f"[DEBUG] Model type FINAL: {model.__class__.__name__}. Reverted to clean base state.") return True except Exception as e: From 754ccf1a677c295b7b48d6cba8df87db6c7f1127 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Sat, 14 Feb 2026 17:21:26 +0000 Subject: [PATCH 07/29] swipped logger for print statements as logger isn't propagating --- studio/backend/core/inference/inference.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/studio/backend/core/inference/inference.py b/studio/backend/core/inference/inference.py index 20484f7bb4..ff44777dbd 100644 --- a/studio/backend/core/inference/inference.py +++ b/studio/backend/core/inference/inference.py @@ -177,26 +177,26 @@ class InferenceBackend: return False model = self.models[base_model_name].get("model") - logger.info(f"[DEBUG] revert_to_base_model called. Model type BEFORE: {model.__class__.__name__}") - logger.info(f"[DEBUG] Is PeftModel? {isinstance(model, (PeftModel, PeftModelForCausalLM))}") - logger.info(f"[DEBUG] Has peft_config? {hasattr(model, 'peft_config')}, keys={list(getattr(model, 'peft_config', {}).keys())}") + print(f"[DEBUG] revert_to_base_model called. Model type BEFORE: {model.__class__.__name__}") + print(f"[DEBUG] Is PeftModel? {isinstance(model, (PeftModel, PeftModelForCausalLM))}") + print(f"[DEBUG] Has peft_config? {hasattr(model, 'peft_config')}, keys={list(getattr(model, 'peft_config', {}).keys())}") try: # Step 1: Unload the adapter weights. This returns the base model object. # This step is only necessary if the model is currently a PeftModel instance. if isinstance(model, (PeftModel, PeftModelForCausalLM)): - logger.info("Model is a PeftModel. Unloading adapters...") + print("[DEBUG] Model IS a PeftModel. Calling model.unload()...") unwrapped_base_model = model.unload() self.models[base_model_name]["model"] = unwrapped_base_model model = unwrapped_base_model # Continue with the unwrapped model - logger.info(f"[DEBUG] Model type AFTER unload: {model.__class__.__name__}") + print(f"[DEBUG] Model type AFTER unload: {model.__class__.__name__}") else: - logger.info(f"[DEBUG] Model is NOT a PeftModel, skipping unload.") + print(f"[DEBUG] Model is NOT a PeftModel, skipping unload.") # Step 2: Delete any lingering adapter configurations from the object. # This is the crucial step you identified. if hasattr(model, 'peft_config') and model.peft_config: - logger.info(f"[DEBUG] Lingering peft_config keys: {list(model.peft_config.keys())}") + print(f"[DEBUG] Lingering peft_config keys: {list(model.peft_config.keys())}") logger.info("Found lingering adapter configurations. Deleting them now...") # Create a static list of keys before iterating and deleting for name in list(model.peft_config.keys()): @@ -205,7 +205,7 @@ class InferenceBackend: logger.info(f"Deleting adapter config: '{name}'") model.delete_adapter(name) - logger.info(f"[DEBUG] Model type FINAL: {model.__class__.__name__}. Reverted to clean base state.") + print(f"[DEBUG] Model type FINAL: {model.__class__.__name__}. Reverted to clean base state.") return True except Exception as e: From f122154cf36d4e0b64c4dabf57cba0366b7e9b04 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Sat, 14 Feb 2026 17:25:37 +0000 Subject: [PATCH 08/29] added print statements for activate_lora_adapter --- studio/backend/core/inference/inference.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/studio/backend/core/inference/inference.py b/studio/backend/core/inference/inference.py index ff44777dbd..a4580baa71 100644 --- a/studio/backend/core/inference/inference.py +++ b/studio/backend/core/inference/inference.py @@ -221,25 +221,32 @@ class InferenceBackend: """ model = self.models[base_model_name].get("model") adapter_name_to_load = lora_path.split("/")[-1].replace(".", "_") + print(f"[DEBUG] activate_lora_adapter called. base_model_name='{base_model_name}', lora_path='{lora_path}'") + print(f"[DEBUG] adapter_name_to_load='{adapter_name_to_load}'") + print(f"[DEBUG] Model type BEFORE load_adapter: {model.__class__.__name__}") + print(f"[DEBUG] Has peft_config? {hasattr(model, 'peft_config')}, keys={list(getattr(model, 'peft_config', {}).keys())}") try: # At this point, the model should be clean thanks to revert_to_base_model. # We can now safely load and set the new adapter. # Step 3: Load the new adapter. - logger.info(f"Loading adapter '{adapter_name_to_load}' from '{lora_path}'") + print(f"[DEBUG] Calling model.load_adapter('{lora_path}', adapter_name='{adapter_name_to_load}')...") model.load_adapter(lora_path, adapter_name=adapter_name_to_load) + print(f"[DEBUG] Model type AFTER load_adapter: {model.__class__.__name__}") + print(f"[DEBUG] peft_config keys AFTER load: {list(getattr(model, 'peft_config', {}).keys())}") # Step 4: Set the new adapter as active. - logger.info(f"Setting '{adapter_name_to_load}' as the active adapter.") + print(f"[DEBUG] Calling model.set_adapter('{adapter_name_to_load}')...") model.set_adapter(adapter_name_to_load) + print(f"[DEBUG] activate_lora_adapter SUCCESS. Model type: {model.__class__.__name__}") return True, adapter_name_to_load except Exception as e: # This will catch the "already exists" error if revert_to_base_model failed. - logger.error(f"Failed to activate LoRA adapter '{adapter_name_to_load}': {e}") + print(f"[DEBUG] activate_lora_adapter FAILED: {e}") import traceback - logger.error(traceback.format_exc()) + traceback.print_exc() return False, None pass From 225b3f1750f6221eed1c452de61e1b07ef217d90 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Sat, 14 Feb 2026 17:32:15 +0000 Subject: [PATCH 09/29] del model.peft_config instead of using model.delete_adapter --- studio/backend/core/inference/inference.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/studio/backend/core/inference/inference.py b/studio/backend/core/inference/inference.py index a4580baa71..41a907820f 100644 --- a/studio/backend/core/inference/inference.py +++ b/studio/backend/core/inference/inference.py @@ -193,19 +193,15 @@ class InferenceBackend: else: print(f"[DEBUG] Model is NOT a PeftModel, skipping unload.") - # Step 2: Delete any lingering adapter configurations from the object. - # This is the crucial step you identified. - if hasattr(model, 'peft_config') and model.peft_config: - print(f"[DEBUG] Lingering peft_config keys: {list(model.peft_config.keys())}") - logger.info("Found lingering adapter configurations. Deleting them now...") - # Create a static list of keys before iterating and deleting - for name in list(model.peft_config.keys()): - if name == "default": - continue - logger.info(f"Deleting adapter config: '{name}'") - model.delete_adapter(name) + # Step 2: Clear any lingering peft_config from the unwrapped model. + # After model.unload(), the base model may still carry a peft_config + # attribute (with 'default' key). Removing it entirely ensures + # load_adapter() won't warn about "multiple adapters". + if hasattr(model, 'peft_config'): + print(f"[DEBUG] Clearing lingering peft_config: {list(model.peft_config.keys())}") + del model.peft_config - print(f"[DEBUG] Model type FINAL: {model.__class__.__name__}. Reverted to clean base state.") + print(f"[DEBUG] Model type FINAL: {model.__class__.__name__}, has peft_config={hasattr(model, 'peft_config')}. Reverted to clean base state.") return True except Exception as e: From 4d868e8d2b3d59173f384a06d6b7b1119f4efd01 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Sat, 14 Feb 2026 19:18:49 +0000 Subject: [PATCH 10/29] replace model unloading and peft loading mechanism for compare feature --- studio/backend/core/inference/inference.py | 26 ++++++++-------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/studio/backend/core/inference/inference.py b/studio/backend/core/inference/inference.py index 41a907820f..9abf8d270f 100644 --- a/studio/backend/core/inference/inference.py +++ b/studio/backend/core/inference/inference.py @@ -214,37 +214,29 @@ class InferenceBackend: def activate_lora_adapter(self, base_model_name: str, lora_path: str) -> Tuple[bool, Optional[str]]: """ Activates a specific LoRA adapter on what is assumed to be a clean base model. + Uses PeftModel.from_pretrained() which correctly wraps the base model. """ model = self.models[base_model_name].get("model") adapter_name_to_load = lora_path.split("/")[-1].replace(".", "_") print(f"[DEBUG] activate_lora_adapter called. base_model_name='{base_model_name}', lora_path='{lora_path}'") print(f"[DEBUG] adapter_name_to_load='{adapter_name_to_load}'") - print(f"[DEBUG] Model type BEFORE load_adapter: {model.__class__.__name__}") - print(f"[DEBUG] Has peft_config? {hasattr(model, 'peft_config')}, keys={list(getattr(model, 'peft_config', {}).keys())}") + print(f"[DEBUG] Model type BEFORE: {model.__class__.__name__}") try: - # At this point, the model should be clean thanks to revert_to_base_model. - # We can now safely load and set the new adapter. - - # Step 3: Load the new adapter. - print(f"[DEBUG] Calling model.load_adapter('{lora_path}', adapter_name='{adapter_name_to_load}')...") - model.load_adapter(lora_path, adapter_name=adapter_name_to_load) - print(f"[DEBUG] Model type AFTER load_adapter: {model.__class__.__name__}") - print(f"[DEBUG] peft_config keys AFTER load: {list(getattr(model, 'peft_config', {}).keys())}") - - # Step 4: Set the new adapter as active. - print(f"[DEBUG] Calling model.set_adapter('{adapter_name_to_load}')...") - model.set_adapter(adapter_name_to_load) - print(f"[DEBUG] activate_lora_adapter SUCCESS. Model type: {model.__class__.__name__}") + # Use PeftModel.from_pretrained to wrap the clean base model with the adapter. + # This is the correct approach after model.unload() + del peft_config. + print(f"[DEBUG] Calling PeftModel.from_pretrained(model, '{lora_path}', adapter_name='{adapter_name_to_load}')...") + model = PeftModel.from_pretrained(model, lora_path, adapter_name=adapter_name_to_load) + self.models[base_model_name]["model"] = model + print(f"[DEBUG] Model type AFTER: {model.__class__.__name__}") + print(f"[DEBUG] activate_lora_adapter SUCCESS.") return True, adapter_name_to_load except Exception as e: - # This will catch the "already exists" error if revert_to_base_model failed. print(f"[DEBUG] activate_lora_adapter FAILED: {e}") import traceback traceback.print_exc() return False, None - pass def load_adapter(self, base_model_name: str, adapter_path: str, adapter_name: str = None) -> bool: """ From 4399687f938f6c49c48e8454ea98f654b9f1d589 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Sat, 14 Feb 2026 19:23:51 +0000 Subject: [PATCH 11/29] strip extra debug statements --- studio/backend/core/inference/inference.py | 33 +++++++--------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/studio/backend/core/inference/inference.py b/studio/backend/core/inference/inference.py index 9abf8d270f..23203613f2 100644 --- a/studio/backend/core/inference/inference.py +++ b/studio/backend/core/inference/inference.py @@ -177,31 +177,23 @@ class InferenceBackend: return False model = self.models[base_model_name].get("model") - print(f"[DEBUG] revert_to_base_model called. Model type BEFORE: {model.__class__.__name__}") - print(f"[DEBUG] Is PeftModel? {isinstance(model, (PeftModel, PeftModelForCausalLM))}") - print(f"[DEBUG] Has peft_config? {hasattr(model, 'peft_config')}, keys={list(getattr(model, 'peft_config', {}).keys())}") try: - # Step 1: Unload the adapter weights. This returns the base model object. - # This step is only necessary if the model is currently a PeftModel instance. + # Step 1: Unload the adapter weights if model is a PeftModel. if isinstance(model, (PeftModel, PeftModelForCausalLM)): - print("[DEBUG] Model IS a PeftModel. Calling model.unload()...") + logger.info(f"Unloading LoRA adapters from '{base_model_name}'...") unwrapped_base_model = model.unload() self.models[base_model_name]["model"] = unwrapped_base_model - model = unwrapped_base_model # Continue with the unwrapped model - print(f"[DEBUG] Model type AFTER unload: {model.__class__.__name__}") - else: - print(f"[DEBUG] Model is NOT a PeftModel, skipping unload.") + model = unwrapped_base_model # Step 2: Clear any lingering peft_config from the unwrapped model. # After model.unload(), the base model may still carry a peft_config - # attribute (with 'default' key). Removing it entirely ensures - # load_adapter() won't warn about "multiple adapters". + # attribute. Removing it ensures PeftModel.from_pretrained() gets + # a clean base model without "multiple adapters" warnings. if hasattr(model, 'peft_config'): - print(f"[DEBUG] Clearing lingering peft_config: {list(model.peft_config.keys())}") del model.peft_config - print(f"[DEBUG] Model type FINAL: {model.__class__.__name__}, has peft_config={hasattr(model, 'peft_config')}. Reverted to clean base state.") + logger.info(f"Model '{base_model_name}' reverted to clean base state.") return True except Exception as e: @@ -209,7 +201,6 @@ class InferenceBackend: import traceback logger.error(traceback.format_exc()) return False - pass def activate_lora_adapter(self, base_model_name: str, lora_path: str) -> Tuple[bool, Optional[str]]: """ @@ -218,24 +209,20 @@ class InferenceBackend: """ model = self.models[base_model_name].get("model") adapter_name_to_load = lora_path.split("/")[-1].replace(".", "_") - print(f"[DEBUG] activate_lora_adapter called. base_model_name='{base_model_name}', lora_path='{lora_path}'") - print(f"[DEBUG] adapter_name_to_load='{adapter_name_to_load}'") - print(f"[DEBUG] Model type BEFORE: {model.__class__.__name__}") try: # Use PeftModel.from_pretrained to wrap the clean base model with the adapter. # This is the correct approach after model.unload() + del peft_config. - print(f"[DEBUG] Calling PeftModel.from_pretrained(model, '{lora_path}', adapter_name='{adapter_name_to_load}')...") + logger.info(f"Loading LoRA adapter '{adapter_name_to_load}' from '{lora_path}'...") model = PeftModel.from_pretrained(model, lora_path, adapter_name=adapter_name_to_load) self.models[base_model_name]["model"] = model - print(f"[DEBUG] Model type AFTER: {model.__class__.__name__}") - print(f"[DEBUG] activate_lora_adapter SUCCESS.") + logger.info(f"LoRA adapter '{adapter_name_to_load}' activated successfully.") return True, adapter_name_to_load except Exception as e: - print(f"[DEBUG] activate_lora_adapter FAILED: {e}") + logger.error(f"Failed to activate LoRA adapter '{adapter_name_to_load}': {e}") import traceback - traceback.print_exc() + logger.error(traceback.format_exc()) return False, None def load_adapter(self, base_model_name: str, adapter_path: str, adapter_name: str = None) -> bool: From ac8128519d9e6e1f154612eadc05bee4af6cd673 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Sat, 14 Feb 2026 20:13:50 +0000 Subject: [PATCH 12/29] decouple reliance of backend on frontend for is_lora --- studio/backend/routes/inference.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 9e57946565..b3ea1fa1be 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -69,10 +69,10 @@ async def load_model(request: LoadRequest): backend = get_inference_backend() # Create config using clean factory method + # is_lora is auto-detected from adapter_config.json on disk/HF config = ModelConfig.from_identifier( model_id=request.model_path, hf_token=request.hf_token, - is_lora=request.is_lora, ) if not config: From 354b7d0aca6f02b634b23c38c37a438ab77c87aa Mon Sep 17 00:00:00 2001 From: Manan17 Date: Sun, 15 Feb 2026 00:00:22 +0000 Subject: [PATCH 13/29] feat: add cancel or save and stop training --- studio/backend/core/training/trainer.py | 42 +++++++++++++++++------- studio/backend/core/training/training.py | 14 ++++++-- studio/backend/routes/training.py | 39 +++++++++++++++++++++- 3 files changed, 79 insertions(+), 16 deletions(-) diff --git a/studio/backend/core/training/trainer.py b/studio/backend/core/training/trainer.py index 52cf60af10..013c7817b2 100644 --- a/studio/backend/core/training/trainer.py +++ b/studio/backend/core/training/trainer.py @@ -58,6 +58,7 @@ class UnslothTrainer: self.progress_callbacks = [] self.is_training = False self.should_stop = False + self.save_on_stop = True # Model state tracking self.is_vlm = False @@ -756,16 +757,32 @@ class UnslothTrainer: self.trainer.train() # ========== SAVE MODEL ========== - self.trainer.save_model() - self.tokenizer.save_pretrained(output_dir) - print(f"\nTraining completed! Model saved to {output_dir}\n") - - self._update_progress( - is_training=False, - is_completed=True, - #status_message=status_msg - status_message=f"Training completed! Model saved to {output_dir}", - ) + if self.should_stop and self.save_on_stop: + # Stopped by user — save model at current checkpoint + self.trainer.save_model() + self.tokenizer.save_pretrained(output_dir) + print(f"\nTraining stopped. Model saved to {output_dir}\n") + self._update_progress( + is_training=False, + status_message=f"Training stopped. Model saved to {output_dir}", + ) + elif self.should_stop: + # Cancelled by user — don't save + print("\nTraining cancelled.\n") + self._update_progress( + is_training=False, + status_message="Training cancelled.", + ) + else: + # Normal completion + self.trainer.save_model() + self.tokenizer.save_pretrained(output_dir) + print(f"\nTraining completed! Model saved to {output_dir}\n") + self._update_progress( + is_training=False, + is_completed=True, + status_message=f"Training completed! Model saved to {output_dir}", + ) except Exception as e: logger.error(f"Training error: {e}") @@ -774,10 +791,11 @@ class UnslothTrainer: finally: self.is_training = False - def stop_training(self): + def stop_training(self, save: bool = True): """Stop ongoing training""" - print("\nStopping training...") + print(f"\nStopping training (save={save})...") self.should_stop = True + self.save_on_stop = save self.is_training = False # Clear the status message so timer doesn't show stale status self._update_progress(is_training=False, status_message="") diff --git a/studio/backend/core/training/training.py b/studio/backend/core/training/training.py index fa8b3b5daf..62febadc13 100644 --- a/studio/backend/core/training/training.py +++ b/studio/backend/core/training/training.py @@ -103,6 +103,7 @@ class TrainingBackend: try: # Reset stop flag and clear history self.trainer.should_stop = False + self.trainer.save_on_stop = True self.loss_history = [] self.lr_history = [] self.step_history = [] @@ -224,16 +225,19 @@ class TrainingBackend: ) return False - def stop_training(self) -> bool: + def stop_training(self, save: bool = True) -> bool: """ Stop ongoing training. + Args: + save: If True, save the model at the current checkpoint. + Returns: True if training was successfully stopped. """ try: - logger.info("Stopping training...") - self.trainer.stop_training() + logger.info(f"Stopping training (save={save})...") + self.trainer.stop_training(save=save) return True except Exception as e: logger.error(f"Error stopping training: {e}") @@ -293,6 +297,10 @@ class TrainingBackend: True if training is in progress, False otherwise """ try: + # If user requested stop, training is no longer considered active + if self.trainer.should_stop: + return False + progress = self.trainer.get_training_progress() # Training is active if is_training is True # Also check if we're in loading/preparation phase (status_message indicates activity) diff --git a/studio/backend/routes/training.py b/studio/backend/routes/training.py index 20a5e1e254..1a904cd371 100644 --- a/studio/backend/routes/training.py +++ b/studio/backend/routes/training.py @@ -37,6 +37,11 @@ from models import ( TrainingProgress, ) from models.responses import TrainingStopResponse, TrainingMetricsResponse +from pydantic import BaseModel as PydanticBaseModel + + +class TrainingStopRequest(PydanticBaseModel): + save: bool = True router = APIRouter() logger = logging.getLogger(__name__) @@ -251,10 +256,14 @@ async def start_training( @router.post("/stop", response_model=TrainingStopResponse) async def stop_training( + body: TrainingStopRequest = TrainingStopRequest(), current_subject: str = Depends(get_current_subject), ): """ Stop the currently running training job. + + Body: + save (bool): If True (default), save the model at the current checkpoint. """ try: backend = get_training_backend() @@ -266,7 +275,7 @@ async def stop_training( ) # Call backend stop method - backend.stop_training() + backend.stop_training(save=body.save) return TrainingStopResponse( status="stopped", @@ -281,6 +290,29 @@ async def stop_training( ) +@router.post("/reset") +async def reset_training( + current_subject: str = Depends(get_current_subject), +): + """ + Reset training state so the user can return to configuration. + """ + try: + backend = get_training_backend() + backend.trainer.should_stop = False + backend.trainer.training_progress = backend.trainer.training_progress.__class__() + backend.loss_history = [] + backend.lr_history = [] + backend.step_history = [] + return {"status": "ok"} + except Exception as e: + logger.error(f"Error resetting training: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail=f"Failed to reset training: {str(e)}", + ) + + @router.get("/status") async def get_training_status( current_subject: str = Depends(get_current_subject), @@ -313,6 +345,9 @@ async def get_training_status( ) or "Ready to train" error_message = getattr(progress, "error", None) if progress else None + # Check if training was stopped by user + trainer_stopped = getattr(backend.trainer, "should_stop", False) + # Derive high-level phase if error_message: phase = "error" @@ -326,6 +361,8 @@ async def get_training_status( phase = "configuring" else: phase = "training" + elif trainer_stopped: + phase = "stopped" elif progress and getattr(progress, "is_completed", False): phase = "completed" elif has_thread: From 55e7bd60c158362ac114a7794d856a636200249a Mon Sep 17 00:00:00 2001 From: Manan17 Date: Sun, 15 Feb 2026 00:22:28 +0000 Subject: [PATCH 14/29] feat: UI for cancel or save and stop training --- .../studio/sections/progress-section.tsx | 53 +++++++++++++++---- .../src/features/studio/studio-page.tsx | 20 +++++++ .../src/features/training/api/train-api.ts | 15 +++++- .../training/hooks/use-training-actions.ts | 16 ++++-- .../hooks/use-training-runtime-lifecycle.ts | 6 ++- .../src/features/training/lib/sync-runtime.ts | 5 ++ .../training/stores/training-runtime-store.ts | 6 ++- .../src/features/training/types/runtime.ts | 1 + 8 files changed, 104 insertions(+), 18 deletions(-) diff --git a/studio/frontend/src/features/studio/sections/progress-section.tsx b/studio/frontend/src/features/studio/sections/progress-section.tsx index b8656a7867..f1a10c2684 100644 --- a/studio/frontend/src/features/studio/sections/progress-section.tsx +++ b/studio/frontend/src/features/studio/sections/progress-section.tsx @@ -1,4 +1,14 @@ import { SectionCard } from "@/components/section-card"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Popover, @@ -104,6 +114,7 @@ export function ProgressSection(): ReactElement { ); const { stopTrainingRun } = useTrainingActions(); + const [stopDialogOpen, setStopDialogOpen] = useState(false); const localStartAtRef = useRef(null); const [, setLocalTick] = useState(0); @@ -225,15 +236,39 @@ export function ProgressSection(): ReactElement { - + + + + + Stop Training + + Choose how you want to stop the current training run. + + + + Continue Training + void stopTrainingRun(false)} + > + Cancel Training + + void stopTrainingRun(true)} + > + Stop and Save + + + + } > diff --git a/studio/frontend/src/features/studio/studio-page.tsx b/studio/frontend/src/features/studio/studio-page.tsx index d5e20fc194..5811f161db 100644 --- a/studio/frontend/src/features/studio/studio-page.tsx +++ b/studio/frontend/src/features/studio/studio-page.tsx @@ -1,8 +1,12 @@ +import { Button } from "@/components/ui/button"; import { shouldShowTrainingView, + useTrainingActions, useTrainingRuntimeLifecycle, useTrainingRuntimeStore, } from "@/features/training"; +import { ArrowLeft01Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; import type { ReactElement } from "react"; import { DatasetSection } from "./sections/dataset-section"; import { ModelSection } from "./sections/model-section"; @@ -14,12 +18,28 @@ export function StudioPage(): ReactElement { useTrainingRuntimeLifecycle(); const showTrainingView = useTrainingRuntimeStore(shouldShowTrainingView); const runtimeMessage = useTrainingRuntimeStore((state) => state.message); + const runtimePhase = useTrainingRuntimeStore((state) => state.phase); const isHydratingRuntime = useTrainingRuntimeStore((state) => state.isHydrating); const hasHydratedRuntime = useTrainingRuntimeStore((state) => state.hasHydrated); + const { dismissTrainingRun } = useTrainingActions(); + + const canGoBack = runtimePhase === "stopped" || runtimePhase === "error"; return (
+ {canGoBack && ( + + )} + {/* Header */}

diff --git a/studio/frontend/src/features/training/api/train-api.ts b/studio/frontend/src/features/training/api/train-api.ts index e3c53b0bc2..d2f589c298 100644 --- a/studio/frontend/src/features/training/api/train-api.ts +++ b/studio/frontend/src/features/training/api/train-api.ts @@ -41,11 +41,22 @@ export async function startTraining( return parseJson(response); } -export async function stopTraining(): Promise { - const response = await authFetch("/api/train/stop", { method: "POST" }); +export async function stopTraining(save = true): Promise { + const response = await authFetch("/api/train/stop", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ save }), + }); return parseJson(response); } +export async function resetTraining(): Promise { + const response = await authFetch("/api/train/reset", { method: "POST" }); + if (!response.ok) { + throw new Error(await readError(response)); + } +} + export async function getTrainingStatus(): Promise { const response = await authFetch("/api/train/status"); return parseJson(response); diff --git a/studio/frontend/src/features/training/hooks/use-training-actions.ts b/studio/frontend/src/features/training/hooks/use-training-actions.ts index 510c45ed4a..4ace9dd5ed 100644 --- a/studio/frontend/src/features/training/hooks/use-training-actions.ts +++ b/studio/frontend/src/features/training/hooks/use-training-actions.ts @@ -1,7 +1,7 @@ import { useCallback } from "react"; import { useTrainingConfigStore } from "../stores/training-config-store"; import { useTrainingRuntimeStore } from "../stores/training-runtime-store"; -import { startTraining, stopTraining } from "../api/train-api"; +import { startTraining, stopTraining, resetTraining } from "../api/train-api"; import { buildTrainingStartPayload } from "../api/mappers"; import { syncTrainingRuntimeFromBackend } from "../lib/sync-runtime"; import { validateTrainingConfig } from "../lib/validation"; @@ -45,12 +45,12 @@ export function useTrainingActions() { } }, []); - const stopTrainingRun = useCallback(async (): Promise => { + const stopTrainingRun = useCallback(async (save = true): Promise => { const runtimeStore = useTrainingRuntimeStore.getState(); runtimeStore.setStartError(null); try { - await stopTraining(); + await stopTraining(save); await syncTrainingRuntimeFromBackend(); return true; } catch (error) { @@ -61,10 +61,20 @@ export function useTrainingActions() { } }, []); + const dismissTrainingRun = useCallback(async (): Promise => { + useTrainingRuntimeStore.getState().resetRuntime(); + try { + await resetTraining(); + } catch { + // Frontend already reset; backend will catch up on next poll + } + }, []); + return { isStarting, startError, startTrainingRun, stopTrainingRun, + dismissTrainingRun, }; } diff --git a/studio/frontend/src/features/training/hooks/use-training-runtime-lifecycle.ts b/studio/frontend/src/features/training/hooks/use-training-runtime-lifecycle.ts index 3baff3a62d..8d0eb9c756 100644 --- a/studio/frontend/src/features/training/hooks/use-training-runtime-lifecycle.ts +++ b/studio/frontend/src/features/training/hooks/use-training-runtime-lifecycle.ts @@ -48,9 +48,10 @@ export function useTrainingRuntimeLifecycle(): void { }; const pollMetrics = async () => { + const gen = runtimeStore.getState().resetGeneration; try { const metrics = await getTrainingMetrics(); - if (disposed) { + if (disposed || runtimeStore.getState().resetGeneration !== gen) { return; } runtimeStore.getState().applyMetrics(metrics); @@ -62,9 +63,10 @@ export function useTrainingRuntimeLifecycle(): void { }; const pollStatus = async () => { + const gen = runtimeStore.getState().resetGeneration; try { const status = await getTrainingStatus(); - if (disposed) { + if (disposed || runtimeStore.getState().resetGeneration !== gen) { return; } diff --git a/studio/frontend/src/features/training/lib/sync-runtime.ts b/studio/frontend/src/features/training/lib/sync-runtime.ts index b5fbd0bafb..bf255f58c6 100644 --- a/studio/frontend/src/features/training/lib/sync-runtime.ts +++ b/studio/frontend/src/features/training/lib/sync-runtime.ts @@ -6,12 +6,17 @@ import { useTrainingRuntimeStore } from "../stores/training-runtime-store"; import type { TrainingStatusResponse } from "../types/runtime"; export async function syncTrainingRuntimeFromBackend(): Promise { + const gen = useTrainingRuntimeStore.getState().resetGeneration; + const [status, metrics] = await Promise.all([ getTrainingStatus(), getTrainingMetrics(), ]); const runtimeStore = useTrainingRuntimeStore.getState(); + if (runtimeStore.resetGeneration !== gen) { + return status; + } runtimeStore.applyStatus(status); runtimeStore.applyMetrics(metrics); diff --git a/studio/frontend/src/features/training/stores/training-runtime-store.ts b/studio/frontend/src/features/training/stores/training-runtime-store.ts index bc40019346..94db0f28f5 100644 --- a/studio/frontend/src/features/training/stores/training-runtime-store.ts +++ b/studio/frontend/src/features/training/stores/training-runtime-store.ts @@ -34,6 +34,7 @@ const initialState: TrainingRuntimeState = { lossHistory: [], lrHistory: [], gradNormHistory: [], + resetGeneration: 0, }; function sortSeries(points: TrainingSeriesPoint[]): TrainingSeriesPoint[] { @@ -95,12 +96,13 @@ export const useTrainingRuntimeStore = create()((set) => ( setLastEventId: (value) => set({ lastEventId: value }), resetRuntime: () => - set({ + set((state) => ({ ...initialState, lossHistory: [], lrHistory: [], gradNormHistory: [], - }), + resetGeneration: state.resetGeneration + 1, + })), setStartQueued: (jobId, message) => set({ diff --git a/studio/frontend/src/features/training/types/runtime.ts b/studio/frontend/src/features/training/types/runtime.ts index fe2afbd36d..389418680a 100644 --- a/studio/frontend/src/features/training/types/runtime.ts +++ b/studio/frontend/src/features/training/types/runtime.ts @@ -82,6 +82,7 @@ export interface TrainingRuntimeState { lossHistory: TrainingSeriesPoint[]; lrHistory: TrainingSeriesPoint[]; gradNormHistory: TrainingSeriesPoint[]; + resetGeneration: number; } export interface TrainingRuntimeActions { From df5f45058f52879b2c6ff08870113be02cb93676 Mon Sep 17 00:00:00 2001 From: Manan17 Date: Sun, 15 Feb 2026 05:38:06 +0000 Subject: [PATCH 15/29] Fixing stuck training processes --- studio/backend/core/training/trainer.py | 5 ++++- studio/backend/core/training/training.py | 26 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/studio/backend/core/training/trainer.py b/studio/backend/core/training/trainer.py index 013c7817b2..5a3d8683e2 100644 --- a/studio/backend/core/training/trainer.py +++ b/studio/backend/core/training/trainer.py @@ -2,13 +2,16 @@ Unsloth Training Backend Integrates Unsloth training capabilities with the FastAPI backend """ +import os +# Prevent tokenizer parallelism deadlocks when datasets uses multiprocessing fork +os.environ["TOKENIZERS_PARALLELISM"] = "false" + import torch from utils.hardware import clear_gpu_cache torch._dynamo.config.recompile_limit = 64 from unsloth import FastLanguageModel, FastVisionModel, is_bfloat16_supported from unsloth.chat_templates import get_chat_template -import os import json import threading import math diff --git a/studio/backend/core/training/training.py b/studio/backend/core/training/training.py index 62febadc13..62aa021136 100644 --- a/studio/backend/core/training/training.py +++ b/studio/backend/core/training/training.py @@ -6,6 +6,7 @@ from typing import Any, Generator, Tuple import logging from .trainer import get_trainer, TrainingProgress +from utils.hardware import clear_gpu_cache logger = logging.getLogger(__name__) @@ -101,6 +102,31 @@ class TrainingBackend: True if training started successfully, False otherwise. """ try: + # Wait for any previous training thread to finish + old_thread = getattr(self.trainer, "training_thread", None) + if old_thread and old_thread.is_alive(): + logger.info("Waiting for previous training thread to finish...") + old_thread.join(timeout=30) + + # Explicitly free old SFTTrainer and CUDA resources before loading new model. + # Without this, forked multiprocessing workers (num_proc tokenization) inherit + # stale CUDA state from the previous run, causing extreme slowdowns or crashes. + if self.trainer.trainer is not None: + logger.info("Cleaning up previous SFTTrainer...") + self.trainer.trainer = None + if self.trainer.model is not None: + self.trainer.model = None + if self.trainer.tokenizer is not None: + self.trainer.tokenizer = None + # Flush all pending async CUDA ops so forked tokenization processes + # don't inherit stale async state that causes pool join to hang. + import torch as _torch + if _torch.cuda.is_available(): + _torch.cuda.synchronize() + import gc + gc.collect() + clear_gpu_cache() + # Reset stop flag and clear history self.trainer.should_stop = False self.trainer.save_on_stop = True From e7c289967e77313ff99bffd10af01bd9932b40af Mon Sep 17 00:00:00 2001 From: Manan17 Date: Sun, 15 Feb 2026 05:38:06 +0000 Subject: [PATCH 16/29] Fixing stuck training processes --- studio/backend/core/training/trainer.py | 5 ++++- studio/backend/core/training/training.py | 26 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/studio/backend/core/training/trainer.py b/studio/backend/core/training/trainer.py index 013c7817b2..5a3d8683e2 100644 --- a/studio/backend/core/training/trainer.py +++ b/studio/backend/core/training/trainer.py @@ -2,13 +2,16 @@ Unsloth Training Backend Integrates Unsloth training capabilities with the FastAPI backend """ +import os +# Prevent tokenizer parallelism deadlocks when datasets uses multiprocessing fork +os.environ["TOKENIZERS_PARALLELISM"] = "false" + import torch from utils.hardware import clear_gpu_cache torch._dynamo.config.recompile_limit = 64 from unsloth import FastLanguageModel, FastVisionModel, is_bfloat16_supported from unsloth.chat_templates import get_chat_template -import os import json import threading import math diff --git a/studio/backend/core/training/training.py b/studio/backend/core/training/training.py index 62febadc13..62aa021136 100644 --- a/studio/backend/core/training/training.py +++ b/studio/backend/core/training/training.py @@ -6,6 +6,7 @@ from typing import Any, Generator, Tuple import logging from .trainer import get_trainer, TrainingProgress +from utils.hardware import clear_gpu_cache logger = logging.getLogger(__name__) @@ -101,6 +102,31 @@ class TrainingBackend: True if training started successfully, False otherwise. """ try: + # Wait for any previous training thread to finish + old_thread = getattr(self.trainer, "training_thread", None) + if old_thread and old_thread.is_alive(): + logger.info("Waiting for previous training thread to finish...") + old_thread.join(timeout=30) + + # Explicitly free old SFTTrainer and CUDA resources before loading new model. + # Without this, forked multiprocessing workers (num_proc tokenization) inherit + # stale CUDA state from the previous run, causing extreme slowdowns or crashes. + if self.trainer.trainer is not None: + logger.info("Cleaning up previous SFTTrainer...") + self.trainer.trainer = None + if self.trainer.model is not None: + self.trainer.model = None + if self.trainer.tokenizer is not None: + self.trainer.tokenizer = None + # Flush all pending async CUDA ops so forked tokenization processes + # don't inherit stale async state that causes pool join to hang. + import torch as _torch + if _torch.cuda.is_available(): + _torch.cuda.synchronize() + import gc + gc.collect() + clear_gpu_cache() + # Reset stop flag and clear history self.trainer.should_stop = False self.trainer.save_on_stop = True From 9195311b1c3bf471167bd9c466fe6d666456f62e Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Sun, 15 Feb 2026 06:06:27 +0000 Subject: [PATCH 17/29] fix: add no-cache headers to index.html to prevent stale frontend after rebuild --- studio/backend/main.py | 4 +- test_lora.py | 83 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 test_lora.py diff --git a/studio/backend/main.py b/studio/backend/main.py index e957c668e3..ce67200456 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -128,7 +128,7 @@ def setup_frontend(app: FastAPI, build_path: Path): @app.get("/") async def serve_root(): - return FileResponse(build_path / "index.html") + return FileResponse(build_path / "index.html", headers={"Cache-Control": "no-cache, no-store, must-revalidate"}) @app.get("/{full_path:path}") async def serve_frontend(full_path: str): @@ -139,7 +139,7 @@ def setup_frontend(app: FastAPI, build_path: Path): if file_path.is_file(): return FileResponse(file_path) - return FileResponse(build_path / "index.html") + return FileResponse(build_path / "index.html", headers={"Cache-Control": "no-cache, no-store, must-revalidate"}) return True return False diff --git a/test_lora.py b/test_lora.py new file mode 100644 index 0000000000..8d48f20bba --- /dev/null +++ b/test_lora.py @@ -0,0 +1,83 @@ +from unsloth import FastLanguageModel +from peft import PeftModel, PeftModelForCausalLM + +lora_path="/home/support/new-ui-prototype/outputs/meta-llama_Llama-3.1-8B-Instruct_1771048481" +adapter_name_to_load="test" + +model, tokenizer = FastLanguageModel.from_pretrained( + model_name="/home/support/new-ui-prototype/outputs/meta-llama_Llama-3.1-8B-Instruct_1771048481", + max_seq_length=2048, + load_in_4bit=True, +) + +# Quick sanity check +FastLanguageModel.for_inference(model) +print("Model loaded successfully!") +print(f"Model type before unloading: {type(model)}") +print(f"Tokenizer: {type(tokenizer)}") +print(f"Model class before unloading: {model.__class__.__name__}") +# Test generation +#inputs = tokenizer("What is the capital of France?", return_tensors="pt").to(model.device) +#outputs = model.generate(**inputs, max_new_tokens=32) +#print(tokenizer.decode(outputs[0], skip_special_tokens=True)) + + +print("unloading base model") +if isinstance(model, (PeftModel, PeftModelForCausalLM)): + print("Model is a PeftModel. Unloading adapters...") + unwrapped_base_model = model.unload() + model = unwrapped_base_model + #if hasattr(model, 'peft_config') and model.peft_config: + # print("Found lingering adapter configurations. Deleting them now...") + # # Create a static list of keys before iterating and deleting + # for name in list(model.peft_config.keys()): + # if name == "default": + # continue + # print(f"Deleting adapter config: '{name}'") + # mode=model.delete_adapter(name) + #model.disable_adapters() + if hasattr(model, 'peft_config'): + del model.peft_config +print(f"Model type post unloading{type(model)}") +print(f"Model class post unloading: {model.__class__.__name__}") +#print(f"model: {model}") + +#print("generating using base model") +#inputs = tokenizer("What is the capital of Lebanon?", return_tensors="pt").to(model.device) +#outputs = model.generate(**inputs, max_new_tokens=32) +#print(tokenizer.decode(outputs[0], skip_special_tokens=True)) +#print(f"model config: {model.config}") +#print(f"model: {model}") +#print(f"model.peft_config: {model.peft_config}") + +#print("loading lora dapter") +#model.load_adapter(lora_path, adapter_name=adapter_name_to_load) +#model.enable_adapters +#model.set_adapter(adapter_name_to_load) +model = PeftModel.from_pretrained(model, lora_path, adapter_name=adapter_name_to_load) +print(f"Model type {type(model)}") +print(f"Model class: {model.__class__.__name__}") +#print(f"model: {model}") +#print("generating using peft model hopefully") +#inputs = tokenizer("What is the capital of Brasil?", return_tensors="pt").to(model.device) +#outputs = model.generate(**inputs, max_new_tokens=32) +#print(tokenizer.decode(outputs[0], skip_special_tokens=True)) + +print("unloading base model") +if isinstance(model, (PeftModel, PeftModelForCausalLM)): + print("Model is a PeftModel. Unloading adapters...") + unwrapped_base_model = model.unload() + model = unwrapped_base_model + #if hasattr(model, 'peft_config') and model.peft_config: + # print("Found lingering adapter configurations. Deleting them now...") + # # Create a static list of keys before iterating and deleting + # for name in list(model.peft_config.keys()): + # if name == "default": + # continue + # print(f"Deleting adapter config: '{name}'") + # mode=model.delete_adapter(name) + #model.disable_adapters() + if hasattr(model, 'peft_config'): + del model.peft_config +print(f"Model type post unloading{type(model)}") +print(f"Model class post unloading: {model.__class__.__name__}") From 44f04e067dd205bb090717a15a3defca7a6154f5 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Sun, 15 Feb 2026 06:11:06 +0000 Subject: [PATCH 18/29] Remove test_lora.py from tracking --- test_lora.py | 83 ---------------------------------------------------- 1 file changed, 83 deletions(-) delete mode 100644 test_lora.py diff --git a/test_lora.py b/test_lora.py deleted file mode 100644 index 8d48f20bba..0000000000 --- a/test_lora.py +++ /dev/null @@ -1,83 +0,0 @@ -from unsloth import FastLanguageModel -from peft import PeftModel, PeftModelForCausalLM - -lora_path="/home/support/new-ui-prototype/outputs/meta-llama_Llama-3.1-8B-Instruct_1771048481" -adapter_name_to_load="test" - -model, tokenizer = FastLanguageModel.from_pretrained( - model_name="/home/support/new-ui-prototype/outputs/meta-llama_Llama-3.1-8B-Instruct_1771048481", - max_seq_length=2048, - load_in_4bit=True, -) - -# Quick sanity check -FastLanguageModel.for_inference(model) -print("Model loaded successfully!") -print(f"Model type before unloading: {type(model)}") -print(f"Tokenizer: {type(tokenizer)}") -print(f"Model class before unloading: {model.__class__.__name__}") -# Test generation -#inputs = tokenizer("What is the capital of France?", return_tensors="pt").to(model.device) -#outputs = model.generate(**inputs, max_new_tokens=32) -#print(tokenizer.decode(outputs[0], skip_special_tokens=True)) - - -print("unloading base model") -if isinstance(model, (PeftModel, PeftModelForCausalLM)): - print("Model is a PeftModel. Unloading adapters...") - unwrapped_base_model = model.unload() - model = unwrapped_base_model - #if hasattr(model, 'peft_config') and model.peft_config: - # print("Found lingering adapter configurations. Deleting them now...") - # # Create a static list of keys before iterating and deleting - # for name in list(model.peft_config.keys()): - # if name == "default": - # continue - # print(f"Deleting adapter config: '{name}'") - # mode=model.delete_adapter(name) - #model.disable_adapters() - if hasattr(model, 'peft_config'): - del model.peft_config -print(f"Model type post unloading{type(model)}") -print(f"Model class post unloading: {model.__class__.__name__}") -#print(f"model: {model}") - -#print("generating using base model") -#inputs = tokenizer("What is the capital of Lebanon?", return_tensors="pt").to(model.device) -#outputs = model.generate(**inputs, max_new_tokens=32) -#print(tokenizer.decode(outputs[0], skip_special_tokens=True)) -#print(f"model config: {model.config}") -#print(f"model: {model}") -#print(f"model.peft_config: {model.peft_config}") - -#print("loading lora dapter") -#model.load_adapter(lora_path, adapter_name=adapter_name_to_load) -#model.enable_adapters -#model.set_adapter(adapter_name_to_load) -model = PeftModel.from_pretrained(model, lora_path, adapter_name=adapter_name_to_load) -print(f"Model type {type(model)}") -print(f"Model class: {model.__class__.__name__}") -#print(f"model: {model}") -#print("generating using peft model hopefully") -#inputs = tokenizer("What is the capital of Brasil?", return_tensors="pt").to(model.device) -#outputs = model.generate(**inputs, max_new_tokens=32) -#print(tokenizer.decode(outputs[0], skip_special_tokens=True)) - -print("unloading base model") -if isinstance(model, (PeftModel, PeftModelForCausalLM)): - print("Model is a PeftModel. Unloading adapters...") - unwrapped_base_model = model.unload() - model = unwrapped_base_model - #if hasattr(model, 'peft_config') and model.peft_config: - # print("Found lingering adapter configurations. Deleting them now...") - # # Create a static list of keys before iterating and deleting - # for name in list(model.peft_config.keys()): - # if name == "default": - # continue - # print(f"Deleting adapter config: '{name}'") - # mode=model.delete_adapter(name) - #model.disable_adapters() - if hasattr(model, 'peft_config'): - del model.peft_config -print(f"Model type post unloading{type(model)}") -print(f"Model class post unloading: {model.__class__.__name__}") From 92a1d4cffaeafd2f5862ba01fdfa9ad9772de7a5 Mon Sep 17 00:00:00 2001 From: Manan17 Date: Sun, 15 Feb 2026 06:12:55 +0000 Subject: [PATCH 19/29] Adding hint for password length --- studio/frontend/src/features/auth/components/auth-form.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/studio/frontend/src/features/auth/components/auth-form.tsx b/studio/frontend/src/features/auth/components/auth-form.tsx index e8d531c0b4..f17d2af13b 100644 --- a/studio/frontend/src/features/auth/components/auth-form.tsx +++ b/studio/frontend/src/features/auth/components/auth-form.tsx @@ -199,6 +199,9 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null { )}

+ {!isLoginMode && ( +

Must be at least 8 characters

+ )}
{!isLoginMode && ( @@ -223,7 +226,7 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null { From d752c31baf6be30b18b2037d3dea708bcedbf672 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Sun, 15 Feb 2026 06:29:52 +0000 Subject: [PATCH 20/29] feat: redirect first-time users to signup page instead of login --- studio/frontend/src/app/auth-guards.ts | 14 +++++++++++++- .../src/features/auth/components/auth-form.tsx | 13 ++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/studio/frontend/src/app/auth-guards.ts b/studio/frontend/src/app/auth-guards.ts index 83e4e39a32..6aaa6694ef 100644 --- a/studio/frontend/src/app/auth-guards.ts +++ b/studio/frontend/src/app/auth-guards.ts @@ -12,9 +12,21 @@ async function hasActiveSession(): Promise { return refreshSession(); } +async function checkAuthInitialized(): Promise { + try { + const res = await fetch("/api/auth/status"); + if (!res.ok) return true; // fallback to login on error + const data = (await res.json()) as { initialized: boolean }; + return data.initialized; + } catch { + return true; // fallback to login on error + } +} + export async function requireAuth(): Promise { if (await hasActiveSession()) return; - throw redirect({ to: "/login" }); + const initialized = await checkAuthInitialized(); + throw redirect({ to: initialized ? "/login" : "/signup" }); } export async function requireGuest(): Promise { diff --git a/studio/frontend/src/features/auth/components/auth-form.tsx b/studio/frontend/src/features/auth/components/auth-form.tsx index f17d2af13b..5ed529a9e8 100644 --- a/studio/frontend/src/features/auth/components/auth-form.tsx +++ b/studio/frontend/src/features/auth/components/auth-form.tsx @@ -63,7 +63,18 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null { const response = await fetch("/api/auth/status"); if (!response.ok) throw new Error("Failed to load auth status."); const result = (await response.json()) as AuthStatusResponse; - if (!canceled) setInitialized(result.initialized); + if (!canceled) { + setInitialized(result.initialized); + // Auto-redirect to the correct page based on init state + if (mode === "login" && result.initialized === false) { + navigate({ to: "/signup" }); + return; + } + if (mode === "signup" && result.initialized === true) { + navigate({ to: "/login" }); + return; + } + } } catch (err: unknown) { if (!canceled) { setError(err instanceof Error ? err.message : "Failed to load."); From 94dbd9cc9e428d60b3559bdc5d62adb3382eb5a8 Mon Sep 17 00:00:00 2001 From: Manan17 Date: Sun, 15 Feb 2026 06:34:39 +0000 Subject: [PATCH 21/29] Changing labels in dataset card --- .../frontend/src/features/studio/sections/dataset-section.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/studio/frontend/src/features/studio/sections/dataset-section.tsx b/studio/frontend/src/features/studio/sections/dataset-section.tsx index 890faad2b3..0d325fbefd 100644 --- a/studio/frontend/src/features/studio/sections/dataset-section.tsx +++ b/studio/frontend/src/features/studio/sections/dataset-section.tsx @@ -223,7 +223,7 @@ export function DatasetSection() {
- Dataset Format + Target Format