Skip to content

Commit 6d20c0d

Browse files
FEAT Allow context free grammars in OpenAI Responses (microsoft#1151)
Co-authored-by: Roman Lutz <romanlutz13@gmail.com>
1 parent 1f50412 commit 6d20c0d

4 files changed

Lines changed: 227 additions & 21 deletions

File tree

build_scripts/check_links.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"https://code.visualstudio.com/Download", # This will block python requests
1717
"https://platform.openai.com/docs/api-reference/introduction", # blocks python requests
1818
"https://platform.openai.com/docs/api-reference/responses", # blocks python requests
19+
"https://platform.openai.com/docs/guides/function-calling", # blocks python requests
1920
"https://www.anthropic.com/research/many-shot-jailbreaking", # blocks python requests
2021
"https://code.visualstudio.com/docs/devcontainers/containers",
2122
"https://stackoverflow.com/questions/77134272/pip-install-dev-with-pyproject-toml-not-working",

doc/code/targets/8_openai_responses_target.ipynb

Lines changed: 120 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,6 @@
101101
],
102102
"source": [
103103
"from pyrit.models import Message, MessagePiece\n",
104-
"from pyrit.prompt_target.openai.openai_response_target import OpenAIResponseTarget\n",
105104
"from pyrit.setup import IN_MEMORY, initialize_pyrit\n",
106105
"\n",
107106
"initialize_pyrit(memory_db_type=IN_MEMORY)\n",
@@ -135,7 +134,6 @@
135134
"\n",
136135
"# Let the model auto-select tools\n",
137136
"target = OpenAIResponseTarget(\n",
138-
" model_name=\"o4-mini\",\n",
139137
" custom_functions={\"get_current_weather\": get_current_weather},\n",
140138
" extra_body_parameters={\n",
141139
" \"tools\": [function_tool],\n",
@@ -187,14 +185,19 @@
187185
"name": "stdout",
188186
"output_type": "stream",
189187
"text": [
190-
"1 | assistant: {\"id\":\"ws_084e1fa6b70d4c190068fabb4db6148194b034c920a816a3ad\",\"type\":\"web_search_call\",\"status\":\"completed\",\"action\":{\"type\":\"search\",\"query\":\"October 23 2025 positive news story\"}}\n",
191-
"3 | assistant: {\"id\":\"ws_084e1fa6b70d4c190068fabb4f44988194afba2fcd2ba16277\",\"type\":\"web_search_call\",\"status\":\"completed\",\"action\":{\"type\":\"search\",\"query\":\"good news story October 23 2025 USA\"}}\n",
192-
"5 | assistant: {\"id\":\"ws_084e1fa6b70d4c190068fabb50ace481949a24558e23723e9a\",\"type\":\"web_search_call\",\"status\":\"completed\",\"action\":{\"type\":\"search\",\"query\":\"Good News Network October 23 2025\"}}\n",
193-
"7 | assistant: {\"id\":\"ws_084e1fa6b70d4c190068fabb5291488194893ff4ba621d13b9\",\"type\":\"web_search_call\",\"status\":\"completed\",\"action\":{\"type\":\"open_page\"}}\n",
194-
"9 | assistant: {\"id\":\"ws_084e1fa6b70d4c190068fabb540ed88194b03acd6052d7130c\",\"type\":\"web_search_call\",\"status\":\"completed\",\"action\":{\"type\":\"search\",\"query\":\"Woman Rescues Cat Survived in Derelict Bathroom for Two Years Rescuing starts with noticing Good News Network\"}}\n",
195-
"11 | assistant: {\"id\":\"ws_084e1fa6b70d4c190068fabb57c0dc819491322103b1333154\",\"type\":\"web_search_call\",\"status\":\"completed\",\"action\":{\"type\":\"open_page\",\"url\":\"https://www.goodnewsnetwork.org/fortress-unearthed-in-egypt-features-thick-curving-walls-built-3500-years-ago/\"}}\n",
196-
"13 | assistant: {\"id\":\"ws_084e1fa6b70d4c190068fabb5921608194b363cf710edd6163\",\"type\":\"web_search_call\",\"status\":\"completed\",\"action\":{\"type\":\"open_page\",\"url\":\"https://www.goodnewsnetwork.org/virus-that-kills-more-elephants-than-any-other-cause-is-finally-defeated/\"}}\n",
197-
"15 | assistant: One uplifting story today comes from the Good News Network: veterinary researchers have, for the first time, developed and successfully trialed a vaccine against elephant endotheliotropic herpesvirus (EEHV)—the leading cause of death for young elephants both in the wild and in human care. Early trials showed the two-dose vaccine triggers a strong immune response with no harmful side effects, marking a “landmark moment” in efforts to protect these iconic animals from a previously untreatable disease ([goodnewsnetwork.org](https://www.goodnewsnetwork.org/virus-that-kills-more-elephants-than-any-other-cause-is-finally-defeated/)).\n"
188+
"1 | assistant: {\"id\":\"ws_0f4da57d2d3f46590069004c09cd10819d9094acc94db1e9b8\",\"type\":\"web_search_call\",\"status\":\"completed\",\"action\":{\"type\":\"search\",\"query\":\"October 28 2025 positive news story\"}}\n",
189+
"3 | assistant: {\"id\":\"ws_0f4da57d2d3f46590069004c0c6018819d8748260711df9d07\",\"type\":\"web_search_call\",\"status\":\"completed\",\"action\":{\"type\":\"search\",\"query\":\"site:reuters.com October 28 2025 positive news Reuters\"}}\n",
190+
"5 | assistant: {\"id\":\"ws_0f4da57d2d3f46590069004c0eb990819d9badc437da40d3d4\",\"type\":\"web_search_call\",\"status\":\"completed\",\"action\":{\"type\":\"search\",\"query\":\"Oct 28 2025 Reuters conservation positive Oct 28\"}}\n",
191+
"7 | assistant: {\"id\":\"ws_0f4da57d2d3f46590069004c12830c819d9af29ce9359bb845\",\"type\":\"web_search_call\",\"status\":\"completed\",\"action\":{\"type\":\"search\",\"query\":\"October 28 2025 'good news' site:goodnewsnetwork.org\"}}\n",
192+
"9 | assistant: {\"id\":\"ws_0f4da57d2d3f46590069004c156f24819d84a01c0a9434429c\",\"type\":\"web_search_call\",\"status\":\"completed\",\"action\":{\"type\":\"search\",\"query\":\"Oct 28 2025 Greenpeace positive story Reuters Oct 28\"}}\n",
193+
"11 | assistant: {\"id\":\"ws_0f4da57d2d3f46590069004c18abd0819d8812b7df936429ab\",\"type\":\"web_search_call\",\"status\":\"completed\",\"action\":{\"type\":\"search\",\"query\":\"Reuters Oct 28 2025 scientists discover\"}}\n",
194+
"13 | assistant: {\"id\":\"ws_0f4da57d2d3f46590069004c1c0a20819d8dfae61d9a38e390\",\"type\":\"web_search_call\",\"status\":\"completed\",\"action\":{\"type\":\"search\",\"query\":\"AP News October 28 2025 good news\"}}\n",
195+
"15 | assistant: {\"id\":\"ws_0f4da57d2d3f46590069004c1f720c819da2b09372d48d1386\",\"type\":\"web_search_call\",\"status\":\"completed\",\"action\":{\"type\":\"search\",\"query\":\"Oct 28 2025 Reuters child mortality decline UN Oct 28 2025\"}}\n",
196+
"17 | assistant: {\"id\":\"ws_0f4da57d2d3f46590069004c22eadc819dbe71f68926b4635a\",\"type\":\"web_search_call\",\"status\":\"completed\",\"action\":{\"type\":\"search\",\"query\":\"Oct 28 2025 CNN good news story\"}}\n",
197+
"19 | assistant: {\"id\":\"ws_0f4da57d2d3f46590069004c26c934819d90506cad3f9d1352\",\"type\":\"web_search_call\",\"status\":\"completed\",\"action\":{\"type\":\"open_page\",\"url\":\"https://www.reuters.com/business/energy/us-department-energy-forms-1-billion-supercomputer-ai-partnership-with-amd-2025-10-27/\"}}\n",
198+
"21 | assistant: {\"id\":\"ws_0f4da57d2d3f46590069004c2a2bc0819d900dc446636516ef\",\"type\":\"web_search_call\",\"status\":\"completed\",\"action\":{\"type\":\"open_page\"}}\n",
199+
"23 | assistant: {\"id\":\"ws_0f4da57d2d3f46590069004c2b97ac819d929413078f891848\",\"type\":\"web_search_call\",\"status\":\"completed\",\"action\":{\"type\":\"search\",\"query\":\"Oct 28 (Reuters) Positive scientists discover\"}}\n",
200+
"25 | assistant: One positive story from today: the U.S. Department of Energy announced a $1 billion partnership with AMD to build two next-generation supercomputers—named “Lux” and “Discovery”—that will “supercharge” research into fusion energy, national security and even molecular-level cancer drug discovery ([reuters.com](https://www.reuters.com/business/energy/us-department-energy-forms-1-billion-supercomputer-ai-partnership-with-amd-2025-10-27/)).\n"
198201
]
199202
}
200203
],
@@ -208,10 +211,11 @@
208211
"\n",
209212
"initialize_pyrit(memory_db_type=IN_MEMORY)\n",
210213
"\n",
214+
"# Note: web search is not yet supported by Azure OpenAI endpoints so we'll use OpenAI from here on.\n",
211215
"target = OpenAIResponseTarget(\n",
212216
" endpoint=os.getenv(\"PLATFORM_OPENAI_RESPONSES_ENDPOINT\"),\n",
213217
" api_key=os.getenv(\"PLATFORM_OPENAI_RESPONSES_KEY\"),\n",
214-
" model_name=os.getenv(\"PLATFORM_OPENAI_RESPONSES_MODEL\", \"gpt-4o-mini\"),\n",
218+
" model_name=os.getenv(\"PLATFORM_OPENAI_RESPONSES_MODEL\"),\n",
215219
" api_version=None,\n",
216220
" extra_body_parameters={\n",
217221
" \"tools\": [web_search_tool()],\n",
@@ -228,11 +232,113 @@
228232
"response = await target.send_prompt_async(prompt_request=prompt_request) # type: ignore\n",
229233
"\n",
230234
"for idx, piece in enumerate(response.message_pieces):\n",
231-
" # Reasoning traces are necessary to be sent back to the endpoint for function calling even if they're empty.\n",
232-
" # They are excluded here for a cleaner output.\n",
233235
" if piece.original_value_data_type != \"reasoning\":\n",
234236
" print(f\"{idx} | {piece.role}: {piece.original_value}\")"
235237
]
238+
},
239+
{
240+
"cell_type": "markdown",
241+
"id": "6",
242+
"metadata": {},
243+
"source": [
244+
"## Grammar-Constrained Generation\n",
245+
"\n",
246+
"OpenAI models also support constrained generation in the [Responses API](https://platform.openai.com/docs/guides/function-calling#context-free-grammars). This forces the LLM to produce output which conforms to the given grammar, which is useful when specific syntax is required in the output.\n",
247+
"\n",
248+
"In this example, we will define a simple Lark grammar which prevents the model from giving a correct answer to a simple question, and compare that to the unconstrained model.\n",
249+
"\n",
250+
"Note that as of October 2025, this is only supported by OpenAI (not Azure) on \"gpt-5\""
251+
]
252+
},
253+
{
254+
"cell_type": "code",
255+
"execution_count": null,
256+
"id": "7",
257+
"metadata": {},
258+
"outputs": [
259+
{
260+
"name": "stdout",
261+
"output_type": "stream",
262+
"text": [
263+
"Unconstrained Response:\n",
264+
"1 | assistant: Rome.\n",
265+
"\n",
266+
"Constrained Response:\n",
267+
"1 | assistant: I think that it is cat\n"
268+
]
269+
}
270+
],
271+
"source": [
272+
"from pyrit.setup import IN_MEMORY, initialize_pyrit\n",
273+
"\n",
274+
"initialize_pyrit(memory_db_type=IN_MEMORY)\n",
275+
"\n",
276+
"\n",
277+
"message_piece = MessagePiece(\n",
278+
" role=\"user\",\n",
279+
" original_value=\"What is the capital of Italy?\",\n",
280+
" original_value_data_type=\"text\",\n",
281+
")\n",
282+
"prompt_request = Message(message_pieces=[message_piece])\n",
283+
"\n",
284+
"# Define a grammar that prevents \"Rome\" from being generated\n",
285+
"lark_grammar = r\"\"\"\n",
286+
"start: \"I think that it is \" SHORTTEXT \n",
287+
"SHORTTEXT: /[^RrOoMmEe]{1,8}/\n",
288+
"\"\"\"\n",
289+
"\n",
290+
"grammar_tool = {\n",
291+
" \"type\": \"custom\",\n",
292+
" \"name\": \"CitiesGrammar\",\n",
293+
" \"description\": \"Constrains generation.\",\n",
294+
" \"format\": {\n",
295+
" \"type\": \"grammar\",\n",
296+
" \"syntax\": \"lark\",\n",
297+
" \"definition\": lark_grammar,\n",
298+
" },\n",
299+
"}\n",
300+
"\n",
301+
"target = OpenAIResponseTarget(\n",
302+
" endpoint=os.getenv(\"PLATFORM_OPENAI_RESPONSES_ENDPOINT\"),\n",
303+
" api_key=os.getenv(\"PLATFORM_OPENAI_RESPONSES_KEY\"),\n",
304+
" model_name=\"gpt-5\",\n",
305+
" api_version=None,\n",
306+
" extra_body_parameters={\"tools\": [grammar_tool], \"tool_choice\": \"required\"},\n",
307+
" temperature=1.0,\n",
308+
")\n",
309+
"\n",
310+
"unconstrained_target = OpenAIResponseTarget(\n",
311+
" endpoint=os.getenv(\"PLATFORM_OPENAI_RESPONSES_ENDPOINT\"),\n",
312+
" api_key=os.getenv(\"PLATFORM_OPENAI_RESPONSES_KEY\"),\n",
313+
" model_name=\"gpt-5\",\n",
314+
" api_version=None,\n",
315+
" temperature=1.0,\n",
316+
")\n",
317+
"\n",
318+
"unconstrained_result = await unconstrained_target.send_prompt_async(prompt_request=prompt_request) # type: ignore\n",
319+
"\n",
320+
"result = await target.send_prompt_async(prompt_request=prompt_request) # type: ignore\n",
321+
"\n",
322+
"print(\"Unconstrained Response:\")\n",
323+
"for idx, piece in enumerate(unconstrained_result.message_pieces):\n",
324+
" if piece.original_value_data_type != \"reasoning\":\n",
325+
" print(f\"{idx} | {piece.role}: {piece.original_value}\")\n",
326+
"\n",
327+
"print()\n",
328+
"\n",
329+
"print(\"Constrained Response:\")\n",
330+
"for idx, piece in enumerate(result.message_pieces):\n",
331+
" if piece.original_value_data_type != \"reasoning\":\n",
332+
" print(f\"{idx} | {piece.role}: {piece.original_value}\")"
333+
]
334+
},
335+
{
336+
"cell_type": "code",
337+
"execution_count": null,
338+
"id": "8",
339+
"metadata": {},
340+
"outputs": [],
341+
"source": []
236342
}
237343
],
238344
"metadata": {
@@ -249,7 +355,7 @@
249355
"name": "python",
250356
"nbconvert_exporter": "python",
251357
"pygments_lexer": "ipython3",
252-
"version": "3.12.11"
358+
"version": "3.11.13"
253359
}
254360
},
255361
"nbformat": 4,

doc/code/targets/8_openai_responses_target.py

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,8 @@
5656
#
5757
# This showcases how agentic function execution works with PyRIT + OpenAI Responses API.
5858

59-
from pyrit.models import Message, MessagePiece
60-
from pyrit.prompt_target.openai.openai_response_target import OpenAIResponseTarget
61-
6259
# %%
60+
from pyrit.models import Message, MessagePiece
6361
from pyrit.setup import IN_MEMORY, initialize_pyrit
6462

6563
initialize_pyrit(memory_db_type=IN_MEMORY)
@@ -93,7 +91,6 @@ async def get_current_weather(args):
9391

9492
# Let the model auto-select tools
9593
target = OpenAIResponseTarget(
96-
model_name="o4-mini",
9794
custom_functions={"get_current_weather": get_current_weather},
9895
extra_body_parameters={
9996
"tools": [function_tool],
@@ -139,10 +136,11 @@ async def get_current_weather(args):
139136

140137
initialize_pyrit(memory_db_type=IN_MEMORY)
141138

139+
# Note: web search is not yet supported by Azure OpenAI endpoints so we'll use OpenAI from here on.
142140
target = OpenAIResponseTarget(
143141
endpoint=os.getenv("PLATFORM_OPENAI_RESPONSES_ENDPOINT"),
144142
api_key=os.getenv("PLATFORM_OPENAI_RESPONSES_KEY"),
145-
model_name=os.getenv("PLATFORM_OPENAI_RESPONSES_MODEL", "gpt-4o-mini"),
143+
model_name=os.getenv("PLATFORM_OPENAI_RESPONSES_MODEL"),
146144
api_version=None,
147145
extra_body_parameters={
148146
"tools": [web_search_tool()],
@@ -159,7 +157,79 @@ async def get_current_weather(args):
159157
response = await target.send_prompt_async(prompt_request=prompt_request) # type: ignore
160158

161159
for idx, piece in enumerate(response.message_pieces):
162-
# Reasoning traces are necessary to be sent back to the endpoint for function calling even if they're empty.
163-
# They are excluded here for a cleaner output.
164160
if piece.original_value_data_type != "reasoning":
165161
print(f"{idx} | {piece.role}: {piece.original_value}")
162+
163+
# %% [markdown]
164+
# ## Grammar-Constrained Generation
165+
#
166+
# OpenAI models also support constrained generation in the [Responses API](https://platform.openai.com/docs/guides/function-calling#context-free-grammars). This forces the LLM to produce output which conforms to the given grammar, which is useful when specific syntax is required in the output.
167+
#
168+
# In this example, we will define a simple Lark grammar which prevents the model from giving a correct answer to a simple question, and compare that to the unconstrained model.
169+
#
170+
# Note that as of October 2025, this is only supported by OpenAI (not Azure) on "gpt-5"
171+
172+
# %%
173+
from pyrit.setup import IN_MEMORY, initialize_pyrit
174+
175+
initialize_pyrit(memory_db_type=IN_MEMORY)
176+
177+
178+
message_piece = MessagePiece(
179+
role="user",
180+
original_value="What is the capital of Italy?",
181+
original_value_data_type="text",
182+
)
183+
prompt_request = Message(message_pieces=[message_piece])
184+
185+
# Define a grammar that prevents "Rome" from being generated
186+
lark_grammar = r"""
187+
start: "I think that it is " SHORTTEXT
188+
SHORTTEXT: /[^RrOoMmEe]{1,8}/
189+
"""
190+
191+
grammar_tool = {
192+
"type": "custom",
193+
"name": "CitiesGrammar",
194+
"description": "Constrains generation.",
195+
"format": {
196+
"type": "grammar",
197+
"syntax": "lark",
198+
"definition": lark_grammar,
199+
},
200+
}
201+
202+
target = OpenAIResponseTarget(
203+
endpoint=os.getenv("PLATFORM_OPENAI_RESPONSES_ENDPOINT"),
204+
api_key=os.getenv("PLATFORM_OPENAI_RESPONSES_KEY"),
205+
model_name="gpt-5",
206+
api_version=None,
207+
extra_body_parameters={"tools": [grammar_tool], "tool_choice": "required"},
208+
temperature=1.0,
209+
)
210+
211+
unconstrained_target = OpenAIResponseTarget(
212+
endpoint=os.getenv("PLATFORM_OPENAI_RESPONSES_ENDPOINT"),
213+
api_key=os.getenv("PLATFORM_OPENAI_RESPONSES_KEY"),
214+
model_name="gpt-5",
215+
api_version=None,
216+
temperature=1.0,
217+
)
218+
219+
unconstrained_result = await unconstrained_target.send_prompt_async(prompt_request=prompt_request) # type: ignore
220+
221+
result = await target.send_prompt_async(prompt_request=prompt_request) # type: ignore
222+
223+
print("Unconstrained Response:")
224+
for idx, piece in enumerate(unconstrained_result.message_pieces):
225+
if piece.original_value_data_type != "reasoning":
226+
print(f"{idx} | {piece.role}: {piece.original_value}")
227+
228+
print()
229+
230+
print("Constrained Response:")
231+
for idx, piece in enumerate(result.message_pieces):
232+
if piece.original_value_data_type != "reasoning":
233+
print(f"{idx} | {piece.role}: {piece.original_value}")
234+
235+
# %%

pyrit/prompt_target/openai/openai_response_target.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,20 @@ def __init__(
134134
self._custom_functions: Dict[str, ToolExecutor] = custom_functions or {}
135135
self._fail_on_missing_function: bool = fail_on_missing_function
136136

137+
# Extract the grammar 'tool' if one is present
138+
# See
139+
# https://platform.openai.com/docs/guides/function-calling#context-free-grammars
140+
self._grammar_name: str | None = None
141+
if extra_body_parameters:
142+
tools = extra_body_parameters.get("tools", [])
143+
for tool in tools:
144+
if tool.get("type") == "custom" and tool.get("format", {}).get("type") == "grammar":
145+
if self._grammar_name is not None:
146+
raise ValueError("Multiple grammar tools detected; only one is supported.")
147+
tool_name = tool.get("name")
148+
logger.debug("Detected grammar tool: %s", tool_name)
149+
self._grammar_name = tool_name
150+
137151
def _set_openai_env_configuration_vars(self) -> None:
138152
self.model_name_environment_variable = "OPENAI_RESPONSES_MODEL"
139153
self.endpoint_environment_variable = "OPENAI_RESPONSES_ENDPOINT"
@@ -441,6 +455,21 @@ def _parse_response_output_section(
441455
piece_value = json.dumps(section, separators=(",", ":"))
442456
piece_type = "tool_call"
443457

458+
elif section_type == "custom_tool_call":
459+
# Had a Lark grammar (hopefully)
460+
# See
461+
# https://platform.openai.com/docs/guides/function-calling#context-free-grammars
462+
logger.debug("Detected custom_tool_call in response, assuming grammar constraint.")
463+
extracted_grammar_name = section.get("name")
464+
if extracted_grammar_name != self._grammar_name:
465+
msg = "Mismatched grammar name in custom_tool_call "
466+
msg += f"(expected {self._grammar_name}, got {extracted_grammar_name})"
467+
logger.error(msg)
468+
raise ValueError(msg)
469+
piece_value = section.get("input", "")
470+
if len(piece_value) == 0:
471+
raise EmptyResponseException(message="The chat returned an empty message section.")
472+
444473
else:
445474
# Other possible types are not yet handled in PyRIT
446475
return None

0 commit comments

Comments
 (0)