@@ -518,6 +556,13 @@ export default function ChatWindow({
attackOperator={isOperatorLocked ? attackOperator ?? undefined : undefined}
noTargetSelected={!activeTarget}
onConfigureTarget={!activeTarget ? () => onNavigate?.('config') : undefined}
+ onToggleConverterPanel={() => setIsConverterPanelOpen(prev => !prev)}
+ isConverterPanelOpen={isConverterPanelOpen}
+ onInputChange={setChatInputText}
+ onAttachmentsChange={setAttachmentTypes}
+ convertedValue={convertedValue}
+ originalValue={originalValue}
+ onClearConversion={() => { setConvertedValue(null); setOriginalValue(null); setActiveConverterInstanceId(null) }}
/>
{isPanelOpen && (
diff --git a/frontend/src/components/Chat/ConverterPanel.styles.ts b/frontend/src/components/Chat/ConverterPanel.styles.ts
new file mode 100644
index 000000000..35f2e97f7
--- /dev/null
+++ b/frontend/src/components/Chat/ConverterPanel.styles.ts
@@ -0,0 +1,227 @@
+import { makeStyles, tokens } from '@fluentui/react-components'
+
+export const useConverterPanelStyles = makeStyles({
+ resizeContainer: {
+ display: 'flex',
+ flexDirection: 'row',
+ height: '100%',
+ flexShrink: 0,
+ },
+ root: {
+ display: 'flex',
+ flexDirection: 'column',
+ height: '100%',
+ flex: 1,
+ minWidth: 0,
+ backgroundColor: tokens.colorNeutralBackground3,
+ overflow: 'hidden',
+ },
+ resizeHandle: {
+ width: '4px',
+ cursor: 'col-resize',
+ backgroundColor: 'transparent',
+ borderRight: `1px solid ${tokens.colorNeutralStroke1}`,
+ flexShrink: 0,
+ ':hover': {
+ backgroundColor: tokens.colorBrandBackground2,
+ },
+ ':active': {
+ backgroundColor: tokens.colorBrandBackground,
+ },
+ },
+ header: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`,
+ borderBottom: `1px solid ${tokens.colorNeutralStroke1}`,
+ minHeight: '48px',
+ gap: tokens.spacingHorizontalS,
+ },
+ headerTitle: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalXXS,
+ minWidth: 0,
+ },
+ body: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalM,
+ padding: tokens.spacingHorizontalL,
+ overflowY: 'auto',
+ flex: 1,
+ },
+ hintText: {
+ color: tokens.colorNeutralForeground3,
+ },
+ loading: {
+ display: 'flex',
+ justifyContent: 'center',
+ paddingTop: tokens.spacingVerticalL,
+ },
+ converterList: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalS,
+ },
+ converterCard: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalXXS,
+ padding: tokens.spacingVerticalS,
+ borderRadius: tokens.borderRadiusMedium,
+ border: `1px solid ${tokens.colorNeutralStroke1}`,
+ backgroundColor: tokens.colorNeutralBackground1,
+ },
+ converterName: {
+ minWidth: 0,
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ },
+ metaRow: {
+ display: 'flex',
+ flexWrap: 'wrap',
+ gap: tokens.spacingHorizontalXS,
+ rowGap: tokens.spacingVerticalXXS,
+ },
+ badgeText: {
+ color: tokens.colorNeutralForeground2,
+ },
+ emptyState: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalS,
+ },
+ llmBadge: {
+ display: 'inline-block',
+ marginLeft: tokens.spacingHorizontalXS,
+ padding: `0 ${tokens.spacingHorizontalXXS}`,
+ borderRadius: tokens.borderRadiusSmall,
+ backgroundColor: tokens.colorPalettePurpleBackground2,
+ color: tokens.colorPalettePurpleForeground2,
+ fontSize: tokens.fontSizeBase100,
+ fontWeight: tokens.fontWeightSemibold as unknown as string,
+ verticalAlign: 'middle',
+ },
+ optionContent: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ width: '100%',
+ gap: tokens.spacingHorizontalXS,
+ },
+ optionBadges: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '2px',
+ flexShrink: 0,
+ },
+ typeBadge: {
+ display: 'inline-block',
+ padding: `0 3px`,
+ borderRadius: tokens.borderRadiusSmall,
+ fontSize: '10px',
+ fontWeight: tokens.fontWeightSemibold as unknown as string,
+ lineHeight: '16px',
+ verticalAlign: 'middle',
+ },
+ typeArrow: {
+ fontSize: '10px',
+ color: tokens.colorNeutralForeground3,
+ },
+ // Input type colors (solid backgrounds)
+ input_text: {
+ backgroundColor: tokens.colorPaletteBlueBackground2,
+ color: tokens.colorPaletteBlueForeground2,
+ },
+ input_image_path: {
+ backgroundColor: tokens.colorPaletteGreenBackground2,
+ color: tokens.colorPaletteGreenForeground2,
+ },
+ input_audio_path: {
+ backgroundColor: tokens.colorPaletteYellowBackground2,
+ color: tokens.colorPaletteYellowForeground2,
+ },
+ input_video_path: {
+ backgroundColor: tokens.colorPalettePurpleBackground2,
+ color: tokens.colorPalettePurpleForeground2,
+ },
+ // Output type colors (outlined/lighter)
+ output_text: {
+ backgroundColor: 'transparent',
+ color: tokens.colorPaletteBlueForeground2,
+ border: `1px solid ${tokens.colorPaletteBlueBorderActive}`,
+ },
+ output_image_path: {
+ backgroundColor: 'transparent',
+ color: tokens.colorPaletteGreenForeground2,
+ border: `1px solid ${tokens.colorPaletteGreenBorderActive}`,
+ },
+ output_audio_path: {
+ backgroundColor: 'transparent',
+ color: tokens.colorPaletteYellowForeground2,
+ border: `1px solid ${tokens.colorPaletteYellowBorderActive}`,
+ },
+ output_video_path: {
+ backgroundColor: 'transparent',
+ color: tokens.colorPalettePurpleForeground2,
+ border: `1px solid ${tokens.colorPalettePurpleBorderActive}`,
+ },
+ outputSection: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalXS,
+ marginTop: tokens.spacingVerticalS,
+ },
+ paramsSection: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalS,
+ padding: tokens.spacingVerticalS,
+ borderRadius: tokens.borderRadiusMedium,
+ border: `1px solid ${tokens.colorNeutralStroke1}`,
+ backgroundColor: tokens.colorNeutralBackground1,
+ },
+ paramsSectionHeader: {
+ justifyContent: 'flex-start',
+ fontWeight: tokens.fontWeightSemibold as unknown as string,
+ padding: 0,
+ },
+ paramBlock: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalXXS,
+ },
+ paramLabel: {
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: tokens.spacingHorizontalXXS,
+ },
+ paramInfo: {
+ display: 'inline-flex',
+ alignItems: 'center',
+ color: tokens.colorNeutralForeground3,
+ cursor: 'help',
+ },
+ outputBox: {
+ padding: tokens.spacingVerticalS,
+ borderRadius: tokens.borderRadiusMedium,
+ border: `1px solid ${tokens.colorNeutralStroke1}`,
+ backgroundColor: tokens.colorNeutralBackground1,
+ minHeight: '80px',
+ whiteSpace: 'pre-wrap' as const,
+ wordBreak: 'break-word' as const,
+ overflowY: 'auto' as const,
+ maxHeight: '200px',
+ },
+ previewPre: {
+ margin: 0,
+ whiteSpace: 'pre-wrap',
+ wordBreak: 'break-word',
+ fontFamily: tokens.fontFamilyMonospace,
+ fontSize: tokens.fontSizeBase200,
+ color: tokens.colorNeutralForeground1,
+ },
+})
diff --git a/frontend/src/components/Chat/ConverterPanel.tsx b/frontend/src/components/Chat/ConverterPanel.tsx
new file mode 100644
index 000000000..8d6c6261c
--- /dev/null
+++ b/frontend/src/components/Chat/ConverterPanel.tsx
@@ -0,0 +1,384 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { Button, Combobox, Field, Input, MessageBar, MessageBarBody, Option, Select, Spinner, Text, Tooltip } from '@fluentui/react-components'
+import { ChevronDownRegular, ChevronRightRegular, DismissRegular, InfoRegular, PlayRegular } from '@fluentui/react-icons'
+import { convertersApi } from '../../services/api'
+import { toApiError } from '../../services/errors'
+import type { ConverterCatalogEntry } from '../../types'
+import { useConverterPanelStyles } from './ConverterPanel.styles'
+
+interface ConverterPanelProps {
+ onClose: () => void
+ previewText?: string
+ activeInputTypes?: string[]
+ onUseConvertedValue?: (original: string, converted: string, converterInstanceId: string) => void
+}
+
+export default function ConverterPanel({ onClose, previewText = '', activeInputTypes = ['text'], onUseConvertedValue }: ConverterPanelProps) {
+ const styles = useConverterPanelStyles()
+ const [converters, setConverters] = useState
([])
+ const [selectedConverterType, setSelectedConverterType] = useState('')
+ const [query, setQuery] = useState('')
+ const [paramValues, setParamValues] = useState>({})
+ const [paramsExpanded, setParamsExpanded] = useState(true)
+ const [previewOutput, setPreviewOutput] = useState('')
+ const [previewConverterInstanceId, setPreviewConverterInstanceId] = useState(null)
+ const [isPreviewing, setIsPreviewing] = useState(false)
+ const [previewError, setPreviewError] = useState(null)
+ const [isLoading, setIsLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ let isMounted = true
+
+ const loadConverters = async () => {
+ setIsLoading(true)
+ setError(null)
+
+ try {
+ const response = await convertersApi.listConverterCatalog()
+ if (!isMounted) {
+ return
+ }
+ setConverters(response.items)
+ const first = response.items[0]?.converter_type || ''
+ setSelectedConverterType((current) => current || first)
+ setQuery((current) => current || first)
+ } catch (err) {
+ if (!isMounted) {
+ return
+ }
+ setConverters([])
+ setSelectedConverterType('')
+ setQuery('')
+ setError(toApiError(err).detail)
+ } finally {
+ if (isMounted) {
+ setIsLoading(false)
+ }
+ }
+ }
+
+ void loadConverters()
+
+ return () => {
+ isMounted = false
+ }
+ }, [])
+
+ // Map frontend attachment types to backend data type prefixes
+ const inputTypeSet = useMemo(() => {
+ const set = new Set()
+ for (const t of activeInputTypes) {
+ if (t === 'text') set.add('text')
+ else if (t === 'image') set.add('image_path')
+ else if (t === 'audio') set.add('audio_path')
+ else if (t === 'video') set.add('video_path')
+ }
+ if (set.size === 0) set.add('text')
+ return set
+ }, [activeInputTypes])
+
+ const filteredConverters = useMemo(() => {
+ // First filter by supported input types
+ const byType = converters.filter((c) => {
+ const supported = c.supported_input_types ?? []
+ if (supported.length === 0) return true
+ return supported.some((s) => inputTypeSet.has(s))
+ })
+ // Then filter by search query
+ if (query === selectedConverterType) {
+ return byType
+ }
+ return byType.filter((c) => c.converter_type.toLowerCase().includes(query.toLowerCase()))
+ }, [converters, query, selectedConverterType, inputTypeSet])
+
+ const selectedConverter = converters.find(
+ (converter) => converter.converter_type === selectedConverterType
+ ) ?? converters[0]
+
+ const handlePreview = async () => {
+ if (!selectedConverterType || !previewText.trim()) {
+ return
+ }
+ setIsPreviewing(true)
+ setPreviewError(null)
+ setPreviewOutput('')
+
+ try {
+ const createResponse = await convertersApi.createConverter({
+ type: selectedConverterType,
+ params: { ...paramValues },
+ })
+
+ const previewResponse = await convertersApi.previewConversion({
+ original_value: previewText,
+ converter_ids: [createResponse.converter_id],
+ })
+
+ setPreviewOutput(previewResponse.converted_value)
+ setPreviewConverterInstanceId(createResponse.converter_id)
+ } catch (err) {
+ setPreviewError(toApiError(err).detail)
+ } finally {
+ setIsPreviewing(false)
+ }
+ }
+
+ const [panelWidth, setPanelWidth] = useState(320)
+ const isDragging = useRef(false)
+
+ const handleMouseDown = useCallback(() => {
+ isDragging.current = true
+ document.body.style.cursor = 'col-resize'
+ document.body.style.userSelect = 'none'
+ }, [])
+
+ useEffect(() => {
+ const handleMouseMove = (e: MouseEvent) => {
+ if (!isDragging.current) return
+ const newWidth = Math.max(240, Math.min(600, e.clientX))
+ setPanelWidth(newWidth)
+ }
+ const handleMouseUp = () => {
+ if (!isDragging.current) return
+ isDragging.current = false
+ document.body.style.cursor = ''
+ document.body.style.userSelect = ''
+ }
+ document.addEventListener('mousemove', handleMouseMove)
+ document.addEventListener('mouseup', handleMouseUp)
+ return () => {
+ document.removeEventListener('mousemove', handleMouseMove)
+ document.removeEventListener('mouseup', handleMouseUp)
+ }
+ }, [])
+
+ return (
+
+
+
+
+ )
+}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index 55b079313..a7c21c4c8 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -3,6 +3,10 @@ import { toApiError } from './errors'
import type {
TargetInstance,
TargetListResponse,
+ ConverterCatalogEntry,
+ ConverterCatalogResponse,
+ ConverterInstance,
+ ConverterListResponse,
CreateTargetRequest,
CreateAttackRequest,
CreateAttackResponse,
@@ -105,6 +109,33 @@ export const targetsApi = {
},
}
+export const convertersApi = {
+ listConverterCatalog: async (): Promise => {
+ const response = await apiClient.get('/converters/catalog')
+ return response.data
+ },
+
+ listConverters: async (): Promise => {
+ const response = await apiClient.get('/converters')
+ return response.data
+ },
+
+ getConverter: async (converterId: string): Promise => {
+ const response = await apiClient.get(`/converters/${encodeURIComponent(converterId)}`)
+ return response.data
+ },
+
+ createConverter: async (request: { type: string; params?: Record }): Promise<{ converter_id: string; converter_type: string }> => {
+ const response = await apiClient.post('/converters', request)
+ return response.data
+ },
+
+ previewConversion: async (request: { original_value: string; converter_ids: string[] }): Promise<{ converted_value: string }> => {
+ const response = await apiClient.post('/converters/preview', request)
+ return response.data
+ },
+}
+
export const attacksApi = {
createAttack: async (request: CreateAttackRequest): Promise => {
const response = await apiClient.post('/attacks', request)
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index a98817874..c66d327cb 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -75,6 +75,44 @@ export interface CreateTargetRequest {
params: Record
}
+// --- Converters ---
+
+export interface ConverterInstance {
+ converter_id: string
+ converter_type: string
+ display_name?: string | null
+ supported_input_types: string[]
+ supported_output_types: string[]
+ converter_specific_params?: Record | null
+ sub_converter_ids?: string[] | null
+}
+
+export interface ConverterListResponse {
+ items: ConverterInstance[]
+}
+
+export interface ConverterParameterSchema {
+ name: string
+ type_name: string
+ required: boolean
+ default_value?: string | null
+ choices?: string[] | null
+ description?: string | null
+}
+
+export interface ConverterCatalogEntry {
+ converter_type: string
+ supported_input_types: string[]
+ supported_output_types: string[]
+ parameters: ConverterParameterSchema[]
+ is_llm_based: boolean
+ description?: string | null
+}
+
+export interface ConverterCatalogResponse {
+ items: ConverterCatalogEntry[]
+}
+
// --- Attacks ---
export interface TargetInfo {
diff --git a/pyrit/backend/models/converters.py b/pyrit/backend/models/converters.py
index 1c4e615f8..ba5ca5390 100644
--- a/pyrit/backend/models/converters.py
+++ b/pyrit/backend/models/converters.py
@@ -14,8 +14,11 @@
from pyrit.models import PromptDataType
__all__ = [
+ "ConverterCatalogEntry",
+ "ConverterCatalogResponse",
"ConverterInstance",
"ConverterInstanceListResponse",
+ "ConverterParameterSchema",
"CreateConverterRequest",
"CreateConverterResponse",
"ConverterPreviewRequest",
@@ -24,6 +27,45 @@
]
+# ============================================================================
+# Converter Catalog (Available Types)
+# ============================================================================
+
+
+class ConverterParameterSchema(BaseModel):
+ """Schema for a single converter constructor parameter."""
+
+ name: str = Field(..., description="Parameter name")
+ type_name: str = Field(..., description="Human-readable type (e.g. 'str', 'int', 'Literal[...]')")
+ required: bool = Field(..., description="Whether the parameter must be provided")
+ default_value: Optional[str] = Field(None, description="String representation of default value, if any")
+ choices: Optional[list[str]] = Field(None, description="Allowed values for Literal types")
+ description: Optional[str] = Field(None, description="Parameter description from docstring")
+
+
+class ConverterCatalogEntry(BaseModel):
+ """A converter type available from the backend registry."""
+
+ converter_type: str = Field(..., description="Converter class name (e.g., 'Base64Converter')")
+ supported_input_types: list[str] = Field(
+ default_factory=list, description="Input data types supported by this converter type"
+ )
+ supported_output_types: list[str] = Field(
+ default_factory=list, description="Output data types produced by this converter type"
+ )
+ parameters: list[ConverterParameterSchema] = Field(
+ default_factory=list, description="Constructor parameters for dynamic form generation"
+ )
+ is_llm_based: bool = Field(False, description="Whether this converter requires an LLM target")
+ description: Optional[str] = Field(None, description="Short description of the converter from its docstring")
+
+
+class ConverterCatalogResponse(BaseModel):
+ """Response for listing available converter types from the registry."""
+
+ items: list[ConverterCatalogEntry] = Field(..., description="List of available converter types")
+
+
# ============================================================================
# Converter Instances (Runtime Objects)
# ============================================================================
diff --git a/pyrit/backend/routes/converters.py b/pyrit/backend/routes/converters.py
index 095b6ef44..8b4c610bd 100644
--- a/pyrit/backend/routes/converters.py
+++ b/pyrit/backend/routes/converters.py
@@ -12,6 +12,7 @@
from pyrit.backend.models.common import ProblemDetail
from pyrit.backend.models.converters import (
+ ConverterCatalogResponse,
ConverterInstance,
ConverterInstanceListResponse,
ConverterPreviewRequest,
@@ -41,6 +42,21 @@ async def list_converters() -> ConverterInstanceListResponse:
return await service.list_converters_async()
+@router.get(
+ "/catalog",
+ response_model=ConverterCatalogResponse,
+)
+async def list_converter_catalog() -> ConverterCatalogResponse:
+ """
+ List all available converter types from the backend converter registry.
+
+ Returns:
+ ConverterCatalogResponse: List of available converter types.
+ """
+ service = get_converter_service()
+ return await service.list_converter_catalog_async()
+
+
@router.post(
"",
response_model=CreateConverterResponse,
diff --git a/pyrit/backend/services/attack_service.py b/pyrit/backend/services/attack_service.py
index 5bb4f6bbc..24775cdf8 100644
--- a/pyrit/backend/services/attack_service.py
+++ b/pyrit/backend/services/attack_service.py
@@ -729,6 +729,18 @@ async def _update_attack_after_message_async(
children=new_children,
)
update_fields["attack_identifier"] = new_aid.to_dict()
+ # Also update atomic_attack_identifier so get_attack_strategy_identifier() sees the change
+ if ar.atomic_attack_identifier:
+ atomic = ComponentIdentifier.from_dict(ar.atomic_attack_identifier.to_dict())
+ atomic_children = dict(atomic.children)
+ atomic_children["attack"] = new_aid
+ new_atomic = ComponentIdentifier(
+ class_name=atomic.class_name,
+ class_module=atomic.class_module,
+ params=dict(atomic.params),
+ children=atomic_children,
+ )
+ update_fields["atomic_attack_identifier"] = new_atomic.to_dict()
self._memory.update_attack_result_by_id(
attack_result_id=attack_result_id,
diff --git a/pyrit/backend/services/converter_service.py b/pyrit/backend/services/converter_service.py
index a0579239c..14333cdad 100644
--- a/pyrit/backend/services/converter_service.py
+++ b/pyrit/backend/services/converter_service.py
@@ -12,15 +12,20 @@
- Retrieved from registry (pre-registered at startup or created earlier)
"""
+import inspect
+import re
import uuid
from functools import lru_cache
-from typing import Any, Optional
+from typing import Any, Literal, Optional, Union, get_args, get_origin
from pyrit import prompt_converter
from pyrit.backend.mappers.converter_mappers import converter_object_to_instance
from pyrit.backend.models.converters import (
+ ConverterCatalogEntry,
+ ConverterCatalogResponse,
ConverterInstance,
ConverterInstanceListResponse,
+ ConverterParameterSchema,
ConverterPreviewRequest,
ConverterPreviewResponse,
CreateConverterRequest,
@@ -52,6 +57,110 @@ def _build_converter_class_registry() -> dict[str, type]:
# Module-level class registry (built once on import)
_CONVERTER_CLASS_REGISTRY: dict[str, type] = _build_converter_class_registry()
+# Types that can be rendered as simple form fields
+_SIMPLE_TYPES: set[type] = {str, int, float, bool}
+
+
+def _is_simple_type(annotation: Any) -> bool:
+ """Return True if the annotation represents a type renderable in a form field."""
+ if annotation in _SIMPLE_TYPES:
+ return True
+ origin = get_origin(annotation)
+ if origin is Literal:
+ return True
+ if origin is Union:
+ args = get_args(annotation)
+ non_none = [a for a in args if a is not type(None)]
+ return len(non_none) == 1 and _is_simple_type(non_none[0])
+ return False
+
+
+def _serialize_type(annotation: Any) -> str:
+ """Convert a type annotation to a concise human-readable string."""
+ if annotation is inspect.Parameter.empty:
+ return "Any"
+ origin = get_origin(annotation)
+ if origin is Literal:
+ args = get_args(annotation)
+ return f"Literal[{', '.join(repr(a) for a in args)}]"
+ if origin is Union:
+ args = get_args(annotation)
+ non_none = [a for a in args if a is not type(None)]
+ if len(non_none) == 1:
+ inner = _serialize_type(non_none[0])
+ return f"Optional[{inner}]" if len(args) > len(non_none) else inner
+ if hasattr(annotation, "__name__"):
+ return annotation.__name__
+ return str(annotation)
+
+
+def _parse_arg_descriptions(converter_class: type) -> dict[str, str]:
+ """Parse parameter descriptions from Google-style docstring Args section."""
+ doc = (converter_class.__init__.__doc__ or converter_class.__doc__ or "").strip()
+ match = re.search(r"Args:\s*\n(.*?)(?:\n\s*\n|\n\s*Returns:|\n\s*Raises:|\Z)", doc, re.DOTALL)
+ if not match:
+ return {}
+ args_block = match.group(1)
+ # Detect indentation of first parameter line
+ indent_match = re.match(r"^(\s+)", args_block)
+ indent = indent_match.group(1) if indent_match else r"\s+"
+ pattern = rf"^{indent}(\w+)\s*(?:\([^)]*\))?\s*:\s*(.+?)(?=\n{indent}\w|\Z)"
+ descriptions: dict[str, str] = {}
+ for m in re.finditer(pattern, args_block, re.DOTALL | re.MULTILINE):
+ descriptions[m.group(1)] = " ".join(m.group(2).split())
+ return descriptions
+
+
+def _extract_parameters(converter_class: type) -> list[ConverterParameterSchema]:
+ """Extract simple constructor parameters from a converter class."""
+ try:
+ sig = inspect.signature(converter_class.__init__)
+ except (ValueError, TypeError):
+ return []
+
+ arg_descriptions = _parse_arg_descriptions(converter_class)
+
+ params: list[ConverterParameterSchema] = []
+ for name, p in sig.parameters.items():
+ if name == "self":
+ continue
+ if not _is_simple_type(p.annotation):
+ continue
+
+ no_default = p.default is inspect.Parameter.empty
+ is_sentinel = hasattr(p.default, "__class__") and "Sentinel" in type(p.default).__name__
+ required = no_default or is_sentinel
+
+ default_value: Optional[str] = None
+ if not required and p.default is not None:
+ default_value = str(p.default)
+
+ choices: Optional[list[str]] = None
+ if get_origin(p.annotation) is Literal:
+ choices = [str(a) for a in get_args(p.annotation)]
+
+ params.append(
+ ConverterParameterSchema(
+ name=name,
+ type_name=_serialize_type(p.annotation),
+ required=required,
+ default_value=default_value,
+ choices=choices,
+ description=arg_descriptions.get(name),
+ )
+ )
+
+ return params
+
+
+def _is_llm_based(converter_class: type) -> bool:
+ """Return True if the converter requires an LLM target parameter."""
+ try:
+ sig = inspect.signature(converter_class.__init__)
+ except (ValueError, TypeError):
+ return False
+ return any("target" in name.lower() for name in sig.parameters if name != "self")
+
class ConverterService:
"""
@@ -93,6 +202,38 @@ async def list_converters_async(self) -> ConverterInstanceListResponse:
]
return ConverterInstanceListResponse(items=items)
+ async def list_converter_catalog_async(self) -> ConverterCatalogResponse:
+ """
+ List all available converter types from the backend converter registry.
+
+ Returns:
+ ConverterCatalogResponse containing all available converter classes.
+ """
+ items: list[ConverterCatalogEntry] = []
+ for converter_type, converter_class in sorted(_CONVERTER_CLASS_REGISTRY.items()):
+ if converter_type in ("PromptConverter", "ConverterResult") or "Strategy" in converter_type:
+ continue
+
+ supported_input_types = [str(data_type) for data_type in getattr(converter_class, "SUPPORTED_INPUT_TYPES", ())]
+ supported_output_types = [str(data_type) for data_type in getattr(converter_class, "SUPPORTED_OUTPUT_TYPES", ())]
+
+ # Extract first paragraph of docstring as description
+ raw_doc = (converter_class.__doc__ or "").strip()
+ description = raw_doc.split("\n\n")[0].replace("\n", " ").strip() or None
+
+ items.append(
+ ConverterCatalogEntry(
+ converter_type=converter_type,
+ supported_input_types=supported_input_types,
+ supported_output_types=supported_output_types,
+ parameters=_extract_parameters(converter_class),
+ is_llm_based=_is_llm_based(converter_class),
+ description=description,
+ )
+ )
+
+ return ConverterCatalogResponse(items=items)
+
async def get_converter_async(self, *, converter_id: str) -> Optional[ConverterInstance]:
"""
Get a converter instance by ID.
@@ -135,6 +276,7 @@ async def create_converter_async(self, *, request: CreateConverterRequest) -> Cr
# Resolve any converter references in params and instantiate
params = self._resolve_converter_params(params=request.params)
converter_class = self._get_converter_class(converter_type=request.type)
+ params = self._coerce_params(converter_class=converter_class, params=params)
converter_obj = converter_class(**params)
self._registry.register_instance(converter_obj, name=converter_id)
@@ -226,6 +368,51 @@ def _resolve_converter_params(self, *, params: dict[str, Any]) -> dict[str, Any]
resolved["converter"] = conv_obj
return resolved
+ @staticmethod
+ def _coerce_params(*, converter_class: type, params: dict[str, Any]) -> dict[str, Any]:
+ """
+ Coerce parameter values to match the converter's __init__ type annotations.
+
+ The frontend sends all values as strings; this converts them to int, float,
+ or bool as needed based on the constructor signature.
+
+ Returns:
+ Params dict with values coerced to the expected types.
+ """
+ try:
+ sig = inspect.signature(converter_class.__init__)
+ except (ValueError, TypeError):
+ return params
+
+ coerced = dict(params)
+ for name, value in coerced.items():
+ if name not in sig.parameters or not isinstance(value, str):
+ continue
+ annotation = sig.parameters[name].annotation
+ if annotation is inspect.Parameter.empty:
+ continue
+
+ origin = get_origin(annotation)
+ # Unwrap Optional[X] to X
+ if origin is Union:
+ args = get_args(annotation)
+ non_none = [a for a in args if a is not type(None)]
+ if len(non_none) == 1:
+ annotation = non_none[0]
+ origin = get_origin(annotation)
+
+ try:
+ if annotation is int:
+ coerced[name] = int(value)
+ elif annotation is float:
+ coerced[name] = float(value)
+ elif annotation is bool:
+ coerced[name] = value.lower() in ("true", "1", "yes")
+ except (ValueError, TypeError):
+ pass
+
+ return coerced
+
def _gather_converters(self, *, converter_ids: list[str]) -> list[tuple[str, str, Any]]:
"""
Gather converters to apply from IDs.
diff --git a/tests/unit/backend/test_api_routes.py b/tests/unit/backend/test_api_routes.py
index 0618ed150..cbfc74f3b 100644
--- a/tests/unit/backend/test_api_routes.py
+++ b/tests/unit/backend/test_api_routes.py
@@ -27,6 +27,7 @@
)
from pyrit.backend.models.common import PaginationInfo
from pyrit.backend.models.converters import (
+ ConverterCatalogResponse,
ConverterInstance,
ConverterInstanceListResponse,
ConverterPreviewResponse,
@@ -865,6 +866,29 @@ def test_list_converters(self, client: TestClient) -> None:
data = response.json()
assert data["items"] == []
+ def test_list_converter_catalog(self, client: TestClient) -> None:
+ """Test listing available converter types from the converter catalog."""
+ with patch("pyrit.backend.routes.converters.get_converter_service") as mock_get_service:
+ mock_service = MagicMock()
+ mock_service.list_converter_catalog_async = AsyncMock(
+ return_value=ConverterCatalogResponse(
+ items=[
+ {
+ "converter_type": "Base64Converter",
+ "supported_input_types": ["text"],
+ "supported_output_types": ["text"],
+ }
+ ]
+ )
+ )
+ mock_get_service.return_value = mock_service
+
+ response = client.get("/api/converters/catalog")
+
+ assert response.status_code == status.HTTP_200_OK
+ data = response.json()
+ assert data["items"][0]["converter_type"] == "Base64Converter"
+
def test_create_converter_success(self, client: TestClient) -> None:
"""Test successful converter instance creation."""
with patch("pyrit.backend.routes.converters.get_converter_service") as mock_get_service:
diff --git a/tests/unit/backend/test_converter_service.py b/tests/unit/backend/test_converter_service.py
index 0deb273b2..04e41e32c 100644
--- a/tests/unit/backend/test_converter_service.py
+++ b/tests/unit/backend/test_converter_service.py
@@ -76,6 +76,32 @@ async def test_list_converters_returns_converters_from_registry(self) -> None:
assert result.items[0].converter_specific_params == {"param1": "value1", "param2": 42}
+class TestListConverterCatalog:
+ """Tests for ConverterService.list_converter_catalog_async method."""
+
+ @pytest.mark.asyncio
+ async def test_list_converter_catalog_returns_known_converter_types(self) -> None:
+ """Test that the converter catalog exposes available converter classes."""
+ service = ConverterService()
+
+ result = await service.list_converter_catalog_async()
+
+ converter_types = [item.converter_type for item in result.items]
+ assert "Base64Converter" in converter_types
+ assert "CaesarConverter" in converter_types
+
+ @pytest.mark.asyncio
+ async def test_list_converter_catalog_includes_supported_types(self) -> None:
+ """Test that catalog entries include supported input and output types."""
+ service = ConverterService()
+
+ result = await service.list_converter_catalog_async()
+
+ base64_entry = next(item for item in result.items if item.converter_type == "Base64Converter")
+ assert "text" in base64_entry.supported_input_types
+ assert "text" in base64_entry.supported_output_types
+
+
class TestGetConverter:
"""Tests for ConverterService.get_converter method."""