Skip to content
Open
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
49 changes: 11 additions & 38 deletions pyrit/backend/services/target_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,9 @@ class TargetService:
"""
Service for managing target instances.

Uses TargetRegistry as the sole source of truth. Class discovery,
construction (incl. param coercion and reference resolution), and endpoint
validation are all owned by the registry and the target classes; this
service only orchestrates the request → registry hand-off.
Uses TargetRegistry as the sole source of truth for class discovery,
parameter coercion, reference resolution, and construction. Endpoint
validation remains owned by the target classes.
"""

def __init__(self) -> None:
Expand Down Expand Up @@ -153,18 +152,13 @@ async def create_target_async(self, *, request: CreateTargetRequest) -> TargetIn
"""
Create a new target instance from API request.

Class discovery is owned by the ``TargetRegistry``. Targets whose build
contract references other registry instances (e.g. ``RoundRobinTarget``'s
``targets``) are constructed via ``registry.create_instance`` so the
resolver turns registry names into live objects; all other targets carry
their base configuration (``endpoint`` / ``model_name`` / ``api_key``)
through ``**kwargs``, which is not part of the registry's derived
parameter contract, so they are constructed directly from the registry
class. Endpoint trust and identity token minting are owned by the target
classes themselves. This service only enforces the request-level auth
contract: for ``identity`` it confirms the target supports it and omits
the api_key so the target validates its own endpoint and authenticates
itself.
Class discovery, strict parameter validation, scalar coercion, registry
reference resolution, and construction are owned by the
``TargetRegistry``. Endpoint trust and identity token minting are owned
by the target classes themselves. This service only enforces the
request-level auth contract: for ``identity`` it confirms the target
supports it and omits the api_key so the target validates its own
endpoint and authenticates itself.

Args:
request: The create target request with type, params, and auth_mode.
Expand Down Expand Up @@ -192,34 +186,13 @@ async def create_target_async(self, *, request: CreateTargetRequest) -> TargetIn
# Omit any api_key so the target validates its own endpoint and authenticates itself.
params.pop("api_key", None)

if self._has_reference_params(target_type=request.type):
# e.g. RoundRobinTarget: `targets` is a list of registry names the
# resolver turns into live target objects.
target_obj = self._registry.create_instance(request.type, **params)
else:
target_obj = target_cls(**params)
target_obj = self._registry.create_instance(request.type, **params)

self._registry.instances.register(target_obj)

target_registry_name = target_obj.get_identifier().unique_name
return self._build_instance_from_object(target_registry_name=target_registry_name, target_obj=target_obj)

def _has_reference_params(self, *, target_type: str) -> bool:
"""
Return True if the target type's build contract references other registry
instances (so construction must go through the resolver).

Args:
target_type (str): The registered target class name.

Returns:
bool: True if any derived parameter is a registry reference.
"""
metadata = self._registry.get_registered_class_metadata(target_type)
if metadata is None:
return False
return any(param.reference is not None for param in metadata.parameters)


@lru_cache(maxsize=1)
def get_target_service() -> TargetService:
Expand Down
3 changes: 2 additions & 1 deletion pyrit/common/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
reset_default_values,
set_default_value,
)
from pyrit.common.brick_contract import enforce_keyword_only_init
from pyrit.common.brick_contract import enforce_keyword_only_init, forward_init_parameters
from pyrit.common.default_values import get_non_required_value, get_required_value
from pyrit.common.deprecation import print_deprecation_message
from pyrit.common.notebook_utils import is_in_ipython_session
Expand All @@ -43,6 +43,7 @@
"combine_list",
"DefaultValueScope",
"enforce_keyword_only_init",
"forward_init_parameters",
"get_global_default_values",
"get_kwarg_param",
"get_non_required_value",
Expand Down
50 changes: 45 additions & 5 deletions pyrit/common/brick_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,57 @@
every subclass must use the keyword-only constructor shape mandated by the
style guide: ``def __init__(self, *, ...)``.

This module provides one shared helper, ``enforce_keyword_only_init``,
that bases invoke from their own ``__init_subclass__`` hook. The helper
inspects the subclass's directly-defined ``__init__`` (not inherited) and
classifies it as compliant or non-compliant. Non-compliant subclasses
raise ``TypeError`` at class definition time.
``enforce_keyword_only_init`` validates subclass signatures.
``forward_init_parameters`` explicitly marks constructors that pass their
``**kwargs`` to the next constructor in the MRO, allowing registries to derive
the complete strict build contract without interpreting arbitrary keyword bags.
"""

from __future__ import annotations

import inspect
from collections.abc import Callable
from inspect import Parameter
from typing import TypeVar

_InitMethodT = TypeVar("_InitMethodT", bound=Callable[..., None])
_FORWARD_INIT_PARAMETERS_ATTRIBUTE = "__pyrit_forward_init_parameters__"


def forward_init_parameters(init: _InitMethodT) -> _InitMethodT:
"""
Declare that a constructor forwards ``**kwargs`` to the next MRO constructor.

The registry uses this explicit declaration to merge parent constructor
parameters into the class's build contract without treating every variadic
keyword bag as parent arguments.

Args:
init (_InitMethodT): The forwarding constructor.

Returns:
_InitMethodT: The unchanged constructor with registry metadata attached.

Raises:
TypeError: If the constructor does not accept ``**kwargs``.
"""
if not any(param.kind is Parameter.VAR_KEYWORD for param in inspect.signature(init).parameters.values()):
raise TypeError("forward_init_parameters requires a constructor that accepts **kwargs.")
setattr(init, _FORWARD_INIT_PARAMETERS_ATTRIBUTE, True)
return init


