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
62 changes: 55 additions & 7 deletions frontend/src/components/Config/CreateTargetDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen, waitFor, fireEvent, within } from "@testing-library/react";
import { act, render, screen, waitFor, fireEvent, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { makeTarget } from "@/test-utils/targetFixtures";
Expand Down Expand Up @@ -144,6 +144,19 @@ async function selectTargetType(value: string): Promise<void> {
restoreDialogAccessibility();
}

// The catalog fetch mock (see beforeEach) resolves on mount, and its
// setCatalogEntries/setCatalogStatus updates land on the next microtask
// tick. Tests that follow up with an `await` (selectTargetType,
// openTargetTypePicker, userEvent, ...) give React a chance to settle that
// update inside their own act()-wrapped waiting. Tests that only make
// synchronous assertions after `render` never yield, so the update fires
// after the test body returns and React reports it as outside act(...).
// Call this right after `render` in those synchronous tests to flush it
// deterministically.
async function flushCatalogFetch(): Promise<void> {
await act(async () => {});
}

describe("parseWeight", () => {
it("rejects empty input", () => {
expect(parseWeight("")).toEqual({ ok: false, error: "Weight is required" });
Expand Down Expand Up @@ -233,12 +246,13 @@ describe("CreateTargetDialog", () => {
dialogAccessibilityObserver.disconnect();
});

it("should render dialog when open", () => {
it("should render dialog when open", async () => {
render(
<TestWrapper>
<CreateTargetDialog {...defaultProps} />
</TestWrapper>
);
await flushCatalogFetch();

expect(screen.getByText("Create New Target")).toBeInTheDocument();
expect(screen.getByText("Create Target")).toBeInTheDocument();
Expand Down Expand Up @@ -313,7 +327,7 @@ describe("CreateTargetDialog", () => {
expect(picker).not.toHaveTextContent("Select a target type");
});

it("should disable target selection while catalog details are loading", () => {
it("should expose fallback target choices while catalog details are loading", async () => {
mockedTargetsApi.listTargetCatalog.mockReturnValue(
new Promise<TargetCatalogResponse>(() => {}),
);
Expand All @@ -325,7 +339,38 @@ describe("CreateTargetDialog", () => {
);

expect(screen.getByText("Loading target details...")).toBeInTheDocument();
expect(screen.getByRole("combobox", { name: /target type/i })).toBeDisabled();
await openTargetTypePicker();
Comment thread
romanlutz marked this conversation as resolved.
expect(screen.getAllByRole("option")).toHaveLength(8);
});

it("should preserve a fallback selection when catalog details arrive", async () => {
let resolveCatalog: ((catalog: TargetCatalogResponse) => void) | null = null;
mockedTargetsApi.listTargetCatalog.mockReturnValue(
new Promise<TargetCatalogResponse>((resolve) => {
resolveCatalog = resolve;
}),
);

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

await selectTargetType("OpenAIChatTarget");
expect(screen.getByRole("combobox", { name: /target type/i })).toHaveTextContent("OpenAI chat");

if (resolveCatalog === null) {
throw new Error("Catalog resolver was not initialized");
}
await act(async () => {
resolveCatalog(TARGET_CATALOG);
});

await waitFor(() => {
expect(screen.queryByText("Loading target details...")).not.toBeInTheDocument();
});
expect(screen.getByRole("combobox", { name: /target type/i })).toHaveTextContent("OpenAI chat");
});

it("should keep all target types selectable and explain when catalog details fail to load", async () => {
Expand Down Expand Up @@ -355,12 +400,13 @@ describe("CreateTargetDialog", () => {
expect(screen.queryByText("Create New Target")).not.toBeInTheDocument();
});

it("should have Create button disabled until type and endpoint filled", () => {
it("should have Create button disabled until type and endpoint filled", async () => {
render(
<TestWrapper>
<CreateTargetDialog {...defaultProps} />
</TestWrapper>
);
await flushCatalogFetch();

const createButton = screen.getByText("Create Target");
expect(createButton.closest("button")).toBeDisabled();
Expand Down Expand Up @@ -603,12 +649,13 @@ describe("CreateTargetDialog", () => {
});
});

it("should display supported target initializer guidance", () => {
it("should display supported target initializer guidance", async () => {
render(
<TestWrapper>
<CreateTargetDialog {...defaultProps} />
</TestWrapper>
);
await flushCatalogFetch();

expect(screen.getByText("target", { selector: "code" })).toBeInTheDocument();
expect(
Expand All @@ -619,12 +666,13 @@ describe("CreateTargetDialog", () => {
).not.toBeInTheDocument();
});

it("should render .pyrit_conf_example as an accessible link", () => {
it("should render .pyrit_conf_example as an accessible link", async () => {
render(
<TestWrapper>
<CreateTargetDialog {...defaultProps} />
</TestWrapper>
);
await flushCatalogFetch();

const link = screen.getByRole("link", { name: ".pyrit_conf_example" });
expect(link).toBeInTheDocument();
Expand Down
11 changes: 4 additions & 7 deletions frontend/src/components/Config/CreateTargetDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -269,11 +269,9 @@ export default function CreateTargetDialog({ open, onClose, onCreated, existingT
}, [catalogEntries])
const catalogMetadataAvailable = catalogTargetTypeOptions.length > 0
const catalogUnavailable = catalogStatus !== 'loading' && !catalogMetadataAvailable
const targetTypeOptions = catalogStatus === 'loading'
? []
: catalogMetadataAvailable
? catalogTargetTypeOptions
: FALLBACK_TARGET_CATALOG_ENTRIES
const targetTypeOptions = catalogMetadataAvailable
? catalogTargetTypeOptions
: FALLBACK_TARGET_CATALOG_ENTRIES

const formShape = TARGET_FORM_SHAPES[targetType]
const isRoundRobin = formShape === 'roundrobin'
Expand Down Expand Up @@ -531,9 +529,8 @@ export default function CreateTargetDialog({ open, onClose, onCreated, existingT
<Dropdown
aria-label="Target Type"
className={styles.fullWidthSelect}
disabled={catalogStatus === 'loading'}
listbox={{ className: styles.targetTypeListbox }}
placeholder={catalogStatus === 'loading' ? 'Loading target types...' : 'Select a target type'}
placeholder="Select a target type"
positioning={{ matchTargetSize: 'width' }}
selectedOptions={targetType ? [targetType] : []}
value={targetType ? selectedTargetDisplayName : ''}
Expand Down
4 changes: 3 additions & 1 deletion pyrit/backend/services/target_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- Retrieved from registry (pre-registered at startup or created earlier)
"""

import asyncio
import logging
from functools import lru_cache
from typing import Any, Literal, cast
Expand Down Expand Up @@ -137,14 +138,15 @@ async def list_target_catalog_async(self) -> TargetCatalogResponse:
Returns:
TargetCatalogResponse containing all available target classes.
"""
metadata_items = await asyncio.to_thread(self._registry.get_all_registered_class_metadata)
items: list[TargetCatalogEntry] = [
TargetCatalogEntry(
target_type=metadata.class_name,
parameters=[p for p in metadata.parameters if p.is_string_coercible],
supported_auth_modes=cast("list[Literal['api_key', 'identity']]", list(metadata.supported_auth_modes)),
description=metadata.class_description or None,
)
for metadata in self._registry.get_all_registered_class_metadata()
for metadata in metadata_items
]
return TargetCatalogResponse(items=items)

Expand Down
4 changes: 2 additions & 2 deletions pyrit/prompt_target/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@
if TYPE_CHECKING:
from pyrit.prompt_target.hugging_face.hugging_face_chat_target import HuggingFaceChatTarget

# Lazy imports for modules with heavy third-party dependencies (PEP 562).
# HuggingFaceChatTarget imports `transformers` which adds ~4s to startup.
# Keep optional inference targets lazy so package imports do not load their
# target-specific runtime modules.
_LAZY_IMPORTS: dict[str, str] = {
"HuggingFaceChatTarget": "pyrit.prompt_target.hugging_face.hugging_face_chat_target",
}
Expand Down
39 changes: 24 additions & 15 deletions pyrit/prompt_target/hugging_face/hugging_face_chat_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,19 @@
import logging
import warnings
from pathlib import Path
from typing import Any, cast

from transformers import (
AutoModelForCausalLM, # type: ignore[ty:possibly-missing-import]
AutoTokenizer, # type: ignore[ty:possibly-missing-import]
BatchEncoding,
PretrainedConfig,
)
from typing import TYPE_CHECKING, Any, cast

from pyrit.common import default_values
from pyrit.common.download_hf_model import download_specific_files_async
from pyrit.exceptions import EmptyResponseException, pyrit_target_retry
from pyrit.models import ComponentIdentifier, Message, construct_response_from_request
from pyrit.prompt_target.common.prompt_target import PromptTarget
from pyrit.prompt_target.common.target_capabilities import TargetCapabilities
from pyrit.prompt_target.common.target_configuration import TargetConfiguration
from pyrit.prompt_target.common.utils import limit_requests_per_minute

if TYPE_CHECKING:
from transformers import BatchEncoding

logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -212,6 +207,11 @@ def _load_from_path(self, path: str, **kwargs: Any) -> None:
path: The path to load the model and tokenizer from.
**kwargs: Additional keyword arguments to pass to the model loader.
"""
from transformers import (
Comment thread
romanlutz marked this conversation as resolved.
AutoModelForCausalLM, # type: ignore[ty:possibly-missing-import]
AutoTokenizer, # type: ignore[ty:possibly-missing-import]
)

logger.info(f"Loading model and tokenizer from path: {path}...")
self.tokenizer = AutoTokenizer.from_pretrained(path, trust_remote_code=self.trust_remote_code)
self.model = AutoModelForCausalLM.from_pretrained(path, trust_remote_code=self.trust_remote_code, **kwargs)
Expand All @@ -223,6 +223,8 @@ def is_model_id_valid(self) -> bool:
Returns:
bool: True if valid, False otherwise.
"""
from transformers import PretrainedConfig # type: ignore[ty:possibly-missing-import]

try:
# Attempt to load the configuration of the model
PretrainedConfig.from_pretrained(self.model_id or "")
Expand Down Expand Up @@ -263,10 +265,14 @@ async def load_model_and_tokenizer_async(self) -> None:
return

if self.model_path:
# Load the tokenizer and model from the local directory
# Load the tokenizer and model from the local directory. This imports `transformers`
# and performs blocking disk I/O, so it is offloaded to a worker thread to keep the
# event loop responsive.
logger.info(f"Loading model from local path: {self.model_path}...")
self._load_from_path(self.model_path, **optional_model_kwargs)
await asyncio.to_thread(self._load_from_path, self.model_path, **optional_model_kwargs)
else:
from pyrit.common.download_hf_model import download_specific_files_async

# Define the default Hugging Face cache directory
cache_dir = (
Path.home()
Expand Down Expand Up @@ -295,12 +301,15 @@ async def load_model_and_tokenizer_async(self) -> None:
Path(cache_dir),
)

# Load the tokenizer and model from the downloaded local snapshot.
# Load the tokenizer and model from the downloaded local snapshot. This imports
# `transformers` and performs blocking disk I/O, so it is offloaded to a worker
# thread to keep the event loop responsive.
logger.info(f"Loading model {self.model_id} from cache path: {cache_dir}...")
self._load_from_path(str(cache_dir), **optional_model_kwargs)
await asyncio.to_thread(self._load_from_path, str(cache_dir), **optional_model_kwargs)

# Move the model to the correct device
self.model = self.model.to(self.device)
# Move the model to the correct device. This can be a slow, blocking operation
# (e.g., copying weights to a GPU), so it is offloaded to a worker thread as well.
self.model = await asyncio.to_thread(self.model.to, self.device)

# Debug prints to check types
logger.info(f"Model loaded: {type(self.model)}")
Expand Down
Loading
Loading