diff --git a/.env_example b/.env_example index 2d63d6691..d77940bcd 100644 --- a/.env_example +++ b/.env_example @@ -35,6 +35,16 @@ AZURE_OPENAI_GPT4_CHAT_ENDPOINT="https://xxxxx.openai.azure.com/openai/v1" AZURE_OPENAI_GPT4_CHAT_KEY="xxxxx" AZURE_OPENAI_GPT4_CHAT_MODEL="deployment-name" +# Endpoints that host models with fewer safety mechanisms (e.g. via adversarial fine tuning +# or content filters turned off) can be defined below and used in adversarial attack testing scenarios. +AZURE_OPENAI_GPT4O_UNSAFE_CHAT_ENDPOINT="https://xxxxx.openai.azure.com/openai/v1" +AZURE_OPENAI_GPT4O_UNSAFE_CHAT_KEY="xxxxx" +AZURE_OPENAI_GPT4O_UNSAFE_CHAT_MODEL="deployment-name" + +AZURE_OPENAI_GPT4O_UNSAFE_CHAT_ENDPOINT2="https://xxxxx.openai.azure.com/openai/v1" +AZURE_OPENAI_GPT4O_UNSAFE_CHAT_KEY2="xxxxx" +AZURE_OPENAI_GPT4O_UNSAFE_CHAT_MODEL2="deployment-name" + AZURE_FOUNDRY_DEEPSEEK_ENDPOINT="https://xxxxx.eastus2.models.ai.azure.com" AZURE_FOUNDRY_DEEPSEEK_KEY="xxxxx" diff --git a/doc/api.rst b/doc/api.rst index c475c1923..bf94fd6d3 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -681,6 +681,7 @@ API Reference PyRITInitializer AIRTInitializer + AIRTTargetInitializer SimpleInitializer LoadDefaultDatasets ScenarioObjectiveListInitializer diff --git a/pyrit/prompt_target/common/prompt_target.py b/pyrit/prompt_target/common/prompt_target.py index 78b466e04..0a140d8c9 100644 --- a/pyrit/prompt_target/common/prompt_target.py +++ b/pyrit/prompt_target/common/prompt_target.py @@ -95,8 +95,6 @@ def get_identifier(self) -> Dict[str, Any]: Get an identifier dictionary for this prompt target. This includes essential attributes needed for scorer evaluation and registry tracking. - Subclasses should override this method to include additional relevant attributes - (e.g., temperature, top_p) when available. Returns: Dict[str, Any]: A dictionary containing identification attributes. diff --git a/pyrit/registry/__init__.py b/pyrit/registry/__init__.py index ba0566020..4111d2cc2 100644 --- a/pyrit/registry/__init__.py +++ b/pyrit/registry/__init__.py @@ -21,6 +21,8 @@ BaseInstanceRegistry, ScorerMetadata, ScorerRegistry, + TargetMetadata, + TargetRegistry, ) from pyrit.registry.name_utils import class_name_to_registry_name, registry_name_to_class_name @@ -41,4 +43,6 @@ "ScenarioRegistry", "ScorerMetadata", "ScorerRegistry", + "TargetMetadata", + "TargetRegistry", ] diff --git a/pyrit/registry/instance_registries/__init__.py b/pyrit/registry/instance_registries/__init__.py index b2b1fad0f..00d62cfe9 100644 --- a/pyrit/registry/instance_registries/__init__.py +++ b/pyrit/registry/instance_registries/__init__.py @@ -18,6 +18,10 @@ ScorerMetadata, ScorerRegistry, ) +from pyrit.registry.instance_registries.target_registry import ( + TargetMetadata, + TargetRegistry, +) __all__ = [ # Base class @@ -25,4 +29,6 @@ # Concrete registries "ScorerRegistry", "ScorerMetadata", + "TargetRegistry", + "TargetMetadata", ] diff --git a/pyrit/registry/instance_registries/target_registry.py b/pyrit/registry/instance_registries/target_registry.py new file mode 100644 index 000000000..152380131 --- /dev/null +++ b/pyrit/registry/instance_registries/target_registry.py @@ -0,0 +1,145 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Target registry for discovering and managing PyRIT prompt targets. + +Targets are registered explicitly via initializers as pre-configured instances. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Dict, Optional + +from pyrit.registry.base import RegistryItemMetadata +from pyrit.registry.instance_registries.base_instance_registry import ( + BaseInstanceRegistry, +) +from pyrit.registry.name_utils import class_name_to_registry_name + +if TYPE_CHECKING: + from pyrit.prompt_target import PromptTarget + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class TargetMetadata(RegistryItemMetadata): + """ + Metadata describing a registered target instance. + + Unlike ScenarioMetadata/InitializerMetadata which describe classes, + TargetMetadata describes an already-instantiated prompt target. + + Use get() to retrieve the actual target instance. + """ + + target_identifier: Dict[str, Any] + + +class TargetRegistry(BaseInstanceRegistry["PromptTarget", TargetMetadata]): + """ + Registry for managing available prompt target instances. + + This registry stores pre-configured PromptTarget instances (not classes). + Targets are registered explicitly via initializers after being instantiated + with their required parameters (e.g., endpoint, API keys). + + Targets are identified by their snake_case name derived from the class name, + or a custom name provided during registration. + """ + + @classmethod + def get_registry_singleton(cls) -> "TargetRegistry": + """ + Get the singleton instance of the TargetRegistry. + + Returns: + The singleton TargetRegistry instance. + """ + return super().get_registry_singleton() # type: ignore[return-value] + + def register_instance( + self, + target: "PromptTarget", + *, + name: Optional[str] = None, + ) -> None: + """ + Register a target instance. + + Note: Unlike ScenarioRegistry and InitializerRegistry which register classes, + TargetRegistry registers pre-configured instances. + + Args: + target: The pre-configured target instance (not a class). + name: Optional custom registry name. If not provided, + derived from class name with identifier hash appended + (e.g., OpenAIChatTarget -> openai_chat_abc123). + """ + if name is None: + base_name = class_name_to_registry_name(target.__class__.__name__, suffix="Target") + # Append identifier hash for uniqueness + identifier_hash = self._compute_identifier_hash(target)[:8] + name = f"{base_name}_{identifier_hash}" + + self.register(target, name=name) + logger.debug(f"Registered target instance: {name} ({target.__class__.__name__})") + + def get_instance_by_name(self, name: str) -> Optional["PromptTarget"]: + """ + Get a registered target instance by name. + + Note: This returns an already-instantiated target, not a class. + + Args: + name: The registry name of the target. + + Returns: + The target instance, or None if not found. + """ + return self.get(name) + + def _build_metadata(self, name: str, instance: "PromptTarget") -> TargetMetadata: + """ + Build metadata for a target instance. + + Args: + name: The registry name of the target. + instance: The target instance. + + Returns: + TargetMetadata describing the target. + """ + # Get description from docstring + doc = instance.__class__.__doc__ or "" + description = " ".join(doc.split()) if doc else "No description available" + + # Get identifier from the target + target_identifier = instance.get_identifier() + + return TargetMetadata( + name=name, + class_name=instance.__class__.__name__, + description=description, + target_identifier=target_identifier, + ) + + @staticmethod + def _compute_identifier_hash(target: "PromptTarget") -> str: + """ + Compute a hash from the target's identifier for unique naming. + + Args: + target: The target instance. + + Returns: + A hex string hash of the identifier. + """ + identifier = target.get_identifier() + identifier_str = json.dumps(identifier, sort_keys=True) + return hashlib.sha256(identifier_str.encode()).hexdigest() diff --git a/pyrit/setup/initializers/__init__.py b/pyrit/setup/initializers/__init__.py index 1c0cbd468..6b1c63c48 100644 --- a/pyrit/setup/initializers/__init__.py +++ b/pyrit/setup/initializers/__init__.py @@ -4,6 +4,7 @@ """PyRIT initializers package.""" from pyrit.setup.initializers.airt import AIRTInitializer +from pyrit.setup.initializers.airt_targets import AIRTTargetInitializer from pyrit.setup.initializers.pyrit_initializer import PyRITInitializer from pyrit.setup.initializers.scenarios.load_default_datasets import LoadDefaultDatasets from pyrit.setup.initializers.scenarios.objective_list import ScenarioObjectiveListInitializer @@ -13,6 +14,7 @@ __all__ = [ "PyRITInitializer", "AIRTInitializer", + "AIRTTargetInitializer", "SimpleInitializer", "LoadDefaultDatasets", "ScenarioObjectiveListInitializer", diff --git a/pyrit/setup/initializers/airt_targets.py b/pyrit/setup/initializers/airt_targets.py new file mode 100644 index 000000000..7029484f4 --- /dev/null +++ b/pyrit/setup/initializers/airt_targets.py @@ -0,0 +1,223 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +AIRT Target Initializer for registering pre-configured targets from environment variables. + +This module provides the AIRTTargetInitializer class that registers available +targets into the TargetRegistry based on environment variable configuration. +""" + +import logging +import os +from dataclasses import dataclass +from typing import Any, List, Optional, Type + +from pyrit.prompt_target import ( + OpenAIChatTarget, + OpenAIImageTarget, + OpenAIResponseTarget, + OpenAITTSTarget, + OpenAIVideoTarget, + PromptShieldTarget, + PromptTarget, + RealtimeTarget, +) +from pyrit.registry import TargetRegistry +from pyrit.setup.initializers.pyrit_initializer import PyRITInitializer + +logger = logging.getLogger(__name__) + + +@dataclass +class TargetConfig: + """Configuration for a target to be registered.""" + + registry_name: str + target_class: Type[PromptTarget] + endpoint_var: str + key_var: str + model_var: Optional[str] = None + underlying_model_var: Optional[str] = None + + +# Define all supported target configurations +TARGET_CONFIGS: List[TargetConfig] = [ + TargetConfig( + registry_name="default_openai_frontend", + target_class=OpenAIChatTarget, + endpoint_var="DEFAULT_OPENAI_FRONTEND_ENDPOINT", + key_var="DEFAULT_OPENAI_FRONTEND_KEY", + model_var="DEFAULT_OPENAI_FRONTEND_MODEL", + underlying_model_var="DEFAULT_OPENAI_FRONTEND_UNDERLYING_MODEL", + ), + TargetConfig( + registry_name="openai_chat", + target_class=OpenAIChatTarget, + endpoint_var="OPENAI_CHAT_ENDPOINT", + key_var="OPENAI_CHAT_KEY", + model_var="OPENAI_CHAT_MODEL", + underlying_model_var="OPENAI_CHAT_UNDERLYING_MODEL", + ), + TargetConfig( + registry_name="openai_responses", + target_class=OpenAIResponseTarget, + endpoint_var="OPENAI_RESPONSES_ENDPOINT", + key_var="OPENAI_RESPONSES_KEY", + model_var="OPENAI_RESPONSES_MODEL", + underlying_model_var="OPENAI_RESPONSES_UNDERLYING_MODEL", + ), + TargetConfig( + registry_name="azure_gpt4o_unsafe_chat", + target_class=OpenAIChatTarget, + endpoint_var="AZURE_OPENAI_GPT4O_UNSAFE_CHAT_ENDPOINT", + key_var="AZURE_OPENAI_GPT4O_UNSAFE_CHAT_KEY", + model_var="AZURE_OPENAI_GPT4O_UNSAFE_CHAT_MODEL", + underlying_model_var="AZURE_OPENAI_GPT4O_UNSAFE_CHAT_UNDERLYING_MODEL", + ), + TargetConfig( + registry_name="azure_gpt4o_unsafe_chat2", + target_class=OpenAIChatTarget, + endpoint_var="AZURE_OPENAI_GPT4O_UNSAFE_CHAT_ENDPOINT2", + key_var="AZURE_OPENAI_GPT4O_UNSAFE_CHAT_KEY2", + model_var="AZURE_OPENAI_GPT4O_UNSAFE_CHAT_MODEL2", + underlying_model_var="AZURE_OPENAI_GPT4O_UNSAFE_CHAT_UNDERLYING_MODEL2", + ), + TargetConfig( + registry_name="openai_realtime", + target_class=RealtimeTarget, + endpoint_var="OPENAI_REALTIME_ENDPOINT", + key_var="OPENAI_REALTIME_API_KEY", + model_var="OPENAI_REALTIME_MODEL", + underlying_model_var="OPENAI_REALTIME_UNDERLYING_MODEL", + ), + TargetConfig( + registry_name="openai_image", + target_class=OpenAIImageTarget, + endpoint_var="OPENAI_IMAGE_ENDPOINT", + key_var="OPENAI_IMAGE_API_KEY", + model_var="OPENAI_IMAGE_MODEL", + underlying_model_var="OPENAI_IMAGE_UNDERLYING_MODEL", + ), + TargetConfig( + registry_name="openai_tts", + target_class=OpenAITTSTarget, + endpoint_var="OPENAI_TTS_ENDPOINT", + key_var="OPENAI_TTS_KEY", + model_var="OPENAI_TTS_MODEL", + underlying_model_var="OPENAI_TTS_UNDERLYING_MODEL", + ), + TargetConfig( + registry_name="openai_video", + target_class=OpenAIVideoTarget, + endpoint_var="OPENAI_VIDEO_ENDPOINT", + key_var="OPENAI_VIDEO_KEY", + model_var="OPENAI_VIDEO_MODEL", + underlying_model_var="OPENAI_VIDEO_UNDERLYING_MODEL", + ), + TargetConfig( + registry_name="azure_content_safety", + target_class=PromptShieldTarget, + endpoint_var="AZURE_CONTENT_SAFETY_API_ENDPOINT", + key_var="AZURE_CONTENT_SAFETY_API_KEY", + ), +] + + +class AIRTTargetInitializer(PyRITInitializer): + """ + AIRT Target Initializer for registering pre-configured targets. + + This initializer scans for known endpoint environment variables and registers + the corresponding targets into the TargetRegistry. Unlike AIRTInitializer, + this initializer does not require any environment variables - it simply + registers whatever endpoints are available. + + Supported Endpoints: + - DEFAULT_OPENAI_FRONTEND_ENDPOINT: Default OpenAI frontend (OpenAIChatTarget) + - OPENAI_CHAT_ENDPOINT: OpenAI Chat API (OpenAIChatTarget) + - OPENAI_RESPONSES_ENDPOINT: OpenAI Responses API (OpenAIResponseTarget) + - AZURE_OPENAI_GPT4O_UNSAFE_CHAT_ENDPOINT: Azure OpenAI GPT-4o unsafe (OpenAIChatTarget) + - AZURE_OPENAI_GPT4O_UNSAFE_CHAT_ENDPOINT2: Azure OpenAI GPT-4o unsafe secondary (OpenAIChatTarget) + - OPENAI_REALTIME_ENDPOINT: OpenAI Realtime API (RealtimeTarget) + - OPENAI_IMAGE_ENDPOINT: OpenAI Image Generation (OpenAIImageTarget) + - OPENAI_TTS_ENDPOINT: OpenAI Text-to-Speech (OpenAITTSTarget) + - OPENAI_VIDEO_ENDPOINT: OpenAI Video Generation (OpenAIVideoTarget) + - AZURE_CONTENT_SAFETY_API_ENDPOINT: Azure Content Safety (PromptShieldTarget) + + Example: + initializer = AIRTTargetInitializer() + await initializer.initialize_async() + """ + + def __init__(self) -> None: + """Initialize the AIRT Target Initializer.""" + super().__init__() + + @property + def name(self) -> str: + """Get the name of this initializer.""" + return "AIRT Target Initializer" + + @property + def description(self) -> str: + """Get the description of this initializer.""" + return ( + "Instantiates a collection of (AI Red Team suggested) targets from " + "available environment variables and adds them to the TargetRegistry" + ) + + @property + def required_env_vars(self) -> List[str]: + """ + Get list of required environment variables. + + Returns empty list since this initializer is optional - it registers + whatever endpoints are available without requiring any. + """ + return [] + + async def initialize_async(self) -> None: + """ + Register available targets based on environment variables. + + Scans for known endpoint environment variables and registers the + corresponding targets into the TargetRegistry. + """ + for config in TARGET_CONFIGS: + self._register_target(config) + + def _register_target(self, config: TargetConfig) -> None: + """ + Register a target if its required environment variables are set. + + Args: + config: The target configuration specifying env vars and target class. + """ + endpoint = os.getenv(config.endpoint_var) + api_key = os.getenv(config.key_var) + + if not endpoint or not api_key: + return + + model_name = os.getenv(config.model_var) if config.model_var else None + underlying_model = os.getenv(config.underlying_model_var) if config.underlying_model_var else None + + # Build kwargs for the target constructor + kwargs: dict[str, Any] = { + "endpoint": endpoint, + "api_key": api_key, + } + + # Only add model_name if the target supports it (PromptShieldTarget doesn't) + if model_name: + kwargs["model_name"] = model_name + + # Add underlying_model if specified (for Azure deployments where name differs from model) + if underlying_model: + kwargs["underlying_model"] = underlying_model + + target = config.target_class(**kwargs) + registry = TargetRegistry.get_registry_singleton() + registry.register_instance(target, name=config.registry_name) + logger.info(f"Registered target: {config.registry_name}") diff --git a/tests/unit/registry/test_target_registry.py b/tests/unit/registry/test_target_registry.py new file mode 100644 index 000000000..da313be97 --- /dev/null +++ b/tests/unit/registry/test_target_registry.py @@ -0,0 +1,291 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + + +import pytest + +from pyrit.models import Message, MessagePiece +from pyrit.prompt_target import PromptTarget +from pyrit.registry.instance_registries.target_registry import TargetRegistry + + +class MockPromptTarget(PromptTarget): + """Mock PromptTarget for testing.""" + + def __init__(self, *, model_name: str = "mock_model") -> None: + super().__init__(model_name=model_name) + + async def send_prompt_async( + self, + *, + message: Message, + ) -> list[Message]: + return [ + MessagePiece( + role="assistant", + original_value="mock response", + ).to_message() + ] + + def _validate_request(self, *, message: Message) -> None: + pass + + async def dispose_async(self) -> None: + pass + + +class MockChatTarget(PromptTarget): + """Mock chat target for testing different target types.""" + + def __init__(self, *, endpoint: str = "http://test") -> None: + super().__init__(endpoint=endpoint) + + async def send_prompt_async( + self, + *, + message: Message, + ) -> list[Message]: + return [ + MessagePiece( + role="assistant", + original_value="chat response", + ).to_message() + ] + + def _validate_request(self, *, message: Message) -> None: + pass + + async def dispose_async(self) -> None: + pass + + +class TestTargetRegistrySingleton: + """Tests for the singleton pattern in TargetRegistry.""" + + def setup_method(self): + """Reset the singleton before each test.""" + TargetRegistry.reset_instance() + + def teardown_method(self): + """Reset the singleton after each test.""" + TargetRegistry.reset_instance() + + def test_get_registry_singleton_returns_same_instance(self): + """Test that get_registry_singleton returns the same singleton each time.""" + instance1 = TargetRegistry.get_registry_singleton() + instance2 = TargetRegistry.get_registry_singleton() + + assert instance1 is instance2 + + def test_get_registry_singleton_returns_target_registry_type(self): + """Test that get_registry_singleton returns a TargetRegistry instance.""" + instance = TargetRegistry.get_registry_singleton() + assert isinstance(instance, TargetRegistry) + + def test_reset_instance_clears_singleton(self): + """Test that reset_instance clears the singleton.""" + instance1 = TargetRegistry.get_registry_singleton() + TargetRegistry.reset_instance() + instance2 = TargetRegistry.get_registry_singleton() + + assert instance1 is not instance2 + + +@pytest.mark.usefixtures("patch_central_database") +class TestTargetRegistryRegisterInstance: + """Tests for register_instance functionality in TargetRegistry.""" + + def setup_method(self): + """Reset and get a fresh registry for each test.""" + TargetRegistry.reset_instance() + self.registry = TargetRegistry.get_registry_singleton() + + def teardown_method(self): + """Reset the singleton after each test.""" + TargetRegistry.reset_instance() + + def test_register_instance_with_custom_name(self): + """Test registering a target with a custom name.""" + target = MockPromptTarget() + self.registry.register_instance(target, name="custom_target") + + assert "custom_target" in self.registry + assert self.registry.get("custom_target") is target + + def test_register_instance_generates_name_from_class(self): + """Test that register_instance generates a name from class name when not provided.""" + target = MockPromptTarget() + self.registry.register_instance(target) + + # Name should be derived from class name with hash suffix + names = self.registry.get_names() + assert len(names) == 1 + assert names[0].startswith("mock_prompt_") + + def test_register_instance_multiple_targets_unique_names(self): + """Test registering multiple targets generates unique names.""" + target1 = MockPromptTarget() + target2 = MockChatTarget() + + self.registry.register_instance(target1) + self.registry.register_instance(target2) + + assert len(self.registry) == 2 + + def test_register_instance_same_target_type_different_config(self): + """Test that same target class with different configs can be registered.""" + target1 = MockPromptTarget(model_name="model_a") + target2 = MockPromptTarget(model_name="model_b") + + # Register with explicit names + self.registry.register_instance(target1, name="target_1") + self.registry.register_instance(target2, name="target_2") + + assert len(self.registry) == 2 + + +@pytest.mark.usefixtures("patch_central_database") +class TestTargetRegistryGetInstanceByName: + """Tests for get_instance_by_name functionality in TargetRegistry.""" + + def setup_method(self): + """Reset and get a fresh registry for each test.""" + TargetRegistry.reset_instance() + self.registry = TargetRegistry.get_registry_singleton() + self.target = MockPromptTarget() + self.registry.register_instance(self.target, name="test_target") + + def teardown_method(self): + """Reset the singleton after each test.""" + TargetRegistry.reset_instance() + + def test_get_instance_by_name_returns_target(self): + """Test getting a registered target by name.""" + result = self.registry.get_instance_by_name("test_target") + assert result is self.target + + def test_get_instance_by_name_nonexistent_returns_none(self): + """Test that getting a non-existent target returns None.""" + result = self.registry.get_instance_by_name("nonexistent") + assert result is None + + +@pytest.mark.usefixtures("patch_central_database") +class TestTargetRegistryBuildMetadata: + """Tests for _build_metadata functionality in TargetRegistry.""" + + def setup_method(self): + """Reset and get a fresh registry for each test.""" + TargetRegistry.reset_instance() + self.registry = TargetRegistry.get_registry_singleton() + + def teardown_method(self): + """Reset the singleton after each test.""" + TargetRegistry.reset_instance() + + def test_build_metadata_includes_class_name(self): + """Test that metadata includes the class name.""" + target = MockPromptTarget() + self.registry.register_instance(target, name="mock_target") + + metadata = self.registry.list_metadata() + assert len(metadata) == 1 + assert metadata[0].class_name == "MockPromptTarget" + assert metadata[0].name == "mock_target" + + def test_build_metadata_includes_target_identifier(self): + """Test that metadata includes the target_identifier.""" + target = MockPromptTarget(model_name="test_model") + self.registry.register_instance(target, name="mock_target") + + metadata = self.registry.list_metadata() + assert hasattr(metadata[0], "target_identifier") + assert isinstance(metadata[0].target_identifier, dict) + assert metadata[0].target_identifier.get("model_name") == "test_model" + + def test_build_metadata_description_from_docstring(self): + """Test that description is derived from the target's docstring.""" + target = MockPromptTarget() + self.registry.register_instance(target, name="mock_target") + + metadata = self.registry.list_metadata() + # MockPromptTarget has a docstring + assert "Mock PromptTarget for testing" in metadata[0].description + + +@pytest.mark.usefixtures("patch_central_database") +class TestTargetRegistryListMetadata: + """Tests for list_metadata in TargetRegistry.""" + + def setup_method(self): + """Reset and get a fresh registry with multiple targets.""" + TargetRegistry.reset_instance() + self.registry = TargetRegistry.get_registry_singleton() + + self.target1 = MockPromptTarget(model_name="model_a") + self.target2 = MockPromptTarget(model_name="model_b") + self.chat_target = MockChatTarget() + + self.registry.register_instance(self.target1, name="target_1") + self.registry.register_instance(self.target2, name="target_2") + self.registry.register_instance(self.chat_target, name="chat_target") + + def teardown_method(self): + """Reset the singleton after each test.""" + TargetRegistry.reset_instance() + + def test_list_metadata_returns_all_registered(self): + """Test that list_metadata returns metadata for all registered targets.""" + metadata = self.registry.list_metadata() + assert len(metadata) == 3 + + def test_list_metadata_filter_by_class_name(self): + """Test filtering metadata by class_name.""" + mock_metadata = self.registry.list_metadata(include_filters={"class_name": "MockPromptTarget"}) + + assert len(mock_metadata) == 2 + for m in mock_metadata: + assert m.class_name == "MockPromptTarget" + + +@pytest.mark.usefixtures("patch_central_database") +class TestTargetRegistryComputeIdentifierHash: + """Tests for _compute_identifier_hash functionality.""" + + def setup_method(self): + """Reset the singleton before each test.""" + TargetRegistry.reset_instance() + + def teardown_method(self): + """Reset the singleton after each test.""" + TargetRegistry.reset_instance() + + def test_compute_identifier_hash_deterministic(self): + """Test that identifier hash is deterministic for same config.""" + target1 = MockPromptTarget(model_name="same_model") + target2 = MockPromptTarget(model_name="same_model") + + hash1 = TargetRegistry._compute_identifier_hash(target1) + hash2 = TargetRegistry._compute_identifier_hash(target2) + + assert hash1 == hash2 + + def test_compute_identifier_hash_different_for_different_config(self): + """Test that identifier hash is different for different configs.""" + target1 = MockPromptTarget(model_name="model_a") + target2 = MockPromptTarget(model_name="model_b") + + hash1 = TargetRegistry._compute_identifier_hash(target1) + hash2 = TargetRegistry._compute_identifier_hash(target2) + + assert hash1 != hash2 + + def test_compute_identifier_hash_is_string(self): + """Test that identifier hash returns a hex string.""" + target = MockPromptTarget() + hash_value = TargetRegistry._compute_identifier_hash(target) + + assert isinstance(hash_value, str) + # Should be a valid hex string (SHA256 = 64 hex chars) + assert len(hash_value) == 64 + assert all(c in "0123456789abcdef" for c in hash_value)