def init_parameters_are_forwarded(init: Callable[..., object]) -> bool:
"""
Return whether a constructor declares that it forwards ``**kwargs``.

Args:
init (Callable[..., object]): The constructor to inspect.

Returns:
bool: True when ``forward_init_parameters`` marked the constructor.
"""
return bool(getattr(init, _FORWARD_INIT_PARAMETERS_ATTRIBUTE, False))


def enforce_keyword_only_init(cls: type, *, base_name: str) -> None:
Expand Down
14 changes: 6 additions & 8 deletions pyrit/models/parameter.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,18 +191,16 @@ def is_string_coercible(self) -> bool:
Whether a single string token can be coerced to this parameter's value.

True for a non-reference plain scalar (``str`` / ``int`` / ``float`` /
``bool``) or ``Literal[...]`` parameter — exactly the forms a text field or
CLI token can supply. References and structured types (lists, enums,
arbitrary objects) are False and are surfaced/handled elsewhere.
``bool``), ``Literal[...]``, or ``Enum`` parameter — exactly the forms a
text field or CLI token can supply. References and structured types (lists
and arbitrary objects) are False and are surfaced/handled elsewhere.

Returns:
bool: True when a string can be coerced to this parameter's value.
"""
if self.reference is not None:
if self.reference is not None or self.opaque:
return False
if self.param_type in _SUPPORTED_SCALAR_TYPES:
return True
return get_origin(self.param_type) is Literal
return _is_scalar_param_type(_unwrap_optional(self.param_type))

def is_reference_to(self, component_type: ComponentType) -> bool:
"""
Expand Down Expand Up @@ -246,7 +244,7 @@ def coerce_value(self, raw_value: Any) -> Any:
"""
if self.reference is not None or self.opaque:
return raw_value
param_type = self.param_type
param_type = _unwrap_optional(self.param_type)
if param_type is None:
return copy.deepcopy(raw_value)
if get_origin(param_type) is list:
Expand Down
2 changes: 2 additions & 0 deletions pyrit/prompt_target/openai/openai_chat_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from collections.abc import MutableSequence
from typing import Any

from pyrit.common import forward_init_parameters
from pyrit.exceptions import (
EmptyResponseException,
pyrit_target_retry,
Expand Down Expand Up @@ -89,6 +90,7 @@ class OpenAIChatTarget(OpenAITarget):
)
)

@forward_init_parameters
def __init__(
self,
*,
Expand Down
2 changes: 2 additions & 0 deletions pyrit/prompt_target/openai/openai_completion_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import logging
from typing import Any

from pyrit.common import forward_init_parameters
from pyrit.exceptions.exception_classes import (
pyrit_target_retry,
)
Expand All @@ -21,6 +22,7 @@ class OpenAICompletionTarget(OpenAITarget):

_DEFAULT_CONFIGURATION: TargetConfiguration = TargetConfiguration(capabilities=TargetCapabilities())

@forward_init_parameters
def __init__(
self,
*,
Expand Down
2 changes: 2 additions & 0 deletions pyrit/prompt_target/openai/openai_image_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import logging
from typing import Any, Literal

from pyrit.common import forward_init_parameters
from pyrit.exceptions import (
EmptyResponseException,
pyrit_target_retry,
Expand Down Expand Up @@ -41,6 +42,7 @@ class OpenAIImageTarget(OpenAITarget):
)
)

@forward_init_parameters
def __init__(
self,
*,
Expand Down
2 changes: 2 additions & 0 deletions pyrit/prompt_target/openai/openai_realtime_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from openai import AsyncOpenAI

from pyrit.common import forward_init_parameters
from pyrit.exceptions import (
pyrit_target_retry,
)
Expand Down Expand Up @@ -79,6 +80,7 @@ class RealtimeTarget(OpenAITarget):
#: of truth for both atomic (send_text/send_audio) and streaming session paths.
SAMPLE_RATE_HZ: ClassVar[int] = 24000

@forward_init_parameters
def __init__(
self,
*,
Expand Down
2 changes: 2 additions & 0 deletions pyrit/prompt_target/openai/openai_response_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from openai.types.shared import ReasoningEffort

from pyrit.common import forward_init_parameters
from pyrit.exceptions import (
EmptyResponseException,
PyritException,
Expand Down Expand Up @@ -95,6 +96,7 @@ class OpenAIResponseTarget(OpenAITarget):
)
)

@forward_init_parameters
def __init__(
self,
*,
Expand Down
2 changes: 2 additions & 0 deletions pyrit/prompt_target/openai/openai_tts_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import logging
from typing import Any, Literal

from pyrit.common import forward_init_parameters
from pyrit.exceptions import (
pyrit_target_retry,
)
Expand All @@ -30,6 +31,7 @@ class OpenAITTSTarget(OpenAITarget):
)
)

@forward_init_parameters
def __init__(
self,
*,
Expand Down
2 changes: 2 additions & 0 deletions pyrit/prompt_target/openai/openai_video_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from openai.types import VideoSeconds, VideoSize

from pyrit.common import forward_init_parameters
from pyrit.exceptions import (
pyrit_target_retry,
)
Expand Down Expand Up @@ -56,6 +57,7 @@ class OpenAIVideoTarget(OpenAITarget):
)
)

@forward_init_parameters
def __init__(
self,
*,
Expand Down
Loading