Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
073cf62
Drafting a quick test script
riedgar-ms Nov 7, 2025
244f2cf
Corrected JSON support
riedgar-ms Nov 7, 2025
9fccc5b
Expand testing
riedgar-ms Nov 7, 2025
dd36ea2
Don't need this
riedgar-ms Nov 7, 2025
170b16b
Some small refinements
riedgar-ms Nov 7, 2025
dd56600
Draft unit test updates
riedgar-ms Nov 7, 2025
8df25b9
Merge remote-tracking branch 'origin/main' into riedgar-ms/selfask-sc…
riedgar-ms Nov 9, 2025
5405dec
Merge remote-tracking branch 'origin/main' into riedgar-ms/selfask-sc…
riedgar-ms Nov 13, 2025
fa4ca37
Proposal for schema smuggling
riedgar-ms Nov 13, 2025
7390271
Merge remote-tracking branch 'origin/main' into riedgar-ms/selfask-sc…
riedgar-ms Nov 13, 2025
320d58c
Linting issues
riedgar-ms Nov 13, 2025
842cd03
Merge remote-tracking branch 'origin/main' into riedgar-ms/selfask-sc…
riedgar-ms Nov 13, 2025
80e8cd4
Merge remote-tracking branch 'origin/main' into riedgar-ms/selfask-sc…
riedgar-ms Nov 14, 2025
2003466
Merge remote-tracking branch 'origin/main' into riedgar-ms/selfask-sc…
riedgar-ms Nov 14, 2025
45cb825
Add the JSONResponseConfig class
riedgar-ms Nov 15, 2025
ec4efaa
Better name
riedgar-ms Nov 15, 2025
1eb4395
Start on other changes
riedgar-ms Nov 15, 2025
29fdb2f
Next changes
riedgar-ms Nov 15, 2025
2c8e919
Try dealing with some linting
riedgar-ms Nov 15, 2025
9009edf
More changes....
riedgar-ms Nov 16, 2025
d899af4
Correct responses setup
riedgar-ms Nov 16, 2025
c78f819
blacken
riedgar-ms Nov 16, 2025
45f73a6
Fix a test....
riedgar-ms Nov 16, 2025
becb214
Fix reponses tests
riedgar-ms Nov 16, 2025
f37d070
Fix chat target tests
riedgar-ms Nov 16, 2025
6072ae3
blacken
riedgar-ms Nov 16, 2025
4262cd7
Merge remote-tracking branch 'origin/main' into riedgar-ms/selfask-sc…
riedgar-ms Nov 20, 2025
970c4f2
Merge remote-tracking branch 'origin/main' into riedgar-ms/selfask-sc…
riedgar-ms Nov 21, 2025
55502ff
Merge remote-tracking branch 'origin/main' into riedgar-ms/selfask-sc…
riedgar-ms Nov 21, 2025
388f138
Resync with origin/main
riedgar-ms Dec 2, 2025
b6182e9
Merge from main
riedgar-ms Dec 3, 2025
630842a
Merge remote-tracking branch 'origin/main' into riedgar-ms/selfask-sc…
Dec 8, 2025
4da3b9d
Merge remote-tracking branch 'origin/main' into riedgar-ms/selfask-sc…
riedgar-ms Dec 8, 2025
fd03bba
Merge remote-tracking branch 'origin/main' into riedgar-ms/selfask-sc…
riedgar-ms Dec 10, 2025
21fe8a0
Merge remote-tracking branch 'origin/main' into riedgar-ms/selfask-sc…
riedgar-ms Dec 11, 2025
48c61cc
Switching auth
riedgar-ms Dec 11, 2025
cf71d2d
Merge remote-tracking branch 'origin/main' into riedgar-ms/selfask-sc…
riedgar-ms Dec 12, 2025
d231d79
Merge remote-tracking branch 'origin/main' into riedgar-ms/selfask-sc…
riedgar-ms Dec 13, 2025
c8b4d0d
Working on next bit
riedgar-ms Dec 13, 2025
03c59a6
Get one test working again...
riedgar-ms Dec 13, 2025
5275b14
Should be the final test working (plus linting)
riedgar-ms Dec 13, 2025
9dfdcf6
Think this is the other place?
riedgar-ms Dec 13, 2025
5cef55d
Missing import?
riedgar-ms Dec 13, 2025
bea538c
And another
riedgar-ms Dec 13, 2025
e5bbea0
Sort imports
riedgar-ms Dec 13, 2025
f325976
A bad merge
riedgar-ms Dec 13, 2025
271ea14
More missed merges
riedgar-ms Dec 13, 2025
e90e409
ruff fix
riedgar-ms Dec 13, 2025
b1a7fdb
Merge remote-tracking branch 'origin/main' into riedgar-ms/selfask-sc…
riedgar-ms Dec 17, 2025
a272af9
Merge remote-tracking branch 'origin/main' into riedgar-ms/selfask-sc…
riedgar-ms Dec 17, 2025
eee44be
Need a doc string
riedgar-ms Dec 17, 2025
41f576c
Forgot doc hook
riedgar-ms Dec 17, 2025
955ce14
Merge remote-tracking branch 'origin/main' into riedgar-ms/selfask-sc…
riedgar-ms Dec 22, 2025
5c53356
Address ruff issues
riedgar-ms Dec 22, 2025
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
1 change: 1 addition & 0 deletions doc/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ API Reference
AttackOutcome
AttackResult
DecomposedSeedGroup
JsonResponseConfig
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I did this because one of the unit tests whinged at me, but I'm not sure that this really ought to be public @romanlutz ?

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah yes, test_api_documentation.py 😆 You can definitely add exclusions there. It wouldn't be the first.

