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
1 change: 1 addition & 0 deletions doc/code/framework.md
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,7 @@ See [message normalizers](./targets/11_message_normalizer) for capability behavi

- If you are creating a component with user input (e.g. via config, REST, or automatically) it should always use the registry
- If you are storing an instance of a component, it should always use the registry
- The registry accepts only explicitly supported external inputs, permits opaque Python objects only for in-process callers, and leaves component validation to constructors.

## [Setup](./setup/0_setup)

Expand Down
14 changes: 7 additions & 7 deletions doc/gui/0_gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -588,9 +588,11 @@ at `GET /api/runtime`.
## Registry API Migration Notes

Use `/api/converters/types` and `/api/targets/types` for registry build metadata.
These endpoints return all constructor parameters from the registry, including
lists, unions, and component references. The temporary `/catalog` routes retain
their scalar-only filtering for the current UI.
These endpoints return the constructor parameters external callers can set, each
described in the form callers send it: a flat collection as a list, a union as its
first alternative callers can send (`font_size: int | tuple[int, int]` as `int`),
and a component reference as a name. They leave out types external callers can't
create. Registry metadata keeps every parameter with its full annotation.
Create requests should supply an explicit registry `name`. Converter creation
returns the complete `ConverterInstance`; read its type from
`identifier.class_name`, not the old top-level `converter_type` field. Treat
Expand All @@ -607,10 +609,8 @@ allowlisted image, audio, and video extensions inline. Other files, including PD
SVG, HTML, text, and executables, download as `application/octet-stream` attachments.

**Temporary compatibility, scheduled for removal with the chat migration:**
the `/api/converters/catalog` and `/api/targets/catalog` routes project the same
registry metadata for the current UI. Create requests without a name receive a
generated `compat_...` name. New clients should not depend on these routes or
unnamed creation.
target create requests without a name receive a generated `compat_...` name.
New clients should supply an explicit name.

## Connection Health

Expand Down
60 changes: 60 additions & 0 deletions frontend/src/components/Config/CreateTargetDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1140,6 +1140,66 @@ describe("CreateTargetDialog", () => {
});
});

it("should submit the catalog video duration as a number", async () => {
const user = userEvent.setup();
const videoParameters: Parameter[] = [
{
name: "resolution_dimensions",
type_name: "str",
is_list: false,
choices: ["720x1280", "1280x720", "1024x1792", "1792x1024"],
default: "1280x720",
required: false,
},
{ name: "n_seconds", type_name: "int", is_list: false, choices: null, default: "4", required: false },
{ name: "model_name", type_name: "str", is_list: false, choices: null, default: null, required: true },
{ name: "endpoint", type_name: "str", is_list: false, choices: null, default: null, required: true },
{ name: "api_key", type_name: "str", is_list: false, choices: null, default: null, required: false },
{ name: "underlying_model", type_name: "str", is_list: false, choices: null, default: null, required: false },
];
mockedTargetsApi.listTargetTypes.mockResolvedValue({
items: TARGET_TYPES.items.map((item) => (
item.target_type === "OpenAIVideoTarget" ? { ...item, parameters: videoParameters } : item
)),
});
mockedTargetsApi.createTarget.mockResolvedValue(makeTarget({
target_registry_name: "openai_video_custom",
target_type: "OpenAIVideoTarget",
}));

render(
<TestWrapper>
<CreateTargetDialog {...defaultProps} />
</TestWrapper>
);

await selectTargetType("OpenAIVideoTarget");
await user.click(screen.getByText("Advanced settings"));

fireEvent.change(screen.getByPlaceholderText("https://your-resource.openai.azure.com/"), {
target: { value: "https://api.openai.com" },
});
fireEvent.change(screen.getByPlaceholderText("e.g. gpt-4o, my-deployment"), {
target: { value: "sora-2" },
});
fireEvent.change(screen.getByLabelText("N Seconds"), {
target: { value: "8" },
});

await user.click(screen.getByText("Create Target"));

await waitFor(() => {
expect(mockedTargetsApi.createTarget).toHaveBeenCalledWith({
type: "OpenAIVideoTarget",
params: {
endpoint: "https://api.openai.com",
model_name: "sora-2",
n_seconds: 8,
},
});
});
});

it("should render and submit metadata choices and booleans", async () => {
const user = userEvent.setup();
mockedTargetsApi.createTarget.mockResolvedValue(makeTarget({
Expand Down
78 changes: 76 additions & 2 deletions frontend/src/components/Registry/CreateConverterDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ describe('CreateConverterDialog', () => {
expect(mockedConvertersApi.createConverter).toHaveBeenCalledWith({
name: 'caesar-custom',
type: 'CaesarConverter',
params: { caesar_offset: '5' },
params: { caesar_offset: 5 },
})
expect(onCreated).toHaveBeenCalledWith('caesar-custom')
})
Expand Down Expand Up @@ -332,11 +332,85 @@ describe('CreateConverterDialog', () => {
expect(mockedConvertersApi.createConverter).toHaveBeenCalledWith({
name: 'SearchReplaceConverter',
type: 'SearchReplaceConverter',
params: { pattern: 'hello', replace: 'world', regex_flags: '0' },
params: { pattern: 'hello', replace: 'world', regex_flags: 0 },
})
},
)

it('should submit the catalog font size as a number', async () => {
const user = userEvent.setup()
mockConverterParameters([
{ name: 'img_to_add', type_name: 'Path', is_list: false, choices: null, default: null, required: true },
{ name: 'font_name', type_name: 'Path', is_list: false, choices: null, default: null, required: false },
{ name: 'font_size', type_name: 'int', is_list: false, choices: null, default: '15', required: false },
{ name: 'rotation', type_name: 'float', is_list: false, choices: null, default: '0.0', required: false },
{ name: 'center_text', type_name: 'bool', is_list: false, choices: null, default: 'False', required: false },
], 'AddImageTextConverter')
renderDialog()
await selectConverterType('AddImageTextConverter')

await user.type(screen.getByRole('textbox', { name: 'img_to_add *' }), 'data:image/png;base64,iVBORw0K')
const fontSize = screen.getByRole('textbox', { name: 'font_size' })
expect(fontSize).toHaveValue('15')
await user.clear(fontSize)
await user.type(fontSize, 'large')
await user.click(screen.getByRole('button', { name: 'Add Converter' }))
expect(screen.getByText('font_size must be a number.')).toBeInTheDocument()
expect(mockedConvertersApi.createConverter).not.toHaveBeenCalled()

await user.clear(fontSize)
await user.type(fontSize, '24')
await user.click(screen.getByRole('button', { name: 'Add Converter' }))
expect(mockedConvertersApi.createConverter).toHaveBeenCalledWith({
name: 'AddImageTextConverter',
type: 'AddImageTextConverter',
params: { img_to_add: 'data:image/png;base64,iVBORw0K', font_size: 24, rotation: 0 },
})
})

it('should submit the catalog SATA word lists as arrays', async () => {
const user = userEvent.setup()
mockConverterParameters([
{ name: 'mask_token', type_name: 'str', is_list: false, choices: null, default: '[MASK]', required: false },
{ ...wordSelectionParameter, name: 'selection_strategy' },
{ name: 'num_masks', type_name: 'int', is_list: false, choices: null, default: null, required: false },
{ name: 'stopwords', type_name: 'list[str]', is_list: true, choices: null, default: null, required: false },
{
name: 'candidate_words', type_name: 'list[str]', is_list: true, choices: null, default: null, required: false,
},
], 'SATAMaskingConverter')
renderDialog()
await selectConverterType('SATAMaskingConverter')

await user.type(screen.getByRole('textbox', { name: 'stopwords' }), 'the, a')
await user.type(screen.getByRole('textbox', { name: 'candidate_words' }), 'bomb')
await user.click(screen.getByRole('button', { name: 'Add Converter' }))
expect(mockedConvertersApi.createConverter).toHaveBeenCalledWith({
name: 'SATAMaskingConverter',
type: 'SATAMaskingConverter',
params: { mask_token: '[MASK]', stopwords: ['the', 'a'], candidate_words: ['bomb'] },
})
})

it('should send text values exactly as entered', async () => {
const user = userEvent.setup()
mockConverterParameters([
{ name: 'pattern', type_name: 'str', required: true },
{ name: 'replace', type_name: 'str', required: true },
], 'SearchReplaceConverter')
renderDialog()
await selectConverterType('SearchReplaceConverter')

await user.type(screen.getByRole('textbox', { name: 'pattern *' }), 'cat')
await user.type(screen.getByRole('textbox', { name: 'replace *' }), ' dog ')
await user.click(screen.getByRole('button', { name: 'Add Converter' }))
expect(mockedConvertersApi.createConverter).toHaveBeenCalledWith({
name: 'SearchReplaceConverter',
type: 'SearchReplaceConverter',
params: { pattern: 'cat', replace: ' dog ' },
})
})

it('selects a registered target for a target reference parameter', async () => {
mockedConvertersApi.listConverterTypes.mockResolvedValue({
items: [
Expand Down
33 changes: 23 additions & 10 deletions frontend/src/components/Registry/CreateConverterDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ import ParameterField from '@/components/Parameters/ParameterField'
import {
buildParametersFromForm,
getInitialFormValues,
getParameterControlKind,
isStructuredParameterFormValue,
type ParameterControlKind,
type ParameterFormValue,
} from '@/components/Parameters/parameterForm'

Expand Down Expand Up @@ -117,6 +119,15 @@ function canConfigureConverterType(converterType: ConverterTypeEntry): boolean {
)
}

// Text, JSON, and multiselect values go to the API exactly as entered: whitespace can be the
// value (a replacement, for one), and this dialog shows those parameters as text or a single
// select. The other controls are converted to the JSON type their parameter describes.
const TYPED_CONTROL_KINDS = new Set<ParameterControlKind>(['structured', 'boolean', 'select', 'list', 'number'])

function sendsTypedValue(parameter: Parameter): boolean {
return TYPED_CONTROL_KINDS.has(getParameterControlKind(parameter))
}

function parameterDefaultValue(parameter: Parameter): string {
if (Array.isArray(parameter.default)) {
return parameter.default.join(', ')
Expand Down Expand Up @@ -408,21 +419,23 @@ export default function CreateConverterDialog({
}

const parameters = selectedConverterType?.parameters ?? []
const params = Object.fromEntries(
Object.entries(parameterValues).filter(([, value]) => !isStructuredParameterFormValue(value)),
)
const structured = buildParametersFromForm(
parameters.filter((parameter) => parameter.variants),
parameterValues,
const params: Record<string, unknown> = Object.fromEntries(
parameters
.filter((parameter) => !sendsTypedValue(parameter))
.flatMap((parameter) => {
const value = parameterValues[parameter.name]
return typeof value === 'string' ? [[parameter.name, value]] : []
}),
)
if (!structured.ok) {
const typed = buildParametersFromForm(parameters.filter(sendsTypedValue), parameterValues)
if (!typed.ok) {
// Not tagged as a submission failure: nothing was disabled, so the keyboard
// is still on the primary action and has nothing to be restored from.
setError({ message: structured.error, fromSubmit: false })
setError({ message: typed.error, fromSubmit: false })
return
}
if (structured.parameters) {
Object.assign(params, structured.parameters)
if (typed.parameters) {
Object.assign(params, typed.parameters)
}

const epoch = openEpochRef.current
Expand Down
15 changes: 10 additions & 5 deletions pyrit/backend/services/converter_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,11 @@ async def list_converter_types_async(self) -> ConverterTypeResponse:
"""
List all available converter types from the converter class registry.
Returns every constructible converter. Deciding which entries to surface
to a user is a presentation concern owned by the caller (e.g. the
frontend), not this service.
Returns every converter that external callers can build, with only the
parameters they may supply, each described in the form callers send it;
converters that need a Python object for a required parameter are left out.
Deciding which entries to surface to a user is a presentation concern owned
by the caller (e.g. the frontend), not this service.
Returns:
ConverterTypeResponse containing all available converter classes.
Expand All @@ -124,11 +126,14 @@ async def list_converter_types_async(self) -> ConverterTypeResponse:
converter_type=metadata.class_name,
supported_input_types=list(metadata.supported_input_types),
supported_output_types=list(metadata.supported_output_types),
parameters=list(metadata.parameters),
parameters=[
parameter.for_external_catalog() for parameter in metadata.parameters if parameter.is_external_input
],
is_llm_based=metadata.is_llm_based,
description=metadata.class_description or None,
)
for metadata in self._registry.get_all_registered_class_metadata()
if all(parameter.is_external_input for parameter in metadata.parameters if parameter.required)
]

return ConverterTypeResponse(items=items)
Expand Down Expand Up @@ -197,7 +202,7 @@ async def create_converter_async(self, *, request: CreateConverterRequest) -> Co
try:
# Uploads may have yielded to another request that took the name.
self._registry.instances.validate_name_available(request.name)
converter_obj = self._registry.create_instance(request.type, **params)
converter_obj = self._registry.create_instance_from_external_input(request.type, params=params)
converter = self._build_instance_from_object(converter_id=request.name, converter_obj=converter_obj)
self._registry.instances.register(
converter_obj,
Expand Down
8 changes: 7 additions & 1 deletion pyrit/backend/services/scenario_run_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
)
from pyrit.prompt_target import PromptTarget
from pyrit.registry import InitializerRegistry, ScenarioRegistry
from pyrit.registry.resolution import resolve_declared_params
from pyrit.registry.resolution import reject_non_external_params, resolve_declared_params
from pyrit.scenario import Scenario
from pyrit.scenario.core import override_default_adversarial_target

Expand Down Expand Up @@ -626,6 +626,12 @@ async def _prepare_run_async(self, *, request: RunScenarioRequest) -> _PreparedR
ValueError: If scenario, target, initializer, or technique cannot be found.
"""
scenario_class = self._configuration_resolver.resolve_scenario_class(scenario_name=request.scenario_name)
if request.scenario_params:
reject_non_external_params(
params=request.scenario_params,
declared=scenario_class.supported_parameters(),
owner=request.scenario_name,
)
await self._run_initializers_async(request=request)
objective_target = self._configuration_resolver.resolve_target(target_name=request.target_name)
adversarial_target = self._configuration_resolver.resolve_adversarial_target(
Expand Down
13 changes: 12 additions & 1 deletion pyrit/backend/services/scenario_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
ScenarioRunSizeEstimateRequest,
)
from pyrit.registry import ScenarioMetadata, ScenarioRegistry
from pyrit.registry.resolution import reject_non_external_params
from pyrit.scenario.core import Scenario, override_default_adversarial_target
from pyrit.scenario.core.dataset_configuration import read_only_dataset_resolution

Expand Down Expand Up @@ -68,7 +69,11 @@ def _metadata_to_registered_scenario(
all_techniques=list(metadata.all_techniques),
technique_summaries=list(metadata.technique_summaries),
default_datasets=list(metadata.default_datasets),
supported_parameters=list(metadata.supported_parameters),
supported_parameters=[
parameter.for_external_catalog()
for parameter in metadata.supported_parameters
if parameter.is_external_input
],
baseline_policy=metadata.baseline_policy,
include_baseline_by_default=metadata.include_baseline_by_default,
uses_default_adversarial_target=metadata.uses_default_adversarial_target,
Expand Down Expand Up @@ -193,6 +198,12 @@ async def estimate_scenario_run_size_async(
scenario_class = self._registry.get_class(scenario_name)
except KeyError:
return None
if request.scenario_params:
reject_non_external_params(
params=request.scenario_params,
declared=scenario_class.supported_parameters(),
owner=scenario_name,
)

estimate_key = self._build_configured_estimate_key(
scenario_name=scenario_name, scenario_class=scenario_class, request=request
Expand Down
16 changes: 13 additions & 3 deletions pyrit/backend/services/scorer_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,21 +36,30 @@ def _build_instance(self, *, name: str, scorer: Any) -> ScorerInstance:

async def list_scorer_types_async(self) -> ScorerTypeResponse:
"""
List registered scorer class metadata without constructing scorers.
List the scorer types external callers can build, without constructing scorers.
Each entry lists only the parameters external callers may supply, each
described in the form callers send it; types that need a Python object for a
required parameter are left out.
Returns:
ScorerTypeResponse: All registered scorer type metadata.
ScorerTypeResponse: Scorer type metadata for external callers.
"""

def list_types() -> ScorerTypeResponse:
items = [
ScorerTypeEntry(
scorer_type=metadata.class_name,
parameters=list(metadata.parameters),
parameters=[
parameter.for_external_catalog()
for parameter in metadata.parameters
if parameter.is_external_input
],
is_llm_based=metadata.is_llm_based,
description=metadata.class_description or None,
)
for metadata in self._registry.get_all_registered_class_metadata()
if all(parameter.is_external_input for parameter in metadata.parameters if parameter.required)
]
return ScorerTypeResponse(items=items)

Expand Down Expand Up @@ -110,6 +119,7 @@ def create() -> ScorerInstance:
name=request.name,
type_name=request.type,
params=request.params,
external_input=True,
)
return self._build_instance(name=request.name, scorer=scorer)

Expand Down
Loading
Loading