From f67ee5834779a7bc152ec88c3bf364bd4c29d83a Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Sat, 14 Feb 2026 14:52:13 +0000 Subject: [PATCH 01/10] 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 35a6e4026887b3e736beca22e676238b6e6b87b5 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Sat, 14 Feb 2026 16:57:24 +0000 Subject: [PATCH 02/10] _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 b5c8136957f14b386566b70795d4d1f18f6c7c5f Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Sat, 14 Feb 2026 17:03:52 +0000 Subject: [PATCH 03/10] 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 d0b94eae75504e6c248978511ae135528f9f2f21 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Sat, 14 Feb 2026 17:09:07 +0000 Subject: [PATCH 04/10] 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 6fefbe9f0bb42393c0defa4e777bc1ccb8f84ef6 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Sat, 14 Feb 2026 17:21:26 +0000 Subject: [PATCH 05/10] 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 7d8e991c1ffad688728f3e7396beee8eb4eff720 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Sat, 14 Feb 2026 17:25:37 +0000 Subject: [PATCH 06/10] 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 e7ae90173771c5e0c60342e66757948288850c62 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Sat, 14 Feb 2026 17:32:15 +0000 Subject: [PATCH 07/10] 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 3ff3def555abaa1d6c2ad12aec83b61a2db83530 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Sat, 14 Feb 2026 19:18:49 +0000 Subject: [PATCH 08/10] 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 be3934860f8b929f0501b48536d695ccd3fcdcc6 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Sat, 14 Feb 2026 19:23:51 +0000 Subject: [PATCH 09/10] 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 b334e49498e9491432358876ca57e456ec18ff5f Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Sat, 14 Feb 2026 20:13:50 +0000 Subject: [PATCH 10/10] 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: