Skip to content

FEAT Add backend APIs#1354

Open
romanlutz wants to merge 14 commits intoAzure:mainfrom
romanlutz:romanlutz/backend_apis
Open

FEAT Add backend APIs#1354
romanlutz wants to merge 14 commits intoAzure:mainfrom
romanlutz:romanlutz/backend_apis

Conversation

@romanlutz
Copy link
Contributor

Description

Adding backend APIs to support upcoming frontend development. This is based on an initial proposal and review.

Tests and Documentation

Includes tests for all APIs.

@rlundeen2 rlundeen2 self-assigned this Feb 6, 2026

# Start backend with uvicorn
# Default to no initializers
if initializers is None:
Copy link
Contributor

@rlundeen2 rlundeen2 Feb 6, 2026

Choose a reason for hiding this comment

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

Chicken/egg, but pretty quickly it'd be nice to update this to use the config, and we can just get rid of initializers here: #1343

Maybe we should check in with Victor and use it to begin with?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Right. Don't want to block on that but that was my feeling, too, while reading that PR.

"""
Attack-related request and response models.

All interactions in the UI are modeled as "attacks" - including manual conversations.
Copy link
Contributor

Choose a reason for hiding this comment

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

One thing to think about is that we likely want the same types of separations we have in workflows. I could also see us wanting to benchmark things or to setup prompt generation.

But for now this is probably good to get started.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That will probably be different since there are no attack results, though.

"""

piece_id: str = Field(..., description="Unique piece identifier")
data_type: str = Field(default="text", description="Data type: 'text', 'image', 'audio', 'video', etc.")
Copy link
Contributor

Choose a reason for hiding this comment

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

wouldn't we need both original_value_datatype and converted_value_data_type?

original_value: Optional[str] = Field(default=None, description="Original value before conversion")
original_value_mime_type: Optional[str] = Field(default=None, description="MIME type of original value")
converted_value: str = Field(..., description="Converted value (text or base64 for media)")
converted_value_mime_type: Optional[str] = Field(default=None, description="MIME type of converted value")
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm guessing we need mime type to upload/download content. If we need it here I could see us needing it elsewhere. Should we add it to MessagePiece? Should we make this a thinner wrapper of that?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's for adding files (imagine any modality other than text).

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds comprehensive backend REST APIs to support upcoming frontend development, implementing an attack-centric design where all user interactions (including manual conversations) are modeled as "attacks". The implementation includes service layers, API routes, Pydantic models, error handling middleware, registries for managing target/converter instances, a CLI tool, and comprehensive test coverage.

Changes:

  • Adds three main services (AttackService, ConverterService, TargetService) for managing attacks, converters, and targets
  • Implements REST API routes for attacks, targets, converters, labels, health, and version endpoints
  • Creates Pydantic models for request/response validation with RFC 7807 error handling
  • Adds instance registries for targets and converters with singleton pattern
  • Implements pyrit_backend CLI command for starting the server with initializer support
  • Includes 900+ lines of comprehensive unit tests for all services and routes

Reviewed changes

Copilot reviewed 35 out of 35 changed files in this pull request and generated 21 comments.

