From e72a5913e550f4336715dd71b0ac4c9a7335bc5e Mon Sep 17 00:00:00 2001 From: Sydney Lister Date: Wed, 4 Mar 2026 16:02:37 -0500 Subject: [PATCH 1/3] Fix XPIA binary_path incompatibility for model targets (#5058420) When the indirect jailbreak (XPIA) strategy creates file-based context prompts with binary_path data type, the callback chat target now reads the file content and converts to text before invoking the callback. This prevents ValueError from targets that don't support binary_path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../red_team/_callback_chat_target.py | 93 +++++++++++++++---- 1 file changed, 75 insertions(+), 18 deletions(-) diff --git a/sdk/evaluation/azure-ai-evaluation/azure/ai/evaluation/red_team/_callback_chat_target.py b/sdk/evaluation/azure-ai-evaluation/azure/ai/evaluation/red_team/_callback_chat_target.py index b33888cc14fb..b2e419abb5e7 100644 --- a/sdk/evaluation/azure-ai-evaluation/azure/ai/evaluation/red_team/_callback_chat_target.py +++ b/sdk/evaluation/azure-ai-evaluation/azure/ai/evaluation/red_team/_callback_chat_target.py @@ -2,6 +2,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # --------------------------------------------------------- import logging +import os from typing import Any, Callable, Dict, List, Optional from openai import RateLimitError as OpenAIRateLimitError @@ -23,7 +24,9 @@ class _CallbackChatTarget(PromptChatTarget): def __init__( self, *, - callback: Callable[[List[Dict], bool, Optional[str], Optional[Dict[str, Any]]], Dict], + callback: Callable[ + [List[Dict], bool, Optional[str], Optional[Dict[str, Any]]], Dict + ], stream: bool = False, retry_enabled: bool = True, ) -> None: @@ -105,14 +108,30 @@ async def _send_prompt_impl(self, *, message: Message) -> List[Message]: self._validate_request(prompt_request=message) request = message.get_piece(0) + # Handle binary_path by reading file content and converting to text. + # XPIA (indirect jailbreak) strategy creates file-based context prompts with + # binary_path data type, but model targets only support text content. + request_content = request.converted_value or request.original_value or "" + if request.converted_value_data_type == "binary_path" and os.path.isfile( + request_content + ): + with open(request_content, "r", encoding="utf-8", errors="replace") as f: + request_content = f.read() + # Get conversation history and convert to chat message format - conversation_history = self._memory.get_conversation(conversation_id=request.conversation_id) + conversation_history = self._memory.get_conversation( + conversation_id=request.conversation_id + ) messages: List[Dict[str, str]] = [] for msg in conversation_history: for piece in msg.message_pieces: messages.append( { - "role": (piece.api_role if hasattr(piece, "api_role") else str(piece.role)), + "role": ( + piece.api_role + if hasattr(piece, "api_role") + else str(piece.role) + ), "content": piece.converted_value or piece.original_value or "", } ) @@ -120,8 +139,12 @@ async def _send_prompt_impl(self, *, message: Message) -> List[Message]: # Add current request messages.append( { - "role": (request.api_role if hasattr(request, "api_role") else str(request.role)), - "content": request.converted_value or request.original_value or "", + "role": ( + request.api_role + if hasattr(request, "api_role") + else str(request.role) + ), + "content": request_content, } ) @@ -130,7 +153,11 @@ async def _send_prompt_impl(self, *, message: Message) -> List[Message]: # Extract context from request labels if available # The context is stored in memory labels when the prompt is sent by orchestrator context_dict = {} - if hasattr(request, "labels") and request.labels and "context" in request.labels: + if ( + hasattr(request, "labels") + and request.labels + and "context" in request.labels + ): context_data = request.labels["context"] if context_data and isinstance(context_data, dict): # context_data is always a dict with 'contexts' list @@ -143,17 +170,27 @@ async def _send_prompt_impl(self, *, message: Message) -> List[Message]: # Check if any context has agent-specific fields for logging has_agent_fields = any( isinstance(ctx, dict) - and ("context_type" in ctx and "tool_name" in ctx and ctx["tool_name"] is not None) + and ( + "context_type" in ctx + and "tool_name" in ctx + and ctx["tool_name"] is not None + ) for ctx in contexts ) if has_agent_fields: tool_names = [ - ctx.get("tool_name") for ctx in contexts if isinstance(ctx, dict) and "tool_name" in ctx + ctx.get("tool_name") + for ctx in contexts + if isinstance(ctx, dict) and "tool_name" in ctx ] - logger.debug(f"Extracted agent context: {len(contexts)} context source(s), tool_names={tool_names}") + logger.debug( + f"Extracted agent context: {len(contexts)} context source(s), tool_names={tool_names}" + ) else: - logger.debug(f"Extracted model context: {len(contexts)} context source(s)") + logger.debug( + f"Extracted model context: {len(contexts)} context source(s)" + ) # Invoke callback with exception translation for retry handling try: @@ -161,13 +198,21 @@ async def _send_prompt_impl(self, *, message: Message) -> List[Message]: response = await self._callback(messages=messages, stream=self._stream, session_state=None, context=context_dict) # type: ignore except OpenAIRateLimitError as e: # Translate OpenAI RateLimitError to PyRIT RateLimitException for retry decorator - logger.warning(f"Rate limit error from callback, translating for retry: {e}") + logger.warning( + f"Rate limit error from callback, translating for retry: {e}" + ) raise RateLimitException(status_code=429, message=str(e)) from e except Exception as e: # Check for rate limit indicators in error message (fallback detection) error_str = str(e).lower() - if "rate limit" in error_str or "429" in error_str or "too many requests" in error_str: - logger.warning(f"Rate limit detected in error message, translating for retry: {e}") + if ( + "rate limit" in error_str + or "429" in error_str + or "too many requests" in error_str + ): + logger.warning( + f"Rate limit detected in error message, translating for retry: {e}" + ) raise RateLimitException(status_code=429, message=str(e)) from e raise @@ -183,7 +228,11 @@ async def _send_prompt_impl(self, *, message: Message) -> List[Message]: if isinstance(response, dict) and "token_usage" in response: token_usage = response["token_usage"] - if not isinstance(response, dict) or "messages" not in response or not response["messages"]: + if ( + not isinstance(response, dict) + or "messages" not in response + or not response["messages"] + ): raise ValueError( f"Callback returned invalid response: expected dict with non-empty 'messages', got {type(response)}" ) @@ -191,18 +240,25 @@ async def _send_prompt_impl(self, *, message: Message) -> List[Message]: response_text = response["messages"][-1]["content"] # Check for empty response and raise EmptyResponseException for retry - if not response_text or (isinstance(response_text, str) and response_text.strip() == ""): + if not response_text or ( + isinstance(response_text, str) and response_text.strip() == "" + ): logger.warning("Callback returned empty response") raise EmptyResponseException(message="Callback returned empty response") - response_entry = construct_response_from_request(request=request, response_text_pieces=[response_text]) + response_entry = construct_response_from_request( + request=request, response_text_pieces=[response_text] + ) # Add token_usage to the response entry's labels (not the request) if token_usage: response_entry.get_piece(0).labels["token_usage"] = token_usage logger.debug(f"Captured token usage from callback: {token_usage}") - logger.debug("Received the following response from the prompt target" + f"{response_text}") + logger.debug( + "Received the following response from the prompt target" + + f"{response_text}" + ) return [response_entry] def _validate_request(self, *, prompt_request: Message) -> None: @@ -212,7 +268,8 @@ def _validate_request(self, *, prompt_request: Message) -> None: data_type = prompt_request.get_piece(0).converted_value_data_type if data_type not in ("text", "image_path", "binary_path"): raise ValueError( - f"This target only supports text, image_path, and binary_path prompt input. " f"Received: {data_type}." + f"This target only supports text, image_path, and binary_path prompt input. " + f"Received: {data_type}." ) def is_json_response_supported(self) -> bool: From 808fc252b90f2f61f6a3b0e0cdb9ac8b41890e31 Mon Sep 17 00:00:00 2001 From: Sydney Lister Date: Mon, 9 Mar 2026 12:04:52 -0400 Subject: [PATCH 2/3] Address review comments: extract helper, add error handling and tests - Extract _resolve_content() helper to handle binary_path file reading for both current request AND conversation history pieces - Add try/except with logger.warning for unreadable files, falling back to the file path string - Add comment noting sync file read is intentional for small XPIA files - Add 5 unit tests for binary_path resolution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../red_team/_callback_chat_target.py | 48 +++- .../test_redteam/test_callback_chat_target.py | 237 ++++++++++++++++-- 2 files changed, 249 insertions(+), 36 deletions(-) diff --git a/sdk/evaluation/azure-ai-evaluation/azure/ai/evaluation/red_team/_callback_chat_target.py b/sdk/evaluation/azure-ai-evaluation/azure/ai/evaluation/red_team/_callback_chat_target.py index b2e419abb5e7..03252195c36e 100644 --- a/sdk/evaluation/azure-ai-evaluation/azure/ai/evaluation/red_team/_callback_chat_target.py +++ b/sdk/evaluation/azure-ai-evaluation/azure/ai/evaluation/red_team/_callback_chat_target.py @@ -2,7 +2,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # --------------------------------------------------------- import logging -import os from typing import Any, Callable, Dict, List, Optional from openai import RateLimitError as OpenAIRateLimitError @@ -97,6 +96,41 @@ async def _send_prompt_with_retry(self, *, message: Message) -> List[Message]: """ return await self._send_prompt_impl(message=message) + def _resolve_content(self, piece: Any) -> str: + """Resolve the text content for a message piece, reading file content for binary_path pieces. + + XPIA (indirect jailbreak) strategy creates file-based context prompts with + binary_path data type, but model targets only support text content. This helper + reads the file and returns its contents for binary_path pieces, or returns the + converted/original value as-is for other data types. + + Args: + piece: A message piece with converted_value, original_value, and + converted_value_data_type attributes. + + Returns: + The resolved text content string. + """ + value = piece.converted_value or piece.original_value or "" + if ( + getattr(piece, "converted_value_data_type", None) == "binary_path" + and isinstance(value, str) + and value + ): + try: + # Synchronous read is intentional here — XPIA context files are small + # text files, so the blocking I/O is negligible. + with open(value, "r", encoding="utf-8", errors="replace") as f: + return f.read() + except (OSError, IOError) as exc: + logger.warning( + "Failed to read binary_path file %s: %s. Falling back to file path string.", + value, + exc, + ) + return value + return value + async def _send_prompt_impl(self, *, message: Message) -> List[Message]: """ Core implementation of send_prompt_async. @@ -108,15 +142,7 @@ async def _send_prompt_impl(self, *, message: Message) -> List[Message]: self._validate_request(prompt_request=message) request = message.get_piece(0) - # Handle binary_path by reading file content and converting to text. - # XPIA (indirect jailbreak) strategy creates file-based context prompts with - # binary_path data type, but model targets only support text content. - request_content = request.converted_value or request.original_value or "" - if request.converted_value_data_type == "binary_path" and os.path.isfile( - request_content - ): - with open(request_content, "r", encoding="utf-8", errors="replace") as f: - request_content = f.read() + request_content = self._resolve_content(request) # Get conversation history and convert to chat message format conversation_history = self._memory.get_conversation( @@ -132,7 +158,7 @@ async def _send_prompt_impl(self, *, message: Message) -> List[Message]: if hasattr(piece, "api_role") else str(piece.role) ), - "content": piece.converted_value or piece.original_value or "", + "content": self._resolve_content(piece), } ) diff --git a/sdk/evaluation/azure-ai-evaluation/tests/unittests/test_redteam/test_callback_chat_target.py b/sdk/evaluation/azure-ai-evaluation/tests/unittests/test_redteam/test_callback_chat_target.py index cd8de9006848..8f2c38cd67f0 100644 --- a/sdk/evaluation/azure-ai-evaluation/tests/unittests/test_redteam/test_callback_chat_target.py +++ b/sdk/evaluation/azure-ai-evaluation/tests/unittests/test_redteam/test_callback_chat_target.py @@ -46,7 +46,9 @@ def mock_request(): request_piece.conversation_id = "test-id" request_piece.converted_value = "test prompt" request_piece.converted_value_data_type = "text" - request_piece.to_chat_message.return_value = MagicMock(role="user", content="test prompt") + request_piece.to_chat_message.return_value = MagicMock( + role="user", content="test prompt" + ) request_piece.labels.get.return_value = None request = MagicMock() @@ -102,10 +104,14 @@ async def test_send_prompt_async(self, chat_target, mock_request, mock_callback) assert call_args["context"] == {} # Check memory usage - mock_memory.get_conversation.assert_called_once_with(conversation_id="test-id") + mock_memory.get_conversation.assert_called_once_with( + conversation_id="test-id" + ) @pytest.mark.asyncio - async def test_send_prompt_async_with_prompt_request_keyword(self, chat_target, mock_request, mock_callback): + async def test_send_prompt_async_with_prompt_request_keyword( + self, chat_target, mock_request, mock_callback + ): """Test send_prompt_async accepts prompt_request keyword for SDK compatibility.""" with patch.object(chat_target, "_memory") as mock_memory, patch( "azure.ai.evaluation.red_team._callback_chat_target.construct_response_from_request" @@ -127,35 +133,50 @@ async def test_send_prompt_async_with_prompt_request_keyword(self, chat_target, assert call_args["context"] == {} @pytest.mark.asyncio - async def test_send_prompt_async_raises_error_if_both_keywords_provided(self, chat_target, mock_request): + async def test_send_prompt_async_raises_error_if_both_keywords_provided( + self, chat_target, mock_request + ): """Test send_prompt_async raises error if both message and prompt_request are provided.""" with pytest.raises(ValueError) as exc_info: - await chat_target.send_prompt_async(message=mock_request, prompt_request=mock_request) + await chat_target.send_prompt_async( + message=mock_request, prompt_request=mock_request + ) assert "either 'message' or 'prompt_request'" in str(exc_info.value).lower() @pytest.mark.asyncio - async def test_send_prompt_async_raises_error_if_no_keyword_provided(self, chat_target): + async def test_send_prompt_async_raises_error_if_no_keyword_provided( + self, chat_target + ): """Test send_prompt_async raises error if neither message nor prompt_request is provided.""" with pytest.raises(ValueError) as exc_info: await chat_target.send_prompt_async() - assert "either 'message' or 'prompt_request' must be provided" in str(exc_info.value).lower() + assert ( + "either 'message' or 'prompt_request' must be provided" + in str(exc_info.value).lower() + ) @pytest.mark.asyncio - async def test_send_prompt_async_with_context_from_labels(self, chat_target, mock_callback): + async def test_send_prompt_async_with_context_from_labels( + self, chat_target, mock_callback + ): """Test send_prompt_async method with context from request labels.""" # Create a request with context in labels request_piece = MagicMock() request_piece.conversation_id = "test-id" request_piece.converted_value = "test prompt" request_piece.converted_value_data_type = "text" - request_piece.to_chat_message.return_value = MagicMock(role="user", content="test prompt") + request_piece.to_chat_message.return_value = MagicMock( + role="user", content="test prompt" + ) request_piece.labels = {"context": {"contexts": ["test context data"]}} mock_request = MagicMock() mock_request.message_pieces = [request_piece] - mock_request.get_piece = MagicMock(side_effect=lambda i: mock_request.message_pieces[i]) + mock_request.get_piece = MagicMock( + side_effect=lambda i: mock_request.message_pieces[i] + ) with patch.object(chat_target, "_memory") as mock_memory, patch( "azure.ai.evaluation.red_team._callback_chat_target.construct_response_from_request" @@ -177,7 +198,9 @@ async def test_send_prompt_async_with_context_from_labels(self, chat_target, moc assert call_args["context"] == {"contexts": ["test context data"]} # Check memory usage - mock_memory.get_conversation.assert_called_once_with(conversation_id="test-id") + mock_memory.get_conversation.assert_called_once_with( + conversation_id="test-id" + ) def test_validate_request_multiple_pieces(self, chat_target): """Test _validate_request with multiple request pieces.""" @@ -227,7 +250,9 @@ def test_init_retry_enabled_false(self, mock_callback): assert target._retry_enabled is False @pytest.mark.asyncio - async def test_rate_limit_exception_translated_from_openai_error(self, mock_callback): + async def test_rate_limit_exception_translated_from_openai_error( + self, mock_callback + ): """Test that OpenAI RateLimitError is translated to RateLimitException.""" # Create a mock response that looks like an OpenAI rate limit error mock_response = MagicMock() @@ -251,7 +276,9 @@ async def test_rate_limit_exception_translated_from_openai_error(self, mock_call mock_request = MagicMock() mock_request.message_pieces = [request_piece] - mock_request.get_piece = MagicMock(side_effect=lambda i: mock_request.message_pieces[i]) + mock_request.get_piece = MagicMock( + side_effect=lambda i: mock_request.message_pieces[i] + ) with patch.object(target, "_memory") as mock_memory: mock_memory.get_conversation.return_value = [] @@ -265,7 +292,9 @@ async def test_rate_limit_exception_translated_from_openai_error(self, mock_call @pytest.mark.asyncio async def test_rate_limit_in_error_message_translated(self, mock_callback): """Test that errors with 'rate limit' in message are translated.""" - mock_callback.side_effect = Exception("Request failed: rate limit exceeded for model") + mock_callback.side_effect = Exception( + "Request failed: rate limit exceeded for model" + ) target = _CallbackChatTarget(callback=mock_callback, retry_enabled=False) @@ -278,7 +307,9 @@ async def test_rate_limit_in_error_message_translated(self, mock_callback): mock_request = MagicMock() mock_request.message_pieces = [request_piece] - mock_request.get_piece = MagicMock(side_effect=lambda i: mock_request.message_pieces[i]) + mock_request.get_piece = MagicMock( + side_effect=lambda i: mock_request.message_pieces[i] + ) with patch.object(target, "_memory") as mock_memory: mock_memory.get_conversation.return_value = [] @@ -304,7 +335,9 @@ async def test_429_in_error_message_translated(self, mock_callback): mock_request = MagicMock() mock_request.message_pieces = [request_piece] - mock_request.get_piece = MagicMock(side_effect=lambda i: mock_request.message_pieces[i]) + mock_request.get_piece = MagicMock( + side_effect=lambda i: mock_request.message_pieces[i] + ) with patch.object(target, "_memory") as mock_memory: mock_memory.get_conversation.return_value = [] @@ -335,7 +368,9 @@ async def test_empty_response_raises_exception(self, mock_callback): mock_request = MagicMock() mock_request.message_pieces = [request_piece] - mock_request.get_piece = MagicMock(side_effect=lambda i: mock_request.message_pieces[i]) + mock_request.get_piece = MagicMock( + side_effect=lambda i: mock_request.message_pieces[i] + ) with patch.object(target, "_memory") as mock_memory: mock_memory.get_conversation.return_value = [] @@ -366,7 +401,9 @@ async def test_whitespace_only_response_raises_exception(self, mock_callback): mock_request = MagicMock() mock_request.message_pieces = [request_piece] - mock_request.get_piece = MagicMock(side_effect=lambda i: mock_request.message_pieces[i]) + mock_request.get_piece = MagicMock( + side_effect=lambda i: mock_request.message_pieces[i] + ) with patch.object(target, "_memory") as mock_memory: mock_memory.get_conversation.return_value = [] @@ -390,7 +427,9 @@ async def test_non_rate_limit_error_not_translated(self, mock_callback): mock_request = MagicMock() mock_request.message_pieces = [request_piece] - mock_request.get_piece = MagicMock(side_effect=lambda i: mock_request.message_pieces[i]) + mock_request.get_piece = MagicMock( + side_effect=lambda i: mock_request.message_pieces[i] + ) with patch.object(target, "_memory") as mock_memory: mock_memory.get_conversation.return_value = [] @@ -421,7 +460,9 @@ async def test_retry_enabled_uses_retry_wrapper(self, mock_callback): mock_request = MagicMock() mock_request.message_pieces = [request_piece] - mock_request.get_piece = MagicMock(side_effect=lambda i: mock_request.message_pieces[i]) + mock_request.get_piece = MagicMock( + side_effect=lambda i: mock_request.message_pieces[i] + ) with patch.object(target, "_memory") as mock_memory, patch( "azure.ai.evaluation.red_team._callback_chat_target.construct_response_from_request" @@ -430,7 +471,9 @@ async def test_retry_enabled_uses_retry_wrapper(self, mock_callback): mock_construct.return_value = mock_request # Spy on _send_prompt_with_retry - with patch.object(target, "_send_prompt_with_retry", wraps=target._send_prompt_with_retry) as mock_retry: + with patch.object( + target, "_send_prompt_with_retry", wraps=target._send_prompt_with_retry + ) as mock_retry: await target.send_prompt_async(message=mock_request) mock_retry.assert_called_once() @@ -455,7 +498,9 @@ async def test_retry_disabled_bypasses_retry_wrapper(self, mock_callback): mock_request = MagicMock() mock_request.message_pieces = [request_piece] - mock_request.get_piece = MagicMock(side_effect=lambda i: mock_request.message_pieces[i]) + mock_request.get_piece = MagicMock( + side_effect=lambda i: mock_request.message_pieces[i] + ) with patch.object(target, "_memory") as mock_memory, patch( "azure.ai.evaluation.red_team._callback_chat_target.construct_response_from_request" @@ -466,7 +511,9 @@ async def test_retry_disabled_bypasses_retry_wrapper(self, mock_callback): # Spy on both methods with patch.object( target, "_send_prompt_with_retry", wraps=target._send_prompt_with_retry - ) as mock_retry, patch.object(target, "_send_prompt_impl", wraps=target._send_prompt_impl) as mock_impl: + ) as mock_retry, patch.object( + target, "_send_prompt_impl", wraps=target._send_prompt_impl + ) as mock_impl: await target.send_prompt_async(message=mock_request) mock_retry.assert_not_called() mock_impl.assert_called_once() @@ -494,7 +541,9 @@ async def failing_then_succeeding_callback(**kwargs): os.environ["RETRY_WAIT_MAX_SECONDS"] = "1" try: - target = _CallbackChatTarget(callback=failing_then_succeeding_callback, retry_enabled=True) + target = _CallbackChatTarget( + callback=failing_then_succeeding_callback, retry_enabled=True + ) # Create mock request request_piece = MagicMock() @@ -505,7 +554,9 @@ async def failing_then_succeeding_callback(**kwargs): mock_request = MagicMock() mock_request.message_pieces = [request_piece] - mock_request.get_piece = MagicMock(side_effect=lambda i: mock_request.message_pieces[i]) + mock_request.get_piece = MagicMock( + side_effect=lambda i: mock_request.message_pieces[i] + ) with patch.object(target, "_memory") as mock_memory, patch( "azure.ai.evaluation.red_team._callback_chat_target.construct_response_from_request" @@ -609,3 +660,139 @@ async def test_empty_messages_list_raises_valueerror(self, mock_callback): with pytest.raises(ValueError, match="invalid response"): await target.send_prompt_async(message=mock_request) + + +@pytest.mark.unittest +class TestCallbackChatTargetBinaryPath: + """Tests for binary_path resolution via _resolve_content helper.""" + + def test_resolve_content_reads_binary_path_file(self, tmp_path): + """binary_path piece with a valid temp file returns file contents.""" + file = tmp_path / "xpia_context.txt" + file.write_text("injected XPIA payload", encoding="utf-8") + + piece = MagicMock() + piece.converted_value = str(file) + piece.original_value = str(file) + piece.converted_value_data_type = "binary_path" + + target = _CallbackChatTarget(callback=AsyncMock()) + assert target._resolve_content(piece) == "injected XPIA payload" + + def test_resolve_content_falls_back_on_missing_file(self): + """Missing binary_path file logs warning and returns the path string.""" + piece = MagicMock() + piece.converted_value = "/nonexistent/path/to/file.txt" + piece.original_value = "/nonexistent/path/to/file.txt" + piece.converted_value_data_type = "binary_path" + + target = _CallbackChatTarget(callback=AsyncMock()) + result = target._resolve_content(piece) + assert result == "/nonexistent/path/to/file.txt" + + def test_resolve_content_returns_text_as_is(self): + """Text data type pieces are returned without file I/O.""" + piece = MagicMock() + piece.converted_value = "plain text prompt" + piece.original_value = "plain text prompt" + piece.converted_value_data_type = "text" + + target = _CallbackChatTarget(callback=AsyncMock()) + assert target._resolve_content(piece) == "plain text prompt" + + @pytest.mark.asyncio + async def test_binary_path_content_sent_to_callback(self, tmp_path): + """Callback receives file *contents* (not the path) for binary_path requests.""" + file = tmp_path / "context.txt" + file.write_text("file content for callback", encoding="utf-8") + + mock_callback = AsyncMock( + return_value={ + "messages": [{"role": "assistant", "content": "ok"}], + "stream": False, + "session_state": None, + "context": {}, + } + ) + + target = _CallbackChatTarget(callback=mock_callback, retry_enabled=False) + + mock_piece = MagicMock() + mock_piece.id = "piece-1" + mock_piece.converted_value = str(file) + mock_piece.original_value = str(file) + mock_piece.converted_value_data_type = "binary_path" + mock_piece.conversation_id = "conv-bp" + mock_piece.api_role = "user" + mock_piece.role = "user" + mock_piece.labels = {} + + mock_request = MagicMock() + mock_request.message_pieces = [mock_piece] + mock_request.get_piece.return_value = mock_piece + + with patch.object(target, "_memory") as mock_memory, patch( + "azure.ai.evaluation.red_team._callback_chat_target.construct_response_from_request" + ) as mock_construct: + mock_memory.get_conversation.return_value = [] + mock_construct.return_value = mock_request + await target.send_prompt_async(message=mock_request) + + sent_messages = mock_callback.call_args.kwargs["messages"] + assert sent_messages[-1]["content"] == "file content for callback" + + @pytest.mark.asyncio + async def test_binary_path_in_conversation_history_resolved(self, tmp_path): + """Conversation history pieces with binary_path are also resolved to file contents.""" + file = tmp_path / "history_context.txt" + file.write_text("history file content", encoding="utf-8") + + mock_callback = AsyncMock( + return_value={ + "messages": [{"role": "assistant", "content": "ok"}], + "stream": False, + "session_state": None, + "context": {}, + } + ) + + target = _CallbackChatTarget(callback=mock_callback, retry_enabled=False) + + # Build a history message with binary_path piece + history_piece = MagicMock() + history_piece.converted_value = str(file) + history_piece.original_value = str(file) + history_piece.converted_value_data_type = "binary_path" + history_piece.api_role = "user" + history_piece.role = "user" + + history_msg = MagicMock() + history_msg.message_pieces = [history_piece] + + # Current request (plain text) + mock_piece = MagicMock() + mock_piece.id = "piece-2" + mock_piece.converted_value = "follow-up question" + mock_piece.original_value = "follow-up question" + mock_piece.converted_value_data_type = "text" + mock_piece.conversation_id = "conv-bp-hist" + mock_piece.api_role = "user" + mock_piece.role = "user" + mock_piece.labels = {} + + mock_request = MagicMock() + mock_request.message_pieces = [mock_piece] + mock_request.get_piece.return_value = mock_piece + + with patch.object(target, "_memory") as mock_memory, patch( + "azure.ai.evaluation.red_team._callback_chat_target.construct_response_from_request" + ) as mock_construct: + mock_memory.get_conversation.return_value = [history_msg] + mock_construct.return_value = mock_request + await target.send_prompt_async(message=mock_request) + + sent_messages = mock_callback.call_args.kwargs["messages"] + # First message is from history — should contain file content, not path + assert sent_messages[0]["content"] == "history file content" + # Second message is the current plain-text request + assert sent_messages[1]["content"] == "follow-up question" From d55ecb07f56546f97ce236fecbf569bd3ba27ac2 Mon Sep 17 00:00:00 2001 From: Sydney Lister Date: Mon, 9 Mar 2026 14:59:15 -0400 Subject: [PATCH 3/3] Apply black formatting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../red_team/_callback_chat_target.py | 86 ++++----------- .../test_redteam/test_callback_chat_target.py | 101 +++++------------- 2 files changed, 43 insertions(+), 144 deletions(-) diff --git a/sdk/evaluation/azure-ai-evaluation/azure/ai/evaluation/red_team/_callback_chat_target.py b/sdk/evaluation/azure-ai-evaluation/azure/ai/evaluation/red_team/_callback_chat_target.py index 03252195c36e..a41eb7dc715d 100644 --- a/sdk/evaluation/azure-ai-evaluation/azure/ai/evaluation/red_team/_callback_chat_target.py +++ b/sdk/evaluation/azure-ai-evaluation/azure/ai/evaluation/red_team/_callback_chat_target.py @@ -23,9 +23,7 @@ class _CallbackChatTarget(PromptChatTarget): def __init__( self, *, - callback: Callable[ - [List[Dict], bool, Optional[str], Optional[Dict[str, Any]]], Dict - ], + callback: Callable[[List[Dict], bool, Optional[str], Optional[Dict[str, Any]]], Dict], stream: bool = False, retry_enabled: bool = True, ) -> None: @@ -112,11 +110,7 @@ def _resolve_content(self, piece: Any) -> str: The resolved text content string. """ value = piece.converted_value or piece.original_value or "" - if ( - getattr(piece, "converted_value_data_type", None) == "binary_path" - and isinstance(value, str) - and value - ): + if getattr(piece, "converted_value_data_type", None) == "binary_path" and isinstance(value, str) and value: try: # Synchronous read is intentional here — XPIA context files are small # text files, so the blocking I/O is negligible. @@ -145,19 +139,13 @@ async def _send_prompt_impl(self, *, message: Message) -> List[Message]: request_content = self._resolve_content(request) # Get conversation history and convert to chat message format - conversation_history = self._memory.get_conversation( - conversation_id=request.conversation_id - ) + conversation_history = self._memory.get_conversation(conversation_id=request.conversation_id) messages: List[Dict[str, str]] = [] for msg in conversation_history: for piece in msg.message_pieces: messages.append( { - "role": ( - piece.api_role - if hasattr(piece, "api_role") - else str(piece.role) - ), + "role": (piece.api_role if hasattr(piece, "api_role") else str(piece.role)), "content": self._resolve_content(piece), } ) @@ -165,11 +153,7 @@ async def _send_prompt_impl(self, *, message: Message) -> List[Message]: # Add current request messages.append( { - "role": ( - request.api_role - if hasattr(request, "api_role") - else str(request.role) - ), + "role": (request.api_role if hasattr(request, "api_role") else str(request.role)), "content": request_content, } ) @@ -179,11 +163,7 @@ async def _send_prompt_impl(self, *, message: Message) -> List[Message]: # Extract context from request labels if available # The context is stored in memory labels when the prompt is sent by orchestrator context_dict = {} - if ( - hasattr(request, "labels") - and request.labels - and "context" in request.labels - ): + if hasattr(request, "labels") and request.labels and "context" in request.labels: context_data = request.labels["context"] if context_data and isinstance(context_data, dict): # context_data is always a dict with 'contexts' list @@ -196,27 +176,17 @@ async def _send_prompt_impl(self, *, message: Message) -> List[Message]: # Check if any context has agent-specific fields for logging has_agent_fields = any( isinstance(ctx, dict) - and ( - "context_type" in ctx - and "tool_name" in ctx - and ctx["tool_name"] is not None - ) + and ("context_type" in ctx and "tool_name" in ctx and ctx["tool_name"] is not None) for ctx in contexts ) if has_agent_fields: tool_names = [ - ctx.get("tool_name") - for ctx in contexts - if isinstance(ctx, dict) and "tool_name" in ctx + ctx.get("tool_name") for ctx in contexts if isinstance(ctx, dict) and "tool_name" in ctx ] - logger.debug( - f"Extracted agent context: {len(contexts)} context source(s), tool_names={tool_names}" - ) + logger.debug(f"Extracted agent context: {len(contexts)} context source(s), tool_names={tool_names}") else: - logger.debug( - f"Extracted model context: {len(contexts)} context source(s)" - ) + logger.debug(f"Extracted model context: {len(contexts)} context source(s)") # Invoke callback with exception translation for retry handling try: @@ -224,21 +194,13 @@ async def _send_prompt_impl(self, *, message: Message) -> List[Message]: response = await self._callback(messages=messages, stream=self._stream, session_state=None, context=context_dict) # type: ignore except OpenAIRateLimitError as e: # Translate OpenAI RateLimitError to PyRIT RateLimitException for retry decorator - logger.warning( - f"Rate limit error from callback, translating for retry: {e}" - ) + logger.warning(f"Rate limit error from callback, translating for retry: {e}") raise RateLimitException(status_code=429, message=str(e)) from e except Exception as e: # Check for rate limit indicators in error message (fallback detection) error_str = str(e).lower() - if ( - "rate limit" in error_str - or "429" in error_str - or "too many requests" in error_str - ): - logger.warning( - f"Rate limit detected in error message, translating for retry: {e}" - ) + if "rate limit" in error_str or "429" in error_str or "too many requests" in error_str: + logger.warning(f"Rate limit detected in error message, translating for retry: {e}") raise RateLimitException(status_code=429, message=str(e)) from e raise @@ -254,11 +216,7 @@ async def _send_prompt_impl(self, *, message: Message) -> List[Message]: if isinstance(response, dict) and "token_usage" in response: token_usage = response["token_usage"] - if ( - not isinstance(response, dict) - or "messages" not in response - or not response["messages"] - ): + if not isinstance(response, dict) or "messages" not in response or not response["messages"]: raise ValueError( f"Callback returned invalid response: expected dict with non-empty 'messages', got {type(response)}" ) @@ -266,25 +224,18 @@ async def _send_prompt_impl(self, *, message: Message) -> List[Message]: response_text = response["messages"][-1]["content"] # Check for empty response and raise EmptyResponseException for retry - if not response_text or ( - isinstance(response_text, str) and response_text.strip() == "" - ): + if not response_text or (isinstance(response_text, str) and response_text.strip() == ""): logger.warning("Callback returned empty response") raise EmptyResponseException(message="Callback returned empty response") - response_entry = construct_response_from_request( - request=request, response_text_pieces=[response_text] - ) + response_entry = construct_response_from_request(request=request, response_text_pieces=[response_text]) # Add token_usage to the response entry's labels (not the request) if token_usage: response_entry.get_piece(0).labels["token_usage"] = token_usage logger.debug(f"Captured token usage from callback: {token_usage}") - logger.debug( - "Received the following response from the prompt target" - + f"{response_text}" - ) + logger.debug("Received the following response from the prompt target" + f"{response_text}") return [response_entry] def _validate_request(self, *, prompt_request: Message) -> None: @@ -294,8 +245,7 @@ def _validate_request(self, *, prompt_request: Message) -> None: data_type = prompt_request.get_piece(0).converted_value_data_type if data_type not in ("text", "image_path", "binary_path"): raise ValueError( - f"This target only supports text, image_path, and binary_path prompt input. " - f"Received: {data_type}." + f"This target only supports text, image_path, and binary_path prompt input. " f"Received: {data_type}." ) def is_json_response_supported(self) -> bool: diff --git a/sdk/evaluation/azure-ai-evaluation/tests/unittests/test_redteam/test_callback_chat_target.py b/sdk/evaluation/azure-ai-evaluation/tests/unittests/test_redteam/test_callback_chat_target.py index 8f2c38cd67f0..afc2308d418a 100644 --- a/sdk/evaluation/azure-ai-evaluation/tests/unittests/test_redteam/test_callback_chat_target.py +++ b/sdk/evaluation/azure-ai-evaluation/tests/unittests/test_redteam/test_callback_chat_target.py @@ -46,9 +46,7 @@ def mock_request(): request_piece.conversation_id = "test-id" request_piece.converted_value = "test prompt" request_piece.converted_value_data_type = "text" - request_piece.to_chat_message.return_value = MagicMock( - role="user", content="test prompt" - ) + request_piece.to_chat_message.return_value = MagicMock(role="user", content="test prompt") request_piece.labels.get.return_value = None request = MagicMock() @@ -104,14 +102,10 @@ async def test_send_prompt_async(self, chat_target, mock_request, mock_callback) assert call_args["context"] == {} # Check memory usage - mock_memory.get_conversation.assert_called_once_with( - conversation_id="test-id" - ) + mock_memory.get_conversation.assert_called_once_with(conversation_id="test-id") @pytest.mark.asyncio - async def test_send_prompt_async_with_prompt_request_keyword( - self, chat_target, mock_request, mock_callback - ): + async def test_send_prompt_async_with_prompt_request_keyword(self, chat_target, mock_request, mock_callback): """Test send_prompt_async accepts prompt_request keyword for SDK compatibility.""" with patch.object(chat_target, "_memory") as mock_memory, patch( "azure.ai.evaluation.red_team._callback_chat_target.construct_response_from_request" @@ -133,50 +127,35 @@ async def test_send_prompt_async_with_prompt_request_keyword( assert call_args["context"] == {} @pytest.mark.asyncio - async def test_send_prompt_async_raises_error_if_both_keywords_provided( - self, chat_target, mock_request - ): + async def test_send_prompt_async_raises_error_if_both_keywords_provided(self, chat_target, mock_request): """Test send_prompt_async raises error if both message and prompt_request are provided.""" with pytest.raises(ValueError) as exc_info: - await chat_target.send_prompt_async( - message=mock_request, prompt_request=mock_request - ) + await chat_target.send_prompt_async(message=mock_request, prompt_request=mock_request) assert "either 'message' or 'prompt_request'" in str(exc_info.value).lower() @pytest.mark.asyncio - async def test_send_prompt_async_raises_error_if_no_keyword_provided( - self, chat_target - ): + async def test_send_prompt_async_raises_error_if_no_keyword_provided(self, chat_target): """Test send_prompt_async raises error if neither message nor prompt_request is provided.""" with pytest.raises(ValueError) as exc_info: await chat_target.send_prompt_async() - assert ( - "either 'message' or 'prompt_request' must be provided" - in str(exc_info.value).lower() - ) + assert "either 'message' or 'prompt_request' must be provided" in str(exc_info.value).lower() @pytest.mark.asyncio - async def test_send_prompt_async_with_context_from_labels( - self, chat_target, mock_callback - ): + async def test_send_prompt_async_with_context_from_labels(self, chat_target, mock_callback): """Test send_prompt_async method with context from request labels.""" # Create a request with context in labels request_piece = MagicMock() request_piece.conversation_id = "test-id" request_piece.converted_value = "test prompt" request_piece.converted_value_data_type = "text" - request_piece.to_chat_message.return_value = MagicMock( - role="user", content="test prompt" - ) + request_piece.to_chat_message.return_value = MagicMock(role="user", content="test prompt") request_piece.labels = {"context": {"contexts": ["test context data"]}} mock_request = MagicMock() mock_request.message_pieces = [request_piece] - mock_request.get_piece = MagicMock( - side_effect=lambda i: mock_request.message_pieces[i] - ) + mock_request.get_piece = MagicMock(side_effect=lambda i: mock_request.message_pieces[i]) with patch.object(chat_target, "_memory") as mock_memory, patch( "azure.ai.evaluation.red_team._callback_chat_target.construct_response_from_request" @@ -198,9 +177,7 @@ async def test_send_prompt_async_with_context_from_labels( assert call_args["context"] == {"contexts": ["test context data"]} # Check memory usage - mock_memory.get_conversation.assert_called_once_with( - conversation_id="test-id" - ) + mock_memory.get_conversation.assert_called_once_with(conversation_id="test-id") def test_validate_request_multiple_pieces(self, chat_target): """Test _validate_request with multiple request pieces.""" @@ -250,9 +227,7 @@ def test_init_retry_enabled_false(self, mock_callback): assert target._retry_enabled is False @pytest.mark.asyncio - async def test_rate_limit_exception_translated_from_openai_error( - self, mock_callback - ): + async def test_rate_limit_exception_translated_from_openai_error(self, mock_callback): """Test that OpenAI RateLimitError is translated to RateLimitException.""" # Create a mock response that looks like an OpenAI rate limit error mock_response = MagicMock() @@ -276,9 +251,7 @@ async def test_rate_limit_exception_translated_from_openai_error( mock_request = MagicMock() mock_request.message_pieces = [request_piece] - mock_request.get_piece = MagicMock( - side_effect=lambda i: mock_request.message_pieces[i] - ) + mock_request.get_piece = MagicMock(side_effect=lambda i: mock_request.message_pieces[i]) with patch.object(target, "_memory") as mock_memory: mock_memory.get_conversation.return_value = [] @@ -292,9 +265,7 @@ async def test_rate_limit_exception_translated_from_openai_error( @pytest.mark.asyncio async def test_rate_limit_in_error_message_translated(self, mock_callback): """Test that errors with 'rate limit' in message are translated.""" - mock_callback.side_effect = Exception( - "Request failed: rate limit exceeded for model" - ) + mock_callback.side_effect = Exception("Request failed: rate limit exceeded for model") target = _CallbackChatTarget(callback=mock_callback, retry_enabled=False) @@ -307,9 +278,7 @@ async def test_rate_limit_in_error_message_translated(self, mock_callback): mock_request = MagicMock() mock_request.message_pieces = [request_piece] - mock_request.get_piece = MagicMock( - side_effect=lambda i: mock_request.message_pieces[i] - ) + mock_request.get_piece = MagicMock(side_effect=lambda i: mock_request.message_pieces[i]) with patch.object(target, "_memory") as mock_memory: mock_memory.get_conversation.return_value = [] @@ -335,9 +304,7 @@ async def test_429_in_error_message_translated(self, mock_callback): mock_request = MagicMock() mock_request.message_pieces = [request_piece] - mock_request.get_piece = MagicMock( - side_effect=lambda i: mock_request.message_pieces[i] - ) + mock_request.get_piece = MagicMock(side_effect=lambda i: mock_request.message_pieces[i]) with patch.object(target, "_memory") as mock_memory: mock_memory.get_conversation.return_value = [] @@ -368,9 +335,7 @@ async def test_empty_response_raises_exception(self, mock_callback): mock_request = MagicMock() mock_request.message_pieces = [request_piece] - mock_request.get_piece = MagicMock( - side_effect=lambda i: mock_request.message_pieces[i] - ) + mock_request.get_piece = MagicMock(side_effect=lambda i: mock_request.message_pieces[i]) with patch.object(target, "_memory") as mock_memory: mock_memory.get_conversation.return_value = [] @@ -401,9 +366,7 @@ async def test_whitespace_only_response_raises_exception(self, mock_callback): mock_request = MagicMock() mock_request.message_pieces = [request_piece] - mock_request.get_piece = MagicMock( - side_effect=lambda i: mock_request.message_pieces[i] - ) + mock_request.get_piece = MagicMock(side_effect=lambda i: mock_request.message_pieces[i]) with patch.object(target, "_memory") as mock_memory: mock_memory.get_conversation.return_value = [] @@ -427,9 +390,7 @@ async def test_non_rate_limit_error_not_translated(self, mock_callback): mock_request = MagicMock() mock_request.message_pieces = [request_piece] - mock_request.get_piece = MagicMock( - side_effect=lambda i: mock_request.message_pieces[i] - ) + mock_request.get_piece = MagicMock(side_effect=lambda i: mock_request.message_pieces[i]) with patch.object(target, "_memory") as mock_memory: mock_memory.get_conversation.return_value = [] @@ -460,9 +421,7 @@ async def test_retry_enabled_uses_retry_wrapper(self, mock_callback): mock_request = MagicMock() mock_request.message_pieces = [request_piece] - mock_request.get_piece = MagicMock( - side_effect=lambda i: mock_request.message_pieces[i] - ) + mock_request.get_piece = MagicMock(side_effect=lambda i: mock_request.message_pieces[i]) with patch.object(target, "_memory") as mock_memory, patch( "azure.ai.evaluation.red_team._callback_chat_target.construct_response_from_request" @@ -471,9 +430,7 @@ async def test_retry_enabled_uses_retry_wrapper(self, mock_callback): mock_construct.return_value = mock_request # Spy on _send_prompt_with_retry - with patch.object( - target, "_send_prompt_with_retry", wraps=target._send_prompt_with_retry - ) as mock_retry: + with patch.object(target, "_send_prompt_with_retry", wraps=target._send_prompt_with_retry) as mock_retry: await target.send_prompt_async(message=mock_request) mock_retry.assert_called_once() @@ -498,9 +455,7 @@ async def test_retry_disabled_bypasses_retry_wrapper(self, mock_callback): mock_request = MagicMock() mock_request.message_pieces = [request_piece] - mock_request.get_piece = MagicMock( - side_effect=lambda i: mock_request.message_pieces[i] - ) + mock_request.get_piece = MagicMock(side_effect=lambda i: mock_request.message_pieces[i]) with patch.object(target, "_memory") as mock_memory, patch( "azure.ai.evaluation.red_team._callback_chat_target.construct_response_from_request" @@ -511,9 +466,7 @@ async def test_retry_disabled_bypasses_retry_wrapper(self, mock_callback): # Spy on both methods with patch.object( target, "_send_prompt_with_retry", wraps=target._send_prompt_with_retry - ) as mock_retry, patch.object( - target, "_send_prompt_impl", wraps=target._send_prompt_impl - ) as mock_impl: + ) as mock_retry, patch.object(target, "_send_prompt_impl", wraps=target._send_prompt_impl) as mock_impl: await target.send_prompt_async(message=mock_request) mock_retry.assert_not_called() mock_impl.assert_called_once() @@ -541,9 +494,7 @@ async def failing_then_succeeding_callback(**kwargs): os.environ["RETRY_WAIT_MAX_SECONDS"] = "1" try: - target = _CallbackChatTarget( - callback=failing_then_succeeding_callback, retry_enabled=True - ) + target = _CallbackChatTarget(callback=failing_then_succeeding_callback, retry_enabled=True) # Create mock request request_piece = MagicMock() @@ -554,9 +505,7 @@ async def failing_then_succeeding_callback(**kwargs): mock_request = MagicMock() mock_request.message_pieces = [request_piece] - mock_request.get_piece = MagicMock( - side_effect=lambda i: mock_request.message_pieces[i] - ) + mock_request.get_piece = MagicMock(side_effect=lambda i: mock_request.message_pieces[i]) with patch.object(target, "_memory") as mock_memory, patch( "azure.ai.evaluation.red_team._callback_chat_target.construct_response_from_request"