Source code for pyrit.prompt_target.hugging_face.hugging_face_chat_target

# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.

import asyncio
import json
import logging
import os
from typing import Optional

from transformers import AutoModelForCausalLM, AutoTokenizer, PretrainedConfig

from pyrit.prompt_target import PromptChatTarget
from pyrit.common.download_hf_model import download_specific_files
from pyrit.models.prompt_request_response import PromptRequestResponse, construct_response_from_request
from pyrit.exceptions import EmptyResponseException, pyrit_target_retry
from pyrit.common import default_values


logger = logging.getLogger(__name__)


[docs] class HuggingFaceChatTarget(PromptChatTarget): """The HuggingFaceChatTarget interacts with HuggingFace models, specifically for conducting red teaming activities. Inherits from PromptTarget to comply with the current design standards. """ # Class-level cache for model and tokenizer _cached_model = None _cached_tokenizer = None _cached_model_id = None # Class-level flag to enable or disable cache _cache_enabled = True # Define the environment variable name for the Hugging Face token HUGGINGFACE_TOKEN_ENVIRONMENT_VARIABLE = "HUGGINGFACE_TOKEN"
[docs] def __init__( self, *, model_id: Optional[str] = None, model_path: Optional[str] = None, hf_access_token: Optional[str] = None, use_cuda: bool = False, tensor_format: str = "pt", necessary_files: list = None, max_new_tokens: int = 20, temperature: float = 1.0, top_p: float = 1.0, skip_special_tokens: bool = True, trust_remote_code: bool = False, ) -> None: super().__init__() if not model_id and not model_path: raise ValueError("Either `model_id` or `model_path` must be provided.") if model_id and model_path: raise ValueError("Provide only one of `model_id` or `model_path`, not both.") self.model_id = model_id self.model_path = model_path self.use_cuda = use_cuda self.tensor_format = tensor_format self.trust_remote_code = trust_remote_code # Only get the Hugging Face token if a model ID is provided if model_id: self.huggingface_token = default_values.get_required_value( env_var_name=self.HUGGINGFACE_TOKEN_ENVIRONMENT_VARIABLE, passed_value=hf_access_token ) else: self.huggingface_token = None try: import torch except ModuleNotFoundError as e: logger.error("Could not import torch. You may need to install it via 'pip install pyrit[all]'") raise e # Determine the device self.device = "cuda" if self.use_cuda and torch.cuda.is_available() else "cpu" logger.info(f"Using device: {self.device}") # Set necessary files if provided, otherwise set to None to trigger general download self.necessary_files = necessary_files # Set the default parameters for the model generation self.max_new_tokens = max_new_tokens self.temperature = temperature self.top_p = top_p self.skip_special_tokens = skip_special_tokens if self.use_cuda and not torch.cuda.is_available(): raise RuntimeError("CUDA requested but not available.") self.load_model_and_tokenizer_task = asyncio.create_task(self.load_model_and_tokenizer())
def _load_from_path(self, path: str): """ Helper function to load the model and tokenizer from a given path. """ logger.info(f"Loading model and tokenizer from path: {path}...") self.tokenizer = AutoTokenizer.from_pretrained(path, trust_remote_code=self.trust_remote_code) self.model = AutoModelForCausalLM.from_pretrained(path, trust_remote_code=self.trust_remote_code)
[docs] def is_model_id_valid(self) -> bool: """ Check if the HuggingFace model ID is valid. :return: True if valid, False otherwise. """ try: # Attempt to load the configuration of the model PretrainedConfig.from_pretrained(self.model_id) return True except Exception as e: logger.error(f"Invalid HuggingFace model ID {self.model_id}: {e}") return False
[docs] async def load_model_and_tokenizer(self): """Loads the model and tokenizer, downloading if necessary. Downloads the model to the HF_MODELS_DIR folder if it does not exist, then loads it from there. Raises: Exception: If the model loading fails. """ try: # Determine the identifier for caching purposes model_identifier = self.model_path or self.model_id # Check if the model is already cached if HuggingFaceChatTarget._cache_enabled and HuggingFaceChatTarget._cached_model_id == model_identifier: logger.info(f"Using cached model and tokenizer for {model_identifier}.") self.model = HuggingFaceChatTarget._cached_model self.tokenizer = HuggingFaceChatTarget._cached_tokenizer return if self.model_path: # Load the tokenizer and model from the local directory logger.info(f"Loading model from local path: {self.model_path}...") self._load_from_path(self.model_path) else: # Define the default Hugging Face cache directory cache_dir = os.path.join( os.path.expanduser("~"), ".cache", "huggingface", "hub", f"models--{self.model_id.replace('/', '--')}", ) if self.necessary_files is None: # Download all files if no specific files are provided logger.info(f"Downloading all files for {self.model_id}...") await download_specific_files(self.model_id, None, self.huggingface_token, cache_dir) else: # Download only the necessary files logger.info(f"Downloading specific files for {self.model_id}...") await download_specific_files( self.model_id, self.necessary_files, self.huggingface_token, cache_dir ) # Load the tokenizer and model from the specified directory logger.info(f"Loading model {self.model_id} from cache path: {cache_dir}...") self.tokenizer = AutoTokenizer.from_pretrained( self.model_id, cache_dir=cache_dir, trust_remote_code=self.trust_remote_code ) self.model = AutoModelForCausalLM.from_pretrained( self.model_id, cache_dir=cache_dir, trust_remote_code=self.trust_remote_code ) # Move the model to the correct device self.model = self.model.to(self.device) # Debug prints to check types logger.info(f"Model loaded: {type(self.model)}") logger.info(f"Tokenizer loaded: {type(self.tokenizer)}") # Cache the loaded model and tokenizer if caching is enabled if HuggingFaceChatTarget._cache_enabled: HuggingFaceChatTarget._cached_model = self.model HuggingFaceChatTarget._cached_tokenizer = self.tokenizer HuggingFaceChatTarget._cached_model_id = model_identifier logger.info(f"Model {model_identifier} loaded successfully.") except Exception as e: logger.error(f"Error loading model {self.model_id}: {e}") raise
[docs] @pyrit_target_retry async def send_prompt_async(self, *, prompt_request: PromptRequestResponse) -> PromptRequestResponse: """ Sends a normalized prompt asynchronously to the HuggingFace model. """ # Load the model and tokenizer using the encapsulated method await self.load_model_and_tokenizer_task self._validate_request(prompt_request=prompt_request) request = prompt_request.request_pieces[0] prompt_template = request.converted_value logger.info(f"Sending the following prompt to the HuggingFace model: {prompt_template}") # Prepare the input messages using chat templates messages = [{"role": "user", "content": prompt_template}] # Apply chat template via the _apply_chat_template method tokenized_chat = self._apply_chat_template(messages) input_ids = tokenized_chat["input_ids"] attention_mask = tokenized_chat["attention_mask"] logger.info(f"Tokenized chat: {input_ids}") try: # Ensure model is on the correct device (should already be the case from `load_model_and_tokenizer`) self.model.to(self.device) # Record the length of the input tokens to later extract only the generated tokens input_length = input_ids.shape[-1] # Generate the response logger.info("Generating response from model...") generated_ids = self.model.generate( input_ids=input_ids, attention_mask=attention_mask, max_new_tokens=self.max_new_tokens, temperature=self.temperature, top_p=self.top_p, ) logger.info(f"Generated IDs: {generated_ids}") # Log the generated IDs # Extract the assistant's response by slicing the generated tokens after the input tokens generated_tokens = generated_ids[0][input_length:] # Decode the assistant's response from the generated token IDs assistant_response = self.tokenizer.decode( generated_tokens, skip_special_tokens=self.skip_special_tokens ).strip() if not assistant_response: raise EmptyResponseException() logger.info(f"Assistant's response: {assistant_response}") model_identifier = self.model_id or self.model_path return construct_response_from_request( request=request, response_text_pieces=[assistant_response], prompt_metadata=json.dumps({"model_id": model_identifier}), ) except Exception as e: logger.error(f"Error occurred during inference: {e}") raise
def _apply_chat_template(self, messages): """ A private method to apply the chat template to the input messages and tokenize them. """ # Check if the tokenizer has a chat template if hasattr(self.tokenizer, "chat_template") and self.tokenizer.chat_template is not None: logger.info("Tokenizer has a chat template. Applying it to the input messages.") # Apply the chat template to format and tokenize the messages tokenized_chat = self.tokenizer.apply_chat_template( messages, tokenize=True, add_generation_prompt=True, return_tensors=self.tensor_format, return_dict=True, ).to(self.device) return tokenized_chat else: error_message = ( "Tokenizer does not have a chat template. " "This model is not supported, as we only support instruct models with a chat template." ) logger.error(error_message) raise ValueError(error_message) def _validate_request(self, *, prompt_request: PromptRequestResponse) -> None: """ Validates the provided prompt request response. """ if len(prompt_request.request_pieces) != 1: raise ValueError("This target only supports a single prompt request piece.") if prompt_request.request_pieces[0].converted_value_data_type != "text": raise ValueError("This target only supports text prompt input.")
[docs] @classmethod def enable_cache(cls): """Enables the class-level cache.""" cls._cache_enabled = True logger.info("Class-level cache enabled.")
[docs] @classmethod def disable_cache(cls): """Disables the class-level cache and clears the cache.""" cls._cache_enabled = False cls._cached_model = None cls._cached_tokenizer = None cls._cached_model_id = None logger.info("Class-level cache disabled and cleared.")