Show a summary per file
File Description
pyrit/backend/services/attack_service.py Service layer for managing attack lifecycle, messages, and scoring (586 lines)
pyrit/backend/services/converter_service.py Service for managing converter instances and previewing conversions (303 lines)
pyrit/backend/services/target_service.py Service for managing target instance creation and retrieval (187 lines)
pyrit/backend/routes/attacks.py REST API endpoints for attack CRUD operations and messaging (249 lines)
pyrit/backend/routes/targets.py REST API endpoints for target instance management (101 lines)
pyrit/backend/routes/converters.py REST API endpoints for converter instances and preview (134 lines)
pyrit/backend/routes/labels.py REST API endpoint for retrieving filter label options (88 lines)
pyrit/backend/models/attacks.py Pydantic models for attack requests/responses (201 lines)
pyrit/backend/models/targets.py Pydantic models for target instances (52 lines)
pyrit/backend/models/converters.py Pydantic models for converter instances and preview (98 lines)
pyrit/backend/models/common.py Common models including RFC 7807 error responses and sensitive field filtering (93 lines)
pyrit/backend/middleware/error_handlers.py RFC 7807 compliant error handler middleware (182 lines)
pyrit/registry/instance_registries/target_registry.py Registry for managing target instances (88 lines)
pyrit/registry/instance_registries/converter_registry.py Registry for managing converter instances (108 lines)
pyrit/cli/pyrit_backend.py CLI command for starting the backend server with initialization support (217 lines)
tests/unit/backend/*.py Comprehensive unit tests for all services, routes, models, and error handlers (2000+ lines)
pyrit/backend/main.py Updated to register new routes and error handlers
pyproject.toml Adds pyrit_backend CLI entry point and documentation exemption
frontend/dev.py Updated to use pyrit_backend CLI instead of direct uvicorn

Comment on lines +89 to +153
async def list_converters(self) -> ConverterInstanceListResponse:
"""
List all converter instances.

Returns:
ConverterInstanceListResponse containing all registered converters.
"""
items = [
self._build_instance_from_object(name, obj) for name, obj in self._registry.get_all_instances().items()
]
return ConverterInstanceListResponse(items=items)

async def get_converter(self, converter_id: str) -> Optional[ConverterInstance]:
"""
Get a converter instance by ID.

Returns:
ConverterInstance if found, None otherwise.
"""
obj = self._registry.get_instance_by_name(converter_id)
if obj is None:
return None
return self._build_instance_from_object(converter_id, obj)

def get_converter_object(self, converter_id: str) -> Optional[Any]:
"""
Get the actual converter object.

Returns:
The PromptConverter object if found, None otherwise.
"""
return self._registry.get_instance_by_name(converter_id)

async def create_converter(self, request: CreateConverterRequest) -> CreateConverterResponse:
"""
Create a new converter instance from API request.

Instantiates the converter with the given type and params,
then registers it in the registry.

Args:
request: The create converter request with type and params.

Returns:
CreateConverterResponse with the new converter's details.

Raises:
ValueError: If the converter type is not found.
"""
converter_id = str(uuid.uuid4())

# Resolve any converter references in params and instantiate
params = self._resolve_converter_params(request.params)
converter_class = self._get_converter_class(request.type)
converter_obj = converter_class(**params)
self._registry.register_instance(converter_obj, name=converter_id)

return CreateConverterResponse(
converter_id=converter_id,
type=request.type,
display_name=request.display_name,
params=request.params,
)

async def preview_conversion(self, request: ConverterPreviewRequest) -> ConverterPreviewResponse:
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

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

All async functions and methods MUST end with _async suffix according to PyRIT coding guidelines. The following async functions in this file are missing the _async suffix:

  • list_converters
  • get_converter
  • create_converter
  • preview_conversion

These should be renamed to:

  • list_converters_async
  • get_converter_async
  • create_converter_async
  • preview_conversion_async

Copilot generated this review using guidance from repository custom instructions.
Comment on lines +104 to +137
async def list_targets(self) -> TargetListResponse:
"""
List all target instances.

Returns:
TargetListResponse containing all registered targets.
"""
items = [
self._build_instance_from_object(name, obj) for name, obj in self._registry.get_all_instances().items()
]
return TargetListResponse(items=items)

async def get_target(self, target_id: str) -> Optional[TargetInstance]:
"""
Get a target instance by ID.

Returns:
TargetInstance if found, None otherwise.
"""
obj = self._registry.get_instance_by_name(target_id)
if obj is None:
return None
return self._build_instance_from_object(target_id, obj)

def get_target_object(self, target_id: str) -> Optional[Any]:
"""
Get the actual target object for use in attacks.

Returns:
The PromptTarget object if found, None otherwise.
"""
return self._registry.get_instance_by_name(target_id)

async def create_target(self, request: CreateTargetRequest) -> CreateTargetResponse:
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

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

All async functions and methods MUST end with _async suffix according to PyRIT coding guidelines. The following async functions in this file are missing the _async suffix:

  • list_targets
  • get_target
  • create_target

These should be renamed to:

  • list_targets_async
  • get_target_async
  • create_target_async

Copilot generated this review using guidance from repository custom instructions.
pagination=PaginationInfo(limit=limit, has_more=has_more, next_cursor=next_cursor, prev_cursor=cursor),
)

async def get_attack(self, attack_id: str) -> Optional[AttackSummary]:
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

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

Functions with more than 1 parameter MUST use * after self to enforce keyword-only arguments according to PyRIT coding guidelines. The function get_attack has 2 parameters (self, attack_id) but doesn't enforce keyword-only arguments.

Change to:

async def get_attack(self, *, attack_id: str) -> Optional[AttackSummary]:

Copilot generated this review using guidance from repository custom instructions.
def test_get_attack_service_returns_attack_service(self) -> None:
"""Test that get_attack_service returns an AttackService instance."""
# Reset singleton for clean test
import pyrit.backend.services.attack_service as module
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

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

Module 'pyrit.backend.services.attack_service' is imported with both 'import' and 'import from'.

Copilot uses AI. Check for mistakes.
def test_get_attack_service_returns_same_instance(self) -> None:
"""Test that get_attack_service returns the same instance."""
# Reset singleton for clean test
import pyrit.backend.services.attack_service as module
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

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

Module 'pyrit.backend.services.attack_service' is imported with both 'import' and 'import from'.

Copilot uses AI. Check for mistakes.

import pytest

import pyrit.backend.services.converter_service as converter_service_module
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

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

Module 'pyrit.backend.services.converter_service' is imported with both 'import' and 'import from'.

Copilot uses AI. Check for mistakes.

def test_get_target_service_returns_target_service(self) -> None:
"""Test that get_target_service returns a TargetService instance."""
import pyrit.backend.services.target_service as module
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

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

Module 'pyrit.backend.services.target_service' is imported with both 'import' and 'import from'.

Copilot uses AI. Check for mistakes.

def test_get_target_service_returns_same_instance(self) -> None:
"""Test that get_target_service returns the same instance."""
import pyrit.backend.services.target_service as module
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

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

Module 'pyrit.backend.services.target_service' is imported with both 'import' and 'import from'.

Copilot uses AI. Check for mistakes.
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.

"""
Copy link
Contributor

@rlundeen2 rlundeen2 Feb 6, 2026

Choose a reason for hiding this comment

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

Concern: Model Duplication/Drift and Confusion

A lot of these are really similar to our core models (MessagePiece, Message, Score, AttackSummary). Additionally, converters and targets here are really similar to the corresponding Identifiers.

Right now, the translation logic (in attack_service.py) is fragile. A name change breaks things, and things like type could all be different (e.g. score_value is float in the backend but a string in the model)

I also think this would be easier to use if all the same fields are available. It could be confusing to program here and not have access to converted_data_type. As a new user I'd be asking myself "what is converted_type and how does it map?". I think it would be really useful to be able to access all the model pieces in the same ways.

Proposal

Could we define the Pydantic models with the same field names and drift detection? E.g. use from_attributes=True

# pyrit/backend/models/message_piece.py
class MessagePieceSchema(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    
    # Same names as domain MessagePiece
    id: Optional[str] = None  # serialized from UUID
    role: Literal["system", "user", "assistant", "simulated_assistant"]
    original_value: str
    converted_value: Optional[str] = None
    original_value_data_type: str = "text"
    converted_value_data_type: Optional[str] = None
    response_error: str = "none"
    sequence: int = -1
    conversation_id: Optional[str] = None
    # ... all 25 fields with same names ...
    
    # API-only extras
    friendly_name: Optional[str] = None
    _EXTRA_FIELDS = {"friendly_name"}
    
    # Potentially add this as an abstract method in a base class to ensure we add it in all backend models
    @classmethod
    def from_model(cls, obj, **extras):
        instance = cls.model_validate(obj)
        for k, v in extras.items():
            setattr(instance, k, v)
        return instance

Apply the same paradigm to identifiers:

# pyrit/backend/models/converters.py
class ConverterIdentifierSchema(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    
    # Same fields as domain ConverterIdentifier
    __type__: str
    __module__: str
    id: Optional[str] = None
    supported_input_types: Optional[tuple] = None
    supported_output_types: Optional[tuple] = None
    sub_converter_identifiers: Optional[List["ConverterIdentifierSchema"]] = None
    # ... etc
    
    # API extras
    display_name: Optional[str] = None

Add some kind of drift detection test. We could at runtime or even tests I'd be happy with

# tests/unit/backend/test_model_mirrors.py
class TestMessagePieceMirror:
    def test_all_domain_fields_present(self):
        domain_fields = get_init_param_names(MessagePiece)
        schema_fields = set(MessagePieceSchema.model_fields.keys())
        extra_fields = MessagePieceSchema._EXTRA_FIELDS
        
        missing = domain_fields - (schema_fields - extra_fields)
        assert not missing, f"Schema missing domain fields: {missing}"

Then instead of attack_service, translate with from_model

# Before (in attack_service.py)
pieces = [MessagePiece(piece_id=str(p.id), data_type=p.converted_value_data_type, ...)]

# After
pieces = [MessagePieceSchema.from_model(p) for p in msg.message_pieces]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants