Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
29 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
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
25 changes: 24 additions & 1 deletion pyrit/prompt_target/openai/openai_response_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,29 @@ async def _construct_request_body(self, conversation: MutableSequence[Message],
"""
input_items = await self._build_input_for_multi_modal_async(conversation)

text_format = None
if is_json_response:
if conversation[-1].message_pieces[0].prompt_metadata.get("json_schema"):
Copy link
Contributor Author

@riedgar-ms riedgar-ms Nov 7, 2025

Choose a reason for hiding this comment

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

I am not, to put it mildly, a fan of smuggling in the schema this way. Two reasons:

  • The schema can be loaded as a Python object. However, doing so would require a change to MessagePiece
  • Even if leaving it as a string, then it should be extracted by the is_response_format_json() method

However, both of these have a rather larger blast radius, so I wanted to consult first @romanlutz @rlundeen2

json_schema_str = str(conversation[-1].message_pieces[0].prompt_metadata["json_schema"])
try:
json_schema = json.loads(json_schema_str)
except json.JSONDecodeError as e:
raise PyritException(
message=f"Failed to parse provided JSON schema for response_format as JSON.\n"
f"Schema: {json_schema_str}\nFull error: {e}"
)
text_format = {
"format": {
"type": "json_schema",
"name": "CustomSchema",
"schema": json_schema,
"strict": True,
}
}
else:
logger.info("Falling back to json_object; not recommended for new models")
text_format = {"format": {"type": "json_object"}}

body_parameters = {
"model": self._model_name,
"max_output_tokens": self._max_output_tokens,
Expand All @@ -311,7 +334,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 Down
92 changes: 92 additions & 0 deletions tests/integration/targets/test_openai_responses_gpt5.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
# Licensed under the MIT license.


import json
import os
import uuid

import jsonschema
import pytest

from pyrit.models import MessagePiece
Expand All @@ -17,6 +19,7 @@ async def test_openai_responses_gpt5(sqlite_instance):
"endpoint": os.getenv("AZURE_OPENAI_GPT5_RESPONSES_ENDPOINT"),
"model_name": os.getenv("AZURE_OPENAI_GPT5_MODEL"),
"api_key": os.getenv("AZURE_OPENAI_GPT5_RESPONSES_KEY"),
# "use_entra_auth": True,
}

target = OpenAIResponseTarget(**args)
Expand Down Expand Up @@ -46,3 +49,92 @@ async def test_openai_responses_gpt5(sqlite_instance):
assert result.message_pieces[1].role == "assistant"
# Hope that the model manages to give the correct answer somewhere (GPT-5 really should)
assert "Paris" in result.message_pieces[1].converted_value


@pytest.mark.asyncio
async def test_openai_responses_gpt5_json_schema(sqlite_instance):
args = {
"endpoint": os.getenv("AZURE_OPENAI_GPT5_RESPONSES_ENDPOINT"),
"model_name": os.getenv("AZURE_OPENAI_GPT5_MODEL"),
"api_key": os.getenv("AZURE_OPENAI_GPT5_RESPONSES_KEY"),
# "use_entra_auth": True,
}

target = OpenAIResponseTarget(**args)

conv_id = str(uuid.uuid4())

developer_piece = MessagePiece(
role="developer",
original_value="You are an expert in the lore of cats.",
original_value_data_type="text",
conversation_id=conv_id,
attack_identifier={"id": str(uuid.uuid4())},
)
sqlite_instance.add_message_to_memory(request=developer_piece.to_message())

cat_schema = {
"type": "object",
"properties": {
"name": {"type": "string", "minLength": 12},
"age": {"type": "integer", "minimum": 0, "maximum": 20},
"colour": {
"type": "array",
"items": {"type": "integer", "minimum": 0, "maximum": 255},
"minItems": 3,
"maxItems": 3,
},
},
"required": ["name", "age", "colour"],
"additionalProperties": False,
}

user_piece = MessagePiece(
role="user",
original_value="Create a JSON object that describes a mystical cat with the following properties: name, age, colour.",
original_value_data_type="text",
conversation_id=conv_id,
prompt_metadata={"response_format": "json", "json_schema": json.dumps(cat_schema)},
)

response = await target.send_prompt_async(prompt_request=user_piece.to_message())

response_content = response.get_value(1)
response_json = json.loads(response_content)
jsonschema.validate(instance=response_json, schema=cat_schema)


@pytest.mark.asyncio
async def test_openai_responses_gpt5_json_object(sqlite_instance):
args = {
"endpoint": os.getenv("AZURE_OPENAI_GPT5_RESPONSES_ENDPOINT"),
"model_name": os.getenv("AZURE_OPENAI_GPT5_MODEL"),
"api_key": os.getenv("AZURE_OPENAI_GPT5_RESPONSES_KEY"),
# "use_entra_auth": True,
}

target = OpenAIResponseTarget(**args)

conv_id = str(uuid.uuid4())

developer_piece = MessagePiece(
role="developer",
original_value="You are an expert in the lore of cats.",
original_value_data_type="text",
conversation_id=conv_id,
attack_identifier={"id": str(uuid.uuid4())},
)
sqlite_instance.add_message_to_memory(request=developer_piece.to_message())
user_piece = MessagePiece(
role="user",
original_value="Create a JSON object that describes a mystical cat with the following properties: name, age, colour.",
original_value_data_type="text",
conversation_id=conv_id,
prompt_metadata={"response_format": "json"},
)
response = await target.send_prompt_async(prompt_request=user_piece.to_message())

response_content = response.get_value(1)
response_json = json.loads(response_content)
assert response_json is not None
# Can't assert more, since the failure could be due to a bad generation by the model
Loading