Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion doc/code/memory/3_memory_data_types.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ Identifiers are content-addressed: the same configuration always produces the sa

### Composite Identifiers

For atomic attacks, `build_atomic_attack_identifier` composes a tree of identifiers:
For atomic attacks, `AtomicAttackIdentifier.build` composes a tree of identifiers:

- **`attack_technique`** — the attack strategy and its children (target, converters, scorer, technique seeds)
- **`seed_identifiers`** — all seeds from the seed group, for traceability
Expand Down
2 changes: 1 addition & 1 deletion doc/code/targets/0_prompt_targets.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ A `TargetConfiguration` composes three concerns:

Each target class defines defaults; instances can override individual capabilities when they depend on deployment configuration (e.g. `HTTPTarget`, `PlaywrightTarget`).

For well-known underlying models, you can look up a profile with `TargetCapabilities.get_known_capabilities(underlying_model="gpt-4o")`.
For well-known underlying models, you can look up a profile with `get_known_capabilities(underlying_model="gpt-4o")` from `pyrit.prompt_target`.

### How consumers use capabilities

Expand Down
2 changes: 1 addition & 1 deletion doc/code/targets/6_1_target_capabilities.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@
"\n",
"Each target class declares a `_DEFAULT_CONFIGURATION` class attribute. For well-known underlying models,\n",
"`get_default_configuration(underlying_model=...)` returns a richer profile from\n",
"`TargetCapabilities.get_known_capabilities` — for example, `gpt-5` gains `supports_json_schema=True`\n",
"`get_known_capabilities` — for example, `gpt-5` gains `supports_json_schema=True`\n",
"and other models pick up the right modality combinations automatically. Unknown models fall back to\n",
"the class default."
]
Expand Down
2 changes: 1 addition & 1 deletion doc/code/targets/6_1_target_capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
#
# Each target class declares a `_DEFAULT_CONFIGURATION` class attribute. For well-known underlying models,
# `get_default_configuration(underlying_model=...)` returns a richer profile from
# `TargetCapabilities.get_known_capabilities` — for example, `gpt-5` gains `supports_json_schema=True`
# `get_known_capabilities` — for example, `gpt-5` gains `supports_json_schema=True`
# and other models pick up the right modality combinations automatically. Unknown models fall back to
# the class default.

Expand Down
29 changes: 16 additions & 13 deletions pyrit/backend/mappers/converter_mappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,18 @@

"""
Converter mappers – domain → DTO translation for converter-related models.

Identity vs. presentation:
``ConverterIdentifier`` is the typed, lossless
*identity* projection of a converter's ``ComponentIdentifier``;
``ConverterInstance`` is the backend *presentation* view (adds ``converter_id``
binding, ``display_name``, and ``sub_converter_ids``).
"""

from pyrit.backend.models.converters import ConverterInstance
from pyrit.backend.models import ConverterInstance
from pyrit.models import ConverterIdentifier
from pyrit.prompt_converter import PromptConverter

# Base keys from PromptConverter._create_identifier that are NOT converter-specific
_BASE_CONVERTER_PARAM_KEYS = {
"supported_input_types",
"supported_output_types",
}


def converter_object_to_instance(
converter_id: str,
Expand All @@ -35,17 +36,19 @@ def converter_object_to_instance(
Returns:
ConverterInstance DTO with metadata derived from the object.
"""
identifier = converter_obj.get_identifier()
converter_identifier = ConverterIdentifier.from_component_identifier(converter_obj.get_identifier())

supported_input = identifier.params.get("supported_input_types")
supported_output = identifier.params.get("supported_output_types")
supported_input = converter_identifier.supported_input_types
supported_output = converter_identifier.supported_output_types

# Extract converter-specific params by filtering out base keys
converter_specific = {k: v for k, v in identifier.params.items() if k not in _BASE_CONVERTER_PARAM_KEYS} or None
# supported_input/output_types are promoted to typed fields and mirrored into
# params; strip them so only converter-specific params remain.
promoted_param_names = set(ConverterIdentifier._promoted_param_fields())
converter_specific = {k: v for k, v in converter_identifier.params.items() if k not in promoted_param_names} or None

return ConverterInstance(
converter_id=converter_id,
converter_type=identifier.class_name,
converter_type=converter_identifier.class_name,
display_name=None,
supported_input_types=list(supported_input) if supported_input else [],
supported_output_types=list(supported_output) if supported_output else [],
Expand Down
68 changes: 39 additions & 29 deletions pyrit/backend/mappers/target_mappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,17 @@

"""
Target mappers – domain → DTO translation for target-related models.

Identity vs. presentation: ``TargetIdentifier``
is the typed, lossless *identity* projection of a target's
``ComponentIdentifier``. ``TargetInstance`` is the backend *presentation* view —
it adds registry binding (``target_registry_name``), flattened capabilities, and
composite ``inner_targets`` for the frontend. These mappers read typed fields off
``TargetIdentifier`` instead of poking ``identifier.params`` by string key.
"""

from pyrit.backend.models.targets import TargetCapabilitiesInfo, TargetInstance
from pyrit.backend.models import TargetCapabilitiesInfo, TargetInstance
from pyrit.models import TargetIdentifier
from pyrit.prompt_target import PromptTarget
from pyrit.prompt_target.common.target_capabilities import CapabilityName, TargetCapabilities
from pyrit.prompt_target.round_robin_target import RoundRobinTarget
Expand All @@ -15,7 +23,9 @@
_CAPABILITY_PARAM_NAMES = frozenset(cap.value for cap in CapabilityName)


def _target_capabilities_to_info(capabilities: TargetCapabilities) -> TargetCapabilitiesInfo:
def _target_capabilities_to_info(
capabilities: TargetCapabilities,
) -> TargetCapabilitiesInfo:
"""
Build a TargetCapabilitiesInfo DTO from a domain TargetCapabilities object.

Expand Down Expand Up @@ -55,27 +65,27 @@ def target_object_to_instance(target_registry_name: str, target_obj: PromptTarge
Returns:
TargetInstance DTO with metadata derived from the object.
"""
identifier = target_obj.get_identifier()
params = identifier.params

# Keys that are extracted as top-level TargetInstance fields, are internal-only
# (e.g., target_configuration is the verbose capabilities blob), or duplicate
# capability flags (filtered via _CAPABILITY_PARAM_NAMES) — those are sourced
# solely from target_obj.capabilities and must not leak into target_specific_params.
extracted_keys = {
"endpoint",
"model_name",
"underlying_model_name",
"temperature",
"top_p",
"max_requests_per_minute",
"target_specific_params",
"target_configuration",
} | _CAPABILITY_PARAM_NAMES
target_identifier = TargetIdentifier.from_component_identifier(target_obj.get_identifier())

# Promoted params (endpoint, model_name, …) are mirrored into params and also
# exposed as typed fields; strip them so they don't leak into
# target_specific_params. Capabilities are no longer part of the identifier at
# all. The strip set is also defensive: it drops the explicit
# target_specific_params bag (merged in separately) plus any legacy capability /
# configuration keys that might appear in older persisted identifiers.
extracted_keys = (
{
"target_specific_params",
"target_configuration",
}
| _CAPABILITY_PARAM_NAMES
| set(TargetIdentifier._promoted_param_fields())
)

# Collect remaining params as target_specific_params so the frontend can display them
explicit_specific = params.get("target_specific_params") or {}
extra = {k: v for k, v in params.items() if k not in extracted_keys and v is not None}
raw_specific = target_identifier.params.get("target_specific_params")
explicit_specific = raw_specific if isinstance(raw_specific, dict) else {}
extra = {k: v for k, v in target_identifier.params.items() if k not in extracted_keys and v is not None}
combined_specific = {**extra, **explicit_specific} or None

inner_targets = _build_inner_targets(target_obj)
Expand All @@ -84,8 +94,8 @@ def target_object_to_instance(target_registry_name: str, target_obj: PromptTarge
# only when ALL inner targets share the same deployment name. When they differ
# (e.g. "gpt-4o-japan-nilfilter" vs "pyrit-github-gpt4"), show "—" for
# consistency with how other targets display model_name.
model_name = params.get("model_name") or None
underlying_model_name = params.get("underlying_model_name") or None
model_name = target_identifier.model_name or None
underlying_model_name = target_identifier.underlying_model_name or None
if model_name is None and inner_targets:
inner_models = {t.model_name for t in inner_targets}
model_name = inner_models.pop() if len(inner_models) == 1 else None
Expand All @@ -95,17 +105,17 @@ def target_object_to_instance(target_registry_name: str, target_obj: PromptTarge

return TargetInstance(
target_registry_name=target_registry_name,
target_type=identifier.class_name,
endpoint=params.get("endpoint") or None,
target_type=target_identifier.class_name,
endpoint=target_identifier.endpoint or None,
model_name=model_name,
underlying_model_name=underlying_model_name,
temperature=params.get("temperature"),
top_p=params.get("top_p"),
max_requests_per_minute=params.get("max_requests_per_minute"),
temperature=target_identifier.temperature,
top_p=target_identifier.top_p,
max_requests_per_minute=target_identifier.max_requests_per_minute,
capabilities=_target_capabilities_to_info(target_obj.capabilities),
target_specific_params=combined_specific,
inner_targets=inner_targets,
identifier_hash=identifier.hash,
identifier_hash=target_identifier.hash,
)


Expand Down
9 changes: 5 additions & 4 deletions pyrit/backend/services/attack_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@
from pyrit.backend.services.target_service import get_target_service
from pyrit.memory import CentralMemory, data_serializer_factory
from pyrit.models import (
AtomicAttackIdentifier,
AttackIdentifier,
AttackOutcome,
AttackResult,
ComponentIdentifier,
Expand All @@ -60,7 +62,6 @@
ConversationType,
MessagePiece,
PromptDataType,
build_atomic_attack_identifier,
)
from pyrit.prompt_normalizer import PromptConverterConfiguration, PromptNormalizer

Expand Down Expand Up @@ -322,11 +323,11 @@ async def create_attack_async(self, *, request: CreateAttackRequest) -> CreateAt
attack_result = AttackResult(
conversation_id=conversation_id,
objective=request.name or "Manual attack via GUI",
atomic_attack_identifier=build_atomic_attack_identifier(
attack_identifier=ComponentIdentifier(
atomic_attack_identifier=AtomicAttackIdentifier.build(
attack_identifier=AttackIdentifier(
class_name=request.name or "ManualAttack",
class_module="pyrit.backend",
children={"objective_target": target_identifier} if target_identifier else {},
objective_target=target_identifier,
),
),
outcome=AttackOutcome.UNDETERMINED,
Expand Down
64 changes: 42 additions & 22 deletions pyrit/executor/attack/core/attack_strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,17 @@
)
from pyrit.memory.central_memory import CentralMemory
from pyrit.models import (
AttackIdentifier,
AttackOutcome,
AttackResult,
ComponentIdentifier,
ConversationReference,
ConverterIdentifier,
Identifiable,
Message,
ScorerIdentifier,
SeedPrompt,
TargetIdentifier,
)
from pyrit.prompt_target.common.target_requirements import TargetRequirements

Expand Down Expand Up @@ -458,48 +462,64 @@ def _create_identifier(
Returns:
ComponentIdentifier: The identifier for this attack strategy.
"""
all_children: dict[str, ComponentIdentifier | list[ComponentIdentifier]] = {
"objective_target": self.get_objective_target().get_identifier(),
}

all_children: dict[str, ComponentIdentifier | list[ComponentIdentifier]] = dict(children) if children else {}
merged_params: dict[str, Any] = dict(params) if params else {}

objective_target = TargetIdentifier.from_component_identifier(self.get_objective_target().get_identifier())

# Add scorer if present
objective_scorer: ScorerIdentifier | None = None
scoring_config = self.get_attack_scoring_config()
if scoring_config and scoring_config.objective_scorer:
all_children["objective_scorer"] = scoring_config.objective_scorer.get_identifier()
objective_scorer = ScorerIdentifier.from_component_identifier(
scoring_config.objective_scorer.get_identifier()
)

# Add adversarial chat target and its effective prompts if present. The adversarial
# target becomes a child (filtered to model params by the eval rule), while the
# effective system/seed prompts land on the attack-strategy node so they are included
# in both the full component hash and the eval hash. None-valued params are dropped by
# ComponentIdentifier.of, so strategies that do not use a given prompt simply omit it.
# in both the full component hash and the eval hash. None-valued promoted fields are
# dropped by ComponentIdentifier.of, so strategies that do not use a given prompt
# simply omit it.
adversarial_chat: TargetIdentifier | None = None
adversarial_system_prompt: str | None = None
adversarial_seed_prompt: str | None = None
adversarial_config = self.get_attack_adversarial_config()
if adversarial_config is not None and getattr(adversarial_config, "target", None) is not None:
all_children["adversarial_chat"] = adversarial_config.target.get_identifier()
merged_params["adversarial_system_prompt"] = self._extract_adversarial_prompt_text(
adversarial_config.system_prompt
)
merged_params["adversarial_seed_prompt"] = self._extract_adversarial_prompt_text(
adversarial_config.seed_prompt
)
adversarial_chat = TargetIdentifier.from_component_identifier(adversarial_config.target.get_identifier())
adversarial_system_prompt = self._extract_adversarial_prompt_text(adversarial_config.system_prompt)
adversarial_seed_prompt = self._extract_adversarial_prompt_text(adversarial_config.seed_prompt)

# Add request converter identifiers if present
request_converters: list[ConverterIdentifier] | None = None
if self._request_converters:
all_children["request_converters"] = [
converter.get_identifier() for config in self._request_converters for converter in config.converters
request_converters = [
ConverterIdentifier.from_component_identifier(converter.get_identifier())
for config in self._request_converters
for converter in config.converters
]

# Add response converter identifiers if present
response_converters: list[ConverterIdentifier] | None = None
if self._response_converters:
all_children["response_converters"] = [
converter.get_identifier() for config in self._response_converters for converter in config.converters
response_converters = [
ConverterIdentifier.from_component_identifier(converter.get_identifier())
for config in self._response_converters
for converter in config.converters
]

if children:
all_children.update(children)

return ComponentIdentifier.of(self, params=merged_params or None, children=all_children)
return AttackIdentifier.of(
self,
params=merged_params or None,
children=all_children or None,
objective_target=objective_target,
adversarial_chat=adversarial_chat,
objective_scorer=objective_scorer,
request_converters=request_converters,
response_converters=response_converters,
adversarial_system_prompt=adversarial_system_prompt,
adversarial_seed_prompt=adversarial_seed_prompt,
)

@staticmethod
def _extract_adversarial_prompt_text(value: str | SeedPrompt | None) -> str | None:
Expand Down
4 changes: 2 additions & 2 deletions pyrit/executor/attack/multi_turn/chunked_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@
MultiTurnAttackStrategy,
)
from pyrit.models import (
AtomicAttackIdentifier,
AttackOutcome,
AttackResult,
Message,
Score,
build_atomic_attack_identifier,
)
from pyrit.prompt_normalizer import PromptNormalizer
from pyrit.prompt_target import PromptTarget
Expand Down Expand Up @@ -316,7 +316,7 @@ async def _perform_async(self, *, context: ChunkedRequestAttackContext) -> Attac
return AttackResult(
conversation_id=context.session.conversation_id,
objective=context.objective,
atomic_attack_identifier=build_atomic_attack_identifier(attack_identifier=self.get_identifier()),
atomic_attack_identifier=AtomicAttackIdentifier.build(attack_identifier=self.get_identifier()),
last_response=response.get_piece() if response else None,
last_score=score,
related_conversations=context.related_conversations,
Expand Down
4 changes: 2 additions & 2 deletions pyrit/executor/attack/multi_turn/crescendo.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,14 @@
from pyrit.memory.central_memory import CentralMemory
from pyrit.message_normalizer import ConversationContextNormalizer
from pyrit.models import (
AtomicAttackIdentifier,
AttackOutcome,
AttackResult,
ConversationReference,
ConversationType,
Message,
Score,
SeedPrompt,
build_atomic_attack_identifier,
)
from pyrit.prompt_normalizer import PromptNormalizer
from pyrit.prompt_target import CapabilityName, TargetRequirements
Expand Down Expand Up @@ -427,7 +427,7 @@ async def _perform_async(self, *, context: CrescendoAttackContext) -> CrescendoA

# Prepare the result
result = CrescendoAttackResult(
atomic_attack_identifier=build_atomic_attack_identifier(attack_identifier=self.get_identifier()),
atomic_attack_identifier=AtomicAttackIdentifier.build(attack_identifier=self.get_identifier()),
conversation_id=context.session.conversation_id,
objective=context.objective,
outcome=(AttackOutcome.SUCCESS if achieved_objective else AttackOutcome.FAILURE),
Expand Down
Loading
Loading