Message
MessagePiece
PromptDataType
Expand Down
2 changes: 2 additions & 0 deletions pyrit/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
)
from pyrit.models.embeddings import EmbeddingData, EmbeddingResponse, EmbeddingSupport, EmbeddingUsageInformation
from pyrit.models.identifiers import Identifier
from pyrit.models.json_response_config import JsonResponseConfig
from pyrit.models.literals import ChatMessageRole, PromptDataType, PromptResponseError
from pyrit.models.message import (
Message,
Expand Down Expand Up @@ -69,6 +70,7 @@
"group_message_pieces_into_conversations",
"Identifier",
"ImagePathDataTypeSerializer",
"JsonResponseConfig",
"Message",
"MessagePiece",
"PromptDataType",
Expand Down
48 changes: 48 additions & 0 deletions pyrit/models/json_response_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.

from __future__ import annotations

import json
from dataclasses import dataclass
from typing import Any, Dict, Optional


@dataclass
class JsonResponseConfig:
"""
Configuration for JSON responses (with OpenAI).
"""

enabled: bool = False
schema: Optional[Dict[str, Any]] = None
schema_name: str = "CustomSchema"
strict: bool = True

@classmethod
def from_metadata(cls, *, metadata: Optional[Dict[str, Any]]) -> "JsonResponseConfig":
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: should be possible with the imported annotations.

Suggested change
def from_metadata(cls, *, metadata: Optional[Dict[str, Any]]) -> "JsonResponseConfig":
def from_metadata(cls, *, metadata: Optional[Dict[str, Any]]) -> JsonResponseConfig:

if not metadata:
return cls(enabled=False)

response_format = metadata.get("response_format")
if response_format != "json":
return cls(enabled=False)

schema_val = metadata.get("json_schema")
if schema_val:
if isinstance(schema_val, str):
try:
schema = json.loads(schema_val) if schema_val else None
except json.JSONDecodeError:
raise ValueError(f"Invalid JSON schema provided: {schema_val}")
else:
schema = schema_val

return cls(
enabled=True,
schema=schema,
schema_name=metadata.get("schema_name", "CustomSchema"),
strict=metadata.get("strict", True),
Copy link
Contributor

Choose a reason for hiding this comment

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

I have to say this worries me. You also pointed that out in a comment earlier @riedgar-ms . The keys used here are basically now reserved for JSON schema. I would be less concerned if it was prefixed with json_schema__ or something like that but that makes it potentially somewhat harder to work with. Perhaps depends on how you're planning to use it, too.

)

return cls(enabled=True)
36 changes: 26 additions & 10 deletions pyrit/prompt_target/common/prompt_chat_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import abc
from typing import Optional

from pyrit.models import MessagePiece
from pyrit.models import JsonResponseConfig, MessagePiece
from pyrit.prompt_target import PromptTarget


Expand Down Expand Up @@ -86,16 +86,32 @@ def is_response_format_json(self, message_piece: MessagePiece) -> bool:
include a "response_format" key.

Returns:
bool: True if the response format is JSON and supported, False otherwise.
bool: True if the response format is JSON, False otherwise.
Copy link
Contributor

Choose a reason for hiding this comment

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

what prompted the extra indentation?


Raises:
ValueError: If "json" response format is requested but unsupported.
"""
if message_piece.prompt_metadata:
response_format = message_piece.prompt_metadata.get("response_format")
if response_format == "json":
if not self.is_json_response_supported():
target_name = self.get_identifier()["__type__"]
raise ValueError(f"This target {target_name} does not support JSON response format.")
return True
return False
config = self.get_json_response_config(message_piece=message_piece)
return config.enabled

def get_json_response_config(self, *, message_piece: MessagePiece) -> JsonResponseConfig:
"""
Get the JSON response configuration from the message piece metadata.

Args:
message_piece: A MessagePiece object with a `prompt_metadata` dictionary that may
include JSON response configuration.

Returns:
JsonResponseConfig: The JSON response configuration.

Raises:
ValueError: If JSON response format is requested but unsupported.
"""
config = JsonResponseConfig.from_metadata(metadata=message_piece.prompt_metadata)

if config.enabled and not self.is_json_response_supported():
target_name = self.get_identifier()["__type__"]
raise ValueError(f"This target {target_name} does not support JSON response format.")

return config
31 changes: 25 additions & 6 deletions pyrit/prompt_target/openai/openai_chat_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Licensed under the MIT license.

import logging
from typing import Any, MutableSequence, Optional
from typing import Any, Dict, MutableSequence, Optional

from pyrit.common import convert_local_image_to_data_url
from pyrit.exceptions import (
Expand All @@ -13,6 +13,7 @@
from pyrit.models import (
ChatMessage,
ChatMessageListDictContent,
JsonResponseConfig,
Message,
MessagePiece,
construct_response_from_request,
Expand Down Expand Up @@ -182,16 +183,15 @@ async def send_prompt_async(self, *, message: Message) -> list[Message]:
self._validate_request(message=message)

message_piece: MessagePiece = message.message_pieces[0]

is_json_response = self.is_response_format_json(message_piece)
json_config = self.get_json_response_config(message_piece=message_piece)

# Get conversation from memory and append the current message
conversation = self._memory.get_conversation(conversation_id=message_piece.conversation_id)
conversation.append(message)

logger.info(f"Sending the following prompt to the prompt target: {message}")

body = await self._construct_request_body(conversation=conversation, is_json_response=is_json_response)
body = await self._construct_request_body(conversation=conversation, json_config=json_config)

# Use unified error handling - automatically detects ChatCompletion and validates
response = await self._handle_openai_request(
Expand Down Expand Up @@ -389,8 +389,11 @@ async def _build_chat_messages_for_multi_modal_async(self, conversation: Mutable
chat_messages.append(chat_message.model_dump(exclude_none=True))
return chat_messages

async def _construct_request_body(self, conversation: MutableSequence[Message], is_json_response: bool) -> dict:
async def _construct_request_body(
self, *, conversation: MutableSequence[Message], json_config: JsonResponseConfig
) -> dict:
messages = await self._build_chat_messages_async(conversation)
response_format = self._build_response_format(json_config)

body_parameters = {
"model": self._model_name,
Expand All @@ -404,7 +407,7 @@ async def _construct_request_body(self, conversation: MutableSequence[Message],
"seed": self._seed,
"n": self._n,
"messages": messages,
"response_format": {"type": "json_object"} if is_json_response else None,
"response_format": response_format,
}

if self._extra_body_parameters:
Expand Down Expand Up @@ -432,3 +435,19 @@ def _validate_request(self, *, message: Message) -> None:
for prompt_data_type in converted_prompt_data_types:
if prompt_data_type not in ["text", "image_path"]:
raise ValueError(f"This target only supports text and image_path. Received: {prompt_data_type}.")

def _build_response_format(self, json_config: JsonResponseConfig) -> Optional[Dict[str, Any]]:
if not json_config.enabled:
return None

if json_config.schema:
return {
"type": "json_schema",
"json_schema": {
"name": json_config.schema_name,
"schema": json_config.schema,
"strict": json_config.strict,
},
}

return {"type": "json_object"}
35 changes: 30 additions & 5 deletions pyrit/prompt_target/openai/openai_response_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
pyrit_target_retry,
)
from pyrit.models import (
JsonResponseConfig,
Message,
MessagePiece,
PromptDataType,
Expand Down Expand Up @@ -315,7 +316,9 @@ async def _build_input_for_multi_modal_async(self, conversation: MutableSequence

return input_items

async def _construct_request_body(self, conversation: MutableSequence[Message], is_json_response: bool) -> dict:
async def _construct_request_body(
self, *, conversation: MutableSequence[Message], json_config: JsonResponseConfig
) -> dict:
"""
Construct the request body to send to the Responses API.

Expand All @@ -324,13 +327,15 @@ async def _construct_request_body(self, conversation: MutableSequence[Message],

Args:
conversation: The full conversation history.
is_json_response: Whether the response should be formatted as JSON.
json_config: Specification for JSON formatting.

Returns:
dict: The request body to send to the Responses API.
"""
input_items = await self._build_input_for_multi_modal_async(conversation)

text_format = self._build_text_format(json_config=json_config)

body_parameters = {
"model": self._model_name,
"max_output_tokens": self._max_output_tokens,
Expand All @@ -339,7 +344,7 @@ async def _construct_request_body(self, conversation: MutableSequence[Message],
"stream": False,
"input": input_items,
# Correct JSON response format per Responses API
"response_format": {"type": "json_object"} if is_json_response else None,
"text": text_format,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the 'bug fix' part; response_format is from the Chat completions API. See:

https://platform.openai.com/docs/api-reference/responses/create#responses_create-text

}

if self._extra_body_parameters:
Expand All @@ -348,6 +353,23 @@ async def _construct_request_body(self, conversation: MutableSequence[Message],
# Filter out None values
return {k: v for k, v in body_parameters.items() if v is not None}

def _build_text_format(self, json_config: JsonResponseConfig) -> Optional[Dict[str, Any]]:
if not json_config.enabled:
return None

if json_config.schema:
return {
"format": {
"type": "json_schema",
"name": json_config.schema_name,
"schema": json_config.schema,
"strict": json_config.strict,
}
}

logger.info("Using json_object format without schema - consider providing a schema for better results")
return {"format": {"type": "json_object"}}

def _check_content_filter(self, response: Any) -> bool:
"""
Check if a Response API response has a content filter error.
Expand Down Expand Up @@ -445,7 +467,10 @@ async def send_prompt_async(self, *, message: Message) -> list[Message]:
self._validate_request(message=message)

message_piece: MessagePiece = message.message_pieces[0]
is_json_response = self.is_response_format_json(message_piece)
json_config = JsonResponseConfig(enabled=False)
if message.message_pieces:
last_piece = message.message_pieces[-1]
json_config = self.get_json_response_config(message_piece=last_piece)

# Get full conversation history from memory and append the current message
conversation: MutableSequence[Message] = self._memory.get_conversation(
Expand All @@ -462,7 +487,7 @@ async def send_prompt_async(self, *, message: Message) -> list[Message]:
while True:
logger.info(f"Sending conversation with {len(conversation)} messages to the prompt target")

body = await self._construct_request_body(conversation=conversation, is_json_response=is_json_response)
body = await self._construct_request_body(conversation=conversation, json_config=json_config)

# Use unified error handling - automatically detects Response and validates
result = await self._handle_openai_request(
Expand Down
Loading