diff --git a/frontend/src/components/Chat/ChatInputArea.tsx b/frontend/src/components/Chat/ChatInputArea.tsx index 615244d0e9..ec3b638ea7 100644 --- a/frontend/src/components/Chat/ChatInputArea.tsx +++ b/frontend/src/components/Chat/ChatInputArea.tsx @@ -439,7 +439,7 @@ const ChatInputArea = forwardRef(functi />
- {activeTarget && activeTarget.supports_multi_turn === false && ( + {activeTarget && (activeTarget.capabilities?.supports_multi_turn ?? activeTarget.supports_multi_turn) === false && ( m.role === 'user') + const singleTurnLimitReached = (activeTarget?.capabilities?.supports_multi_turn ?? activeTarget?.supports_multi_turn) === false && messages.some(m => m.role === 'user') // Operator locking: if the loaded attack's operator differs from the current // user's operator label, the conversation should be read-only. @@ -561,7 +561,7 @@ export default function ChatWindow({ onBranchConversation={attackResultId && activeConversationId ? handleBranchConversation : undefined} onBranchAttack={activeTarget && activeConversationId ? handleBranchAttack : undefined} isLoading={isLoadingAttack || isLoadingMessages || awaitingConversationLoad} - isSingleTurn={activeTarget?.supports_multi_turn === false} + isSingleTurn={(activeTarget?.capabilities?.supports_multi_turn ?? activeTarget?.supports_multi_turn) === false} isOperatorLocked={isOperatorLocked} isCrossTarget={isCrossTargetLocked} noTargetSelected={!activeTarget} diff --git a/frontend/src/components/Config/TargetTable.styles.ts b/frontend/src/components/Config/TargetTable.styles.ts index 6c02a7077c..35f7aca482 100644 --- a/frontend/src/components/Config/TargetTable.styles.ts +++ b/frontend/src/components/Config/TargetTable.styles.ts @@ -9,6 +9,12 @@ export const useTargetTableStyles = makeStyles({ tableLayout: 'fixed', width: '100%', }, + stickyHeader: { + position: 'sticky', + top: 0, + backgroundColor: tokens.colorNeutralBackground1, + zIndex: 1, + }, activeRow: { backgroundColor: tokens.colorBrandBackground2, }, @@ -20,4 +26,17 @@ export const useTargetTableStyles = makeStyles({ whiteSpace: 'pre-line', wordBreak: 'break-word', }, + capabilityCell: { + width: '80px', + textAlign: 'center', + }, + capabilityIconSupported: { + color: tokens.colorPaletteGreenForeground1, + }, + capabilityIconUnsupported: { + color: tokens.colorPaletteRedForeground1, + }, + helpHeader: { + cursor: 'help', + }, }) diff --git a/frontend/src/components/Config/TargetTable.test.tsx b/frontend/src/components/Config/TargetTable.test.tsx index 52618ef2ff..2db78187a1 100644 --- a/frontend/src/components/Config/TargetTable.test.tsx +++ b/frontend/src/components/Config/TargetTable.test.tsx @@ -17,12 +17,28 @@ const sampleTargets: TargetInstance[] = [ target_type: 'OpenAIChatTarget', endpoint: 'https://api.openai.com', model_name: 'gpt-4', + capabilities: { + supports_multi_turn: true, + supports_multi_message_pieces: true, + supports_json_schema: true, + supports_json_output: true, + supports_editable_history: true, + supports_system_prompt: true, + }, }, { target_registry_name: 'azure_image_dalle', target_type: 'AzureImageTarget', endpoint: 'https://azure.openai.com', model_name: 'dall-e-3', + capabilities: { + supports_multi_turn: false, + supports_multi_message_pieces: false, + supports_json_schema: false, + supports_json_output: false, + supports_editable_history: false, + supports_system_prompt: false, + }, }, { target_registry_name: 'text_target_basic', @@ -58,7 +74,7 @@ describe('TargetTable', () => { expect(screen.getAllByText('TextTarget').length).toBeGreaterThanOrEqual(1) }) - it('should display Type, Model, Endpoint and Parameters columns', () => { + it('should display Type, Model, Endpoint, capability columns and Parameters columns', () => { render( @@ -68,6 +84,12 @@ describe('TargetTable', () => { expect(screen.getByText('Type')).toBeInTheDocument() expect(screen.getByText('Model')).toBeInTheDocument() expect(screen.getByText('Endpoint')).toBeInTheDocument() + expect(screen.getByText('Multi-turn')).toBeInTheDocument() + expect(screen.getByText('Multi-piece')).toBeInTheDocument() + expect(screen.getByText('JSON Schema')).toBeInTheDocument() + expect(screen.getByText('JSON Output')).toBeInTheDocument() + expect(screen.getByText('Edit History')).toBeInTheDocument() + expect(screen.getByText('System Prompt')).toBeInTheDocument() expect(screen.getByText('Parameters')).toBeInTheDocument() }) @@ -151,8 +173,22 @@ describe('TargetTable', () => { ) + // Dashes for model, endpoint, 6 capability columns (all unknown), and params const dashes = screen.getAllByText('—') - expect(dashes.length).toBeGreaterThanOrEqual(2) + expect(dashes).toHaveLength(9) + }) + + it('should show dash for capability columns when capabilities is absent', () => { + render( + + + + ) + + // TextTarget has no capabilities — all 6 should be dashes + const dashes = screen.getAllByText('—') + // model (—) + endpoint (—) + 6 capabilities (—) + params (—) = 9 + expect(dashes).toHaveLength(9) }) it('should display target_specific_params when present', () => { diff --git a/frontend/src/components/Config/TargetTable.tsx b/frontend/src/components/Config/TargetTable.tsx index 4b9011e33d..373e84edd7 100644 --- a/frontend/src/components/Config/TargetTable.tsx +++ b/frontend/src/components/Config/TargetTable.tsx @@ -12,7 +12,7 @@ import { Tooltip, Select, } from '@fluentui/react-components' -import { CheckmarkRegular } from '@fluentui/react-icons' +import { CheckmarkRegular, CheckmarkCircleFilled, DismissCircleFilled } from '@fluentui/react-icons' import type { TargetInstance } from '../../types' import { useTargetTableStyles } from './TargetTable.styles' @@ -39,6 +39,35 @@ function formatParams(params?: Record | null): string { return parts.join('\n') } +/** Capability column definitions with tooltip descriptions. */ +const CAPABILITY_COLUMNS = [ + { key: 'supports_multi_turn', label: 'Multi-turn', tooltip: 'Supports multi-turn conversation history' }, + { key: 'supports_multi_message_pieces', label: 'Multi-piece', tooltip: 'Supports multiple message pieces in a single request' }, + { key: 'supports_json_schema', label: 'JSON Schema', tooltip: 'Supports constraining output to a JSON schema' }, + { key: 'supports_json_output', label: 'JSON Output', tooltip: 'Supports JSON output format' }, + { key: 'supports_editable_history', label: 'Edit History', tooltip: 'Allows attack history to be modified' }, + { key: 'supports_system_prompt', label: 'System Prompt', tooltip: 'Supports system prompts' }, +] as const + +const COLUMN_TOOLTIPS = { + type: 'Target class implementation', + model: 'Configured model name. A dotted underline indicates the deployment alias differs from the underlying model — hover the value to see it.', + endpoint: 'API endpoint URL the target sends requests to', + parameters: 'Target-specific configuration parameters (e.g., reasoning_effort, max_output_tokens)', +} as const + +/** Render a capability indicator: ✓ (green) / ✗ (red) / — (unknown). */ +function CapabilityCell({ value }: { value: boolean | undefined }) { + const styles = useTargetTableStyles() + if (value === undefined) { + return + } + if (value) { + return + } + return +} + /** Render the model cell with a tooltip when underlying model differs. */ function ModelCell({ target }: { target: TargetInstance }) { const displayName = target.model_name || '—' @@ -62,6 +91,22 @@ function ModelCell({ target }: { target: TargetInstance }) { return {displayName} } +/** Render capability cells for a target. */ +function CapabilityCells({ target }: { target: TargetInstance }) { + const styles = useTargetTableStyles() + return ( + <> + {CAPABILITY_COLUMNS.map(({ key }) => ( + + + + ))} + + ) +} + export default function TargetTable({ targets, activeTarget, onSetActiveTarget }: TargetTableProps) { const styles = useTargetTableStyles() const [typeFilter, setTypeFilter] = useState('') @@ -99,6 +144,7 @@ export default function TargetTable({ targets, activeTarget, onSetActiveTarget } {activeTarget.endpoint || '—'} + {formatParams(activeTarget.target_specific_params) || '—'} @@ -126,13 +172,36 @@ export default function TargetTable({ targets, activeTarget, onSetActiveTarget } )} - + - Type - Model - Endpoint - Parameters + + + Type + + + + + Model + + + + + Endpoint + + + {CAPABILITY_COLUMNS.map(({ key, label, tooltip }) => ( + + + {label} + + + ))} + + + Parameters + + @@ -167,6 +236,7 @@ export default function TargetTable({ targets, activeTarget, onSetActiveTarget } {target.endpoint || '—'} + {formatParams(target.target_specific_params) || '—'} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 07d5865123..edf3ad1e74 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -53,6 +53,15 @@ export interface PaginationInfo { // --- Targets --- +export interface TargetCapabilitiesInfo { + supports_multi_turn: boolean + supports_multi_message_pieces: boolean + supports_json_schema: boolean + supports_json_output: boolean + supports_editable_history: boolean + supports_system_prompt: boolean +} + export interface TargetInstance { target_registry_name: string target_type: string @@ -63,6 +72,7 @@ export interface TargetInstance { top_p?: number | null max_requests_per_minute?: number | null supports_multi_turn?: boolean + capabilities?: TargetCapabilitiesInfo | null target_specific_params?: Record | null } diff --git a/pyrit/backend/mappers/target_mappers.py b/pyrit/backend/mappers/target_mappers.py index 1d72822690..526e382a41 100644 --- a/pyrit/backend/mappers/target_mappers.py +++ b/pyrit/backend/mappers/target_mappers.py @@ -5,7 +5,7 @@ Target mappers – domain → DTO translation for target-related models. """ -from pyrit.backend.models.targets import TargetInstance +from pyrit.backend.models.targets import TargetCapabilitiesInfo, TargetInstance from pyrit.prompt_target import PromptTarget @@ -27,6 +27,7 @@ def target_object_to_instance(target_registry_name: str, target_obj: PromptTarge params = identifier.params # Keys that are extracted as top-level TargetInstance fields + # or are internal-only (target_configuration is the verbose capabilities blob). extracted_keys = { "endpoint", "model_name", @@ -36,6 +37,7 @@ def target_object_to_instance(target_registry_name: str, target_obj: PromptTarge "max_requests_per_minute", "supports_multi_turn", "target_specific_params", + "target_configuration", } # Collect remaining params as target_specific_params so the frontend can display them @@ -43,6 +45,16 @@ def target_object_to_instance(target_registry_name: str, target_obj: PromptTarge extra = {k: v for k, v in params.items() if k not in extracted_keys and v is not None} combined_specific = {**extra, **explicit_specific} or None + caps = target_obj.capabilities + capabilities = TargetCapabilitiesInfo( + supports_multi_turn=caps.supports_multi_turn, + supports_multi_message_pieces=caps.supports_multi_message_pieces, + supports_json_schema=caps.supports_json_schema, + supports_json_output=caps.supports_json_output, + supports_editable_history=caps.supports_editable_history, + supports_system_prompt=caps.supports_system_prompt, + ) + return TargetInstance( target_registry_name=target_registry_name, target_type=identifier.class_name, @@ -52,6 +64,7 @@ def target_object_to_instance(target_registry_name: str, target_obj: PromptTarge temperature=params.get("temperature"), top_p=params.get("top_p"), max_requests_per_minute=params.get("max_requests_per_minute"), - supports_multi_turn=target_obj.capabilities.supports_multi_turn, + supports_multi_turn=caps.supports_multi_turn, + capabilities=capabilities, target_specific_params=combined_specific, ) diff --git a/pyrit/backend/models/targets.py b/pyrit/backend/models/targets.py index fef7cbe41e..94a45ac234 100644 --- a/pyrit/backend/models/targets.py +++ b/pyrit/backend/models/targets.py @@ -18,6 +18,23 @@ from pyrit.backend.models.common import PaginationInfo +class TargetCapabilitiesInfo(BaseModel): + """Structured capability flags for a target instance.""" + + supports_multi_turn: bool = Field(..., description="Whether the target supports multi-turn conversation history") + supports_multi_message_pieces: bool = Field( + ..., description="Whether the target supports multiple message pieces in a single request" + ) + supports_json_schema: bool = Field( + ..., description="Whether the target supports constraining output to a JSON schema" + ) + supports_json_output: bool = Field(..., description="Whether the target supports JSON output format") + supports_editable_history: bool = Field( + ..., description="Whether the target allows the attack history to be modified" + ) + supports_system_prompt: bool = Field(..., description="Whether the target supports system prompts") + + class TargetInstance(BaseModel): """ A runtime target instance. @@ -37,6 +54,7 @@ class TargetInstance(BaseModel): top_p: Optional[float] = Field(None, description="Top-p parameter for generation") max_requests_per_minute: Optional[int] = Field(None, description="Maximum requests per minute") supports_multi_turn: bool = Field(True, description="Whether the target supports multi-turn conversation history") + capabilities: Optional[TargetCapabilitiesInfo] = Field(None, description="Structured capability flags") target_specific_params: Optional[dict[str, Any]] = Field(None, description="Additional target-specific parameters") diff --git a/tests/unit/backend/test_mappers.py b/tests/unit/backend/test_mappers.py index 9eda90ab5b..5eb26b2ea9 100644 --- a/tests/unit/backend/test_mappers.py +++ b/tests/unit/backend/test_mappers.py @@ -1257,6 +1257,73 @@ def test_chat_target_extra_params_preserved(self) -> None: assert result.target_specific_params["seed"] == 42 assert result.target_specific_params["max_completion_tokens"] == 2048 + def test_capabilities_populated_from_target_object(self) -> None: + """Test that all 6 capability fields are populated from target_obj.capabilities.""" + target_obj = MagicMock(spec=PromptTarget) + target_obj.capabilities = TargetCapabilities( + supports_multi_turn=True, + supports_multi_message_pieces=True, + supports_json_schema=False, + supports_json_output=True, + supports_editable_history=False, + supports_system_prompt=True, + ) + mock_identifier = ComponentIdentifier( + class_name="OpenAIChatTarget", + class_module="pyrit.prompt_target", + params={"endpoint": "https://api.openai.com", "model_name": "gpt-4"}, + ) + target_obj.get_identifier.return_value = mock_identifier + + result = target_object_to_instance("t-1", target_obj) + + assert result.capabilities is not None + assert result.capabilities.supports_multi_turn is True + assert result.capabilities.supports_multi_message_pieces is True + assert result.capabilities.supports_json_schema is False + assert result.capabilities.supports_json_output is True + assert result.capabilities.supports_editable_history is False + assert result.capabilities.supports_system_prompt is True + + def test_capabilities_matches_legacy_supports_multi_turn(self) -> None: + """Test that legacy supports_multi_turn field matches capabilities.supports_multi_turn.""" + target_obj = MagicMock(spec=PromptTarget) + target_obj.capabilities = TargetCapabilities(supports_multi_turn=False) + mock_identifier = ComponentIdentifier( + class_name="TextTarget", + class_module="pyrit.prompt_target", + ) + target_obj.get_identifier.return_value = mock_identifier + + result = target_object_to_instance("t-1", target_obj) + + assert result.supports_multi_turn is False + assert result.capabilities is not None + assert result.capabilities.supports_multi_turn is False + assert result.supports_multi_turn == result.capabilities.supports_multi_turn + + def test_target_configuration_excluded_from_target_specific_params(self) -> None: + """Test that the verbose target_configuration blob is filtered from target_specific_params.""" + target_obj = MagicMock(spec=PromptTarget) + target_obj.capabilities = TargetCapabilities(supports_multi_turn=True) + mock_identifier = ComponentIdentifier( + class_name="OpenAIChatTarget", + class_module="pyrit.prompt_target", + params={ + "endpoint": "https://api.openai.com", + "model_name": "gpt-4", + "target_configuration": {"capabilities": {"supports_multi_turn": True}}, + "reasoning_effort": "high", + }, + ) + target_obj.get_identifier.return_value = mock_identifier + + result = target_object_to_instance("t-1", target_obj) + + assert result.target_specific_params is not None + assert "target_configuration" not in result.target_specific_params + assert result.target_specific_params["reasoning_effort"] == "high" + # ============================================================================ # Converter Mapper Tests