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
10 changes: 9 additions & 1 deletion pyrit/backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
version,
)
from pyrit.backend.services.configuration_file_service import ConfigurationFileService
from pyrit.backend.services.converter_service import get_converter_service
from pyrit.backend.services.environment_file_service import EnvironmentFileService
from pyrit.common.path import CONFIGURATION_DIRECTORY_PATH
from pyrit.registry import InitializerRegistry
Expand Down Expand Up @@ -110,7 +111,14 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# don't emit noise and don't perform filesystem side effects.
setup_frontend()

yield
converter_service = await asyncio.to_thread(get_converter_service)
try:
yield
finally:
try:
await converter_service.close_async()
finally:
get_converter_service.cache_clear()


app = FastAPI(
Expand Down
16 changes: 13 additions & 3 deletions pyrit/backend/mappers/converter_mappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@
from pyrit.models import ConverterIdentifier


def converter_object_to_instance(converter_id: str, converter_obj: Converter) -> ConverterInstance:
def converter_object_to_instance(
*,
converter_id: str,
converter_obj: Converter,
is_llm_based: bool,
description: str | None,
) -> ConverterInstance:
"""
Build a ConverterInstance DTO from a registry converter object.

Expand All @@ -24,13 +30,17 @@ def converter_object_to_instance(converter_id: str, converter_obj: Converter) ->
on the wire.

Args:
converter_id: The unique converter instance identifier.
converter_obj: The domain Converter object from the registry.
converter_id (str): The unique converter instance identifier.
converter_obj (Converter): The domain Converter object from the registry.
is_llm_based (bool): Whether the converter class requires an LLM target.
description (str | None): The converter class description.

Returns:
ConverterInstance DTO wrapping the converter's identifier.
"""
return ConverterInstance(
converter_id=converter_id,
identifier=ConverterIdentifier.from_component_identifier(converter_obj.get_identifier()),
is_llm_based=is_llm_based,
description=description,
)
4 changes: 4 additions & 0 deletions pyrit/backend/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@
ConverterInstanceListResponse,
ConverterPreviewRequest,
ConverterPreviewResponse,
ConverterTypeEntry,
ConverterTypeResponse,
CreateConverterRequest,
CreateConverterResponse,
PreviewStep,
Expand Down Expand Up @@ -97,6 +99,8 @@
"ConverterInstanceListResponse": "pyrit.backend.models.converters",
"ConverterPreviewRequest": "pyrit.backend.models.converters",
"ConverterPreviewResponse": "pyrit.backend.models.converters",
"ConverterTypeEntry": "pyrit.backend.models.converters",
"ConverterTypeResponse": "pyrit.backend.models.converters",
"CreateConverterRequest": "pyrit.backend.models.converters",
"CreateConverterResponse": "pyrit.backend.models.converters",
"PreviewStep": "pyrit.backend.models.converters",
Expand Down
2 changes: 2 additions & 0 deletions pyrit/backend/models/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

from pydantic import BaseModel, Field

REGISTRY_INSTANCE_NAME_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"


class PaginationInfo(BaseModel):
"""Pagination metadata for list responses."""
Expand Down
39 changes: 33 additions & 6 deletions pyrit/backend/models/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@

from pydantic import BaseModel, Field

from pyrit.backend.models.common import REGISTRY_INSTANCE_NAME_PATTERN
from pyrit.models import ConverterIdentifier, Parameter, PromptDataType

__all__ = [
"ConverterCatalogEntry",
"ConverterCatalogResponse",
"ConverterInstance",
"ConverterInstanceListResponse",
"ConverterTypeEntry",
"ConverterTypeResponse",
"CreateConverterRequest",
"CreateConverterResponse",
"ConverterPreviewRequest",
Expand All @@ -27,11 +30,11 @@


# ============================================================================
# Converter Catalog (Available Types)
# Converter Types
# ============================================================================


class ConverterCatalogEntry(BaseModel):
class ConverterTypeEntry(BaseModel):
"""A converter type available from the backend registry."""

converter_type: str = Field(..., description="Converter class name (e.g., 'Base64Converter')")
Expand All @@ -48,10 +51,17 @@ class ConverterCatalogEntry(BaseModel):
description: str | None = Field(None, description="Short description of the converter from its docstring")


class ConverterCatalogResponse(BaseModel):
class ConverterTypeResponse(BaseModel):
"""Response for listing available converter types from the registry."""

items: list[ConverterCatalogEntry] = Field(..., description="List of available converter types")
items: list[ConverterTypeEntry] = Field(..., description="List of available converter types")


# LEGACY COMPATIBILITY: ``Catalog`` is the pre-registry name for ``Type``. These
# aliases exist only so the un-migrated chat UI keeps working; delete them with the
# /catalog route when that UI switches to the /types API.
ConverterCatalogEntry = ConverterTypeEntry
ConverterCatalogResponse = ConverterTypeResponse


# ============================================================================
Expand All @@ -68,8 +78,10 @@ class ConverterInstance(BaseModel):
for the converter's class, supported data types, and constructor params.
"""

converter_id: str = Field(..., description="Unique converter instance identifier")
converter_id: str = Field(..., description="Converter instance registry name")
identifier: ConverterIdentifier = Field(..., description="The converter's identity/configuration projection")
is_llm_based: bool = Field(False, description="Whether this converter requires an LLM target")
description: str | None = Field(None, description="Short description of the converter type")


class ConverterInstanceListResponse(BaseModel):
Expand All @@ -81,7 +93,17 @@ class ConverterInstanceListResponse(BaseModel):
class CreateConverterRequest(BaseModel):
"""Request to create a new converter instance."""

# LEGACY COMPATIBILITY: The current chat UI does not send a name. Make this
# field required when the chat-migration stack layer sends explicit names.
name: str | None = Field(
None,
min_length=1,
pattern=REGISTRY_INSTANCE_NAME_PATTERN,
description="Unique registry name; omitted only for legacy chat compatibility",
)
type: str = Field(..., description="Converter type (e.g., 'Base64Converter')")
# LEGACY COMPATIBILITY: The former create response echoed this field. Remove
# it after clients use the complete ConverterInstance response.
display_name: str | None = Field(None, description="Human-readable display name")
params: dict[str, Any] = Field(
default_factory=dict,
Expand All @@ -90,7 +112,12 @@ class CreateConverterRequest(BaseModel):


class CreateConverterResponse(BaseModel):
"""Response after creating a converter instance."""
"""
Legacy response model for downstream imports.

POST /converters now returns ``ConverterInstance``. Remove this model when
downstream clients no longer import the former response type.
"""

converter_id: str = Field(..., description="Unique converter instance identifier")
converter_type: str = Field(..., description="Converter class name")
Expand Down
25 changes: 21 additions & 4 deletions pyrit/backend/models/targets.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

from pydantic import BaseModel, Field

from pyrit.backend.models.common import PaginationInfo
from pyrit.backend.models.common import REGISTRY_INSTANCE_NAME_PATTERN, PaginationInfo
from pyrit.models import JSONValue, Parameter
from pyrit.models.catalog.target import TargetInstance

Expand All @@ -21,14 +21,16 @@
"TargetCatalogEntry",
"TargetCatalogResponse",
"TargetListResponse",
"TargetTypeEntry",
"TargetTypeResponse",
]


def _default_auth_modes() -> list[Literal["api_key", "identity"]]:
return ["api_key"]


class TargetCatalogEntry(BaseModel):
class TargetTypeEntry(BaseModel):
"""A target type available from the backend registry."""

target_type: str = Field(..., description="Target class name (e.g., 'OpenAIChatTarget')")
Expand All @@ -43,10 +45,17 @@ class TargetCatalogEntry(BaseModel):
description: str | None = Field(None, description="Short description of the target from its docstring")


class TargetCatalogResponse(BaseModel):
class TargetTypeResponse(BaseModel):
"""Response for listing available target types from the registry."""

items: list[TargetCatalogEntry] = Field(..., description="List of available target types")
items: list[TargetTypeEntry] = Field(..., description="List of available target types")


# LEGACY COMPATIBILITY: ``Catalog`` is the pre-registry name for ``Type``. These
# aliases exist only so the un-migrated configuration UI keeps working; delete them
# with the /catalog route when that UI switches to the /types API.
TargetCatalogEntry = TargetTypeEntry
TargetCatalogResponse = TargetTypeResponse


class TargetListResponse(BaseModel):
Expand All @@ -59,6 +68,14 @@ class TargetListResponse(BaseModel):
class CreateTargetRequest(BaseModel):
"""Request to create a new target instance."""

# LEGACY COMPATIBILITY: The current target configuration UI does not send a
# name. Make this field required after that UI sends explicit registry names.
name: str | None = Field(
None,
min_length=1,
pattern=REGISTRY_INSTANCE_NAME_PATTERN,
description="Unique registry name; omitted only for legacy UI compatibility",
)
type: str = Field(..., description="Target type (e.g., 'OpenAIChatTarget')")
params: dict[str, JSONValue] = Field(default_factory=dict, description="Target constructor parameters")
auth_mode: Literal["api_key", "identity"] = Field(
Expand Down
48 changes: 42 additions & 6 deletions pyrit/backend/routes/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
ConverterInstanceListResponse,
ConverterPreviewRequest,
ConverterPreviewResponse,
ConverterTypeResponse,
CreateConverterRequest,
CreateConverterResponse,
)
from pyrit.backend.services.converter_service import get_converter_service

Expand All @@ -42,38 +42,57 @@ async def list_converters() -> ConverterInstanceListResponse: # pyrit-async-suf
return await service.list_converters_async()


@router.get(
"/types",
response_model=ConverterTypeResponse,
)
async def list_converter_types() -> ConverterTypeResponse: # pyrit-async-suffix-exempt
"""
List converter types projected from ``ConverterRegistry`` metadata.

Returns:
ConverterTypeResponse: Available converter types and build parameters.
"""
service = get_converter_service()
return await service.list_converter_types_async()


@router.get(
"/catalog",
response_model=ConverterCatalogResponse,
)
async def list_converter_catalog() -> ConverterCatalogResponse: # pyrit-async-suffix-exempt
"""
List all available converter types from the backend converter registry.
Return the legacy catalog projection used by the current chat UI.

LEGACY COMPATIBILITY: pre-registry alias for ``/converters/types`` that hides
registry-reference parameters. Deleted with the rest of the ``catalog`` concept
when the chat-migration layer of this stack switches to ``/converters/types``.

Returns:
ConverterCatalogResponse: List of available converter types.
ConverterCatalogResponse: The scalar-only legacy catalog projection.
"""
service = get_converter_service()
return await service.list_converter_catalog_async()


@router.post(
"",
response_model=CreateConverterResponse,
response_model=ConverterInstance,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking backward-compatibility note: the /catalog routes preserve part of the old API, but we should be aware of these changes for existing callers:

  • POST /api/converters drops top-level converter_type and display_name. The type is now at identifier.class_name, so clients parsing the old CreateConverterResponse need updating even though that model remains importable.
  • Duplicate .instances.register(...) calls now raise unless replace=True is passed. Target/converter registries also reject newly reserved names such as catalog and types.
  • REST Path inputs are now upload-only, using base64 data URIs. Uploads for file parameters still typed as str are no longer decoded.
  • Changing AddImageVideoConverter.video_path from str to Path also changes registry coercion, including the handling of Azure Blob URLs.
  • Unnamed targets now receive random compat_... names instead of identifier-derived names, and converter IDs are no longer necessarily UUID strings. Clients need to use the returned names as opaque identifiers.
  • PDF, SVG, HTML, text, Markdown, and CSV media now download as application/octet-stream attachments rather than being served for inline rendering.

I'm not asking to block this PR on adding compatibility shims for all of these, but it would be worth documenting them in the migration/release notes so downstream users aren't surprised. The specific correctness regressions are covered by the separate comments.

status_code=status.HTTP_201_CREATED,
responses={
400: {"model": ProblemDetail, "description": "Invalid converter type or parameters"},
},
)
async def create_converter(request: CreateConverterRequest) -> CreateConverterResponse: # pyrit-async-suffix-exempt
async def create_converter(request: CreateConverterRequest) -> ConverterInstance: # pyrit-async-suffix-exempt
"""
Create a new converter instance.

Instantiates a converter with the given type and parameters.
Supports nested converters via converter_id references in params.

Returns:
CreateConverterResponse: The created converter instance details.
ConverterInstance: The created converter instance details.
"""
service = get_converter_service()

Expand Down Expand Up @@ -117,6 +136,23 @@ async def get_converter(converter_id: str) -> ConverterInstance: # pyrit-async-
return converter


@router.delete(
"/{converter_id}",
status_code=status.HTTP_204_NO_CONTENT,
responses={
404: {"model": ProblemDetail, "description": "Converter not found"},
},
)
async def delete_converter(converter_id: str) -> None: # pyrit-async-suffix-exempt
"""Delete a converter instance by registry name."""
service = get_converter_service()
if not await service.delete_converter_async(converter_id=converter_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Converter '{converter_id}' not found",
)


@router.post(
"/preview",
response_model=ConverterPreviewResponse,
Expand Down
Loading