From bd09a605ed0eb5ced459a93c1c83f02ffb533061 Mon Sep 17 00:00:00 2001 From: Aftabbs Date: Wed, 5 Aug 2026 08:19:23 +0530 Subject: [PATCH 1/3] fix(nim): retry transient HTTP errors and report actual status code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a NIM endpoint returns HTTP 408 (timeout), 502, 503, or 504, garak currently aborts the entire probe with a misleading generic error message ("Is the model name spelled correctly?"). Two independent problems: 1. The backoff decorator on OpenAICompatible._call_model only caught RateLimitError, InternalServerError, APITimeoutError, and APIConnectionError — leaving bare openai.APIStatusError (which NIM raises for 408) to fall through without any retry. 2. The catch-all except Exception block in NIM._call_model swallowed the original HTTP status code, making diagnostics difficult. Fix: - Add _TRANSIENT_HTTP_CODES frozenset {408, 502, 503, 504} and a module- level _is_terminal_api_error() giveup predicate in generators/openai.py. - Extend the backoff decorator to also catch openai.APIStatusError, using the giveup predicate to retry only transient codes and immediately surface terminal errors (400, 401, 403, 404, 409, 422). - Split the bare except Exception in NIM._call_model into an openai.APIStatusError branch that logs and re-raises with the actual HTTP status code and URL, plus a fallback branch that includes the exception type name. - Add 15 unit tests covering the giveup logic and error message content. Closes #1967 Signed-off-by: Aftabbs --- garak/generators/nim.py | 9 +++- garak/generators/openai.py | 16 ++++++ tests/generators/test_nim.py | 101 +++++++++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 1 deletion(-) diff --git a/garak/generators/nim.py b/garak/generators/nim.py index d19d1bb3d..9e4151177 100644 --- a/garak/generators/nim.py +++ b/garak/generators/nim.py @@ -95,8 +95,15 @@ def _call_model( msg = "NIM endpoint not found. Is the model name spelled correctly and the endpoint URI correct?" logging.critical(msg, exc_info=nfe) raise GarakException(f"🛑 {msg}") from nfe + except openai.APIStatusError as oe: + msg = ( + f"NIM generation failed with HTTP {oe.status_code} from {oe.request.url}: " + f"{oe.message}" + ) + logging.critical(msg, exc_info=oe) + raise GarakException(f"🛑 {msg}") from oe except Exception as oe: - msg = "NIM generation failed. Is the model name spelled correctly?" + msg = f"NIM generation failed: {type(oe).__name__}: {oe}" logging.critical(msg, exc_info=oe) raise GarakException(f"🛑 {msg}") from oe diff --git a/garak/generators/openai.py b/garak/generators/openai.py index dbdac75cf..925b35ecd 100644 --- a/garak/generators/openai.py +++ b/garak/generators/openai.py @@ -25,6 +25,20 @@ import garak.exception from garak.generators.base import Generator +# HTTP status codes that indicate a transient server-side condition worth retrying. +# 408 (Request Timeout), 502 (Bad Gateway), 503 (Service Unavailable), 504 (Gateway Timeout) +# arrive as openai.APIStatusError and are not caught by InternalServerError (5xx only) +# or APITimeoutError (client-side read timeout, not an HTTP-level status code). +_TRANSIENT_HTTP_CODES = frozenset({408, 502, 503, 504}) + + +def _is_terminal_api_error(exc: Exception) -> bool: + """Return True when an APIStatusError should NOT be retried (terminal, non-transient).""" + return isinstance(exc, openai.APIStatusError) and ( + exc.status_code not in _TRANSIENT_HTTP_CODES + ) + + # lists derived from https://platform.openai.com/docs/models chat_models = ( "gpt-5-nano", @@ -270,9 +284,11 @@ def _conversation_to_list(conversation: Conversation) -> list[dict]: openai.InternalServerError, openai.APITimeoutError, openai.APIConnectionError, + openai.APIStatusError, garak.exception.GeneratorBackoffTrigger, ), max_value=70, + giveup=_is_terminal_api_error, ) def _call_model( self, prompt: Union[Conversation, List[dict]], generations_this_call: int = 1 diff --git a/tests/generators/test_nim.py b/tests/generators/test_nim.py index 7346663bc..a7251c434 100644 --- a/tests/generators/test_nim.py +++ b/tests/generators/test_nim.py @@ -3,10 +3,111 @@ import os import pytest +import httpx +import openai +from unittest.mock import MagicMock, patch from garak.attempt import Message, Turn, Conversation +from garak.exception import GarakException import garak.cli from garak.generators.nim import NVOpenAIChat +from garak.generators.openai import _is_terminal_api_error, _TRANSIENT_HTTP_CODES + + +def _make_api_status_error(status_code: int, url: str = "http://localhost/v1/chat/completions") -> openai.APIStatusError: + """Build an openai.APIStatusError with a real httpx.Response for a given HTTP status.""" + request = httpx.Request("POST", url) + response = httpx.Response(status_code, request=request) + return openai.APIStatusError(f"HTTP {status_code}", response=response, body=None) + + +def test_transient_http_codes_set(): + assert 408 in _TRANSIENT_HTTP_CODES + assert 502 in _TRANSIENT_HTTP_CODES + assert 503 in _TRANSIENT_HTTP_CODES + assert 504 in _TRANSIENT_HTTP_CODES + assert 400 not in _TRANSIENT_HTTP_CODES + assert 401 not in _TRANSIENT_HTTP_CODES + assert 403 not in _TRANSIENT_HTTP_CODES + assert 404 not in _TRANSIENT_HTTP_CODES + assert 422 not in _TRANSIENT_HTTP_CODES + + +@pytest.mark.parametrize("code", [408, 502, 503, 504]) +def test_is_terminal_api_error_returns_false_for_transient(code): + """Transient codes should NOT give up — backoff must retry them.""" + exc = _make_api_status_error(code) + assert _is_terminal_api_error(exc) is False + + +@pytest.mark.parametrize("code", [400, 401, 403, 404, 409, 422]) +def test_is_terminal_api_error_returns_true_for_fatal(code): + """Fatal/non-retryable codes should give up immediately.""" + exc = _make_api_status_error(code) + assert _is_terminal_api_error(exc) is True + + +def test_is_terminal_api_error_ignores_non_api_status_error(): + """Non-APIStatusError exceptions are not touched by the giveup function.""" + assert _is_terminal_api_error(ValueError("unrelated")) is False + + +@pytest.fixture +def nim_generator(monkeypatch): + """NVOpenAIChat with mocked client; no real API key required.""" + monkeypatch.setenv(NVOpenAIChat.ENV_VAR, "test-fake-key-for-unit-tests") + mock_client = MagicMock() + mock_client.chat.completions = MagicMock() + with patch("openai.OpenAI", return_value=mock_client): + g = NVOpenAIChat(name="org/test-model") + return g + + +def test_nim_408_error_message_includes_status_code(nim_generator): + """A server-side HTTP 408 should surface its status code in the GarakException, not + the generic 'Is the model name spelled correctly?' message.""" + error_408 = _make_api_status_error(408) + prompt = Conversation([Turn(role="user", content=Message("test"))]) + + with patch( + "garak.generators.openai.OpenAICompatible._call_model", side_effect=error_408 + ): + with pytest.raises(GarakException) as exc_info: + nim_generator._call_model(prompt) + + error_text = str(exc_info.value) + assert "408" in error_text, f"Expected '408' in error message; got: {error_text}" + assert "Is the model name spelled correctly?" not in error_text + + +def test_nim_502_error_message_includes_status_code(nim_generator): + """A server-side HTTP 502 should also surface its status code.""" + error_502 = _make_api_status_error(502) + prompt = Conversation([Turn(role="user", content=Message("test"))]) + + with patch( + "garak.generators.openai.OpenAICompatible._call_model", side_effect=error_502 + ): + with pytest.raises(GarakException) as exc_info: + nim_generator._call_model(prompt) + + error_text = str(exc_info.value) + assert "502" in error_text, f"Expected '502' in error message; got: {error_text}" + + +def test_nim_generic_exception_message_improved(nim_generator): + """Non-APIStatusError exceptions should report the exception type, not the old generic message.""" + prompt = Conversation([Turn(role="user", content=Message("test"))]) + + with patch( + "garak.generators.openai.OpenAICompatible._call_model", + side_effect=RuntimeError("connection reset by peer"), + ): + with pytest.raises(GarakException) as exc_info: + nim_generator._call_model(prompt) + + error_text = str(exc_info.value) + assert "Is the model name spelled correctly?" not in error_text @pytest.mark.skipif( From 85b969bf83d2a36b502f40f54c469d1bb3b47477 Mon Sep 17 00:00:00 2001 From: Aftabbs Date: Sat, 8 Aug 2026 13:12:55 +0530 Subject: [PATCH 2/3] fix(openai): prevent giveup predicate from short-circuiting retries for RateLimitError and InternalServerError The _is_terminal_api_error giveup function previously returned True for any APIStatusError whose status code was not in _TRANSIENT_HTTP_CODES. Because RateLimitError (429) and InternalServerError (500/503) both subclass APIStatusError, the predicate fired for them too, causing the backoff decorator to stop retrying errors that have dedicated retry entries in the exception tuple. Fix: return False immediately for RateLimitError and InternalServerError instances so the giveup predicate never overrides their dedicated retry logic. Also add 429 to _TRANSIENT_HTTP_CODES for completeness (bare APIStatusError with 429 should also be retried, e.g. from non-standard endpoints). New tests verify that the predicate does not fire for: - openai.RateLimitError (429) - openai.InternalServerError (500, 503) Signed-off-by: Aftabbs --- garak/generators/openai.py | 18 +++++++++++++----- tests/generators/test_nim.py | 22 +++++++++++++++++++++- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/garak/generators/openai.py b/garak/generators/openai.py index 925b35ecd..d85818888 100644 --- a/garak/generators/openai.py +++ b/garak/generators/openai.py @@ -29,14 +29,22 @@ # 408 (Request Timeout), 502 (Bad Gateway), 503 (Service Unavailable), 504 (Gateway Timeout) # arrive as openai.APIStatusError and are not caught by InternalServerError (5xx only) # or APITimeoutError (client-side read timeout, not an HTTP-level status code). -_TRANSIENT_HTTP_CODES = frozenset({408, 502, 503, 504}) +_TRANSIENT_HTTP_CODES = frozenset({408, 429, 502, 503, 504}) def _is_terminal_api_error(exc: Exception) -> bool: - """Return True when an APIStatusError should NOT be retried (terminal, non-transient).""" - return isinstance(exc, openai.APIStatusError) and ( - exc.status_code not in _TRANSIENT_HTTP_CODES - ) + """Return True when an APIStatusError should NOT be retried (terminal, non-transient). + + RateLimitError (429) and InternalServerError (5xx) subclass APIStatusError but have + dedicated retry entries in the backoff tuple; always return False for them so the + backoff decorator continues retrying without interruption. + """ + if not isinstance(exc, openai.APIStatusError): + return False + # Subtypes with explicit backoff entries should never be treated as terminal. + if isinstance(exc, (openai.RateLimitError, openai.InternalServerError)): + return False + return exc.status_code not in _TRANSIENT_HTTP_CODES # lists derived from https://platform.openai.com/docs/models diff --git a/tests/generators/test_nim.py b/tests/generators/test_nim.py index a7251c434..bc3688075 100644 --- a/tests/generators/test_nim.py +++ b/tests/generators/test_nim.py @@ -23,6 +23,7 @@ def _make_api_status_error(status_code: int, url: str = "http://localhost/v1/cha def test_transient_http_codes_set(): assert 408 in _TRANSIENT_HTTP_CODES + assert 429 in _TRANSIENT_HTTP_CODES assert 502 in _TRANSIENT_HTTP_CODES assert 503 in _TRANSIENT_HTTP_CODES assert 504 in _TRANSIENT_HTTP_CODES @@ -33,7 +34,7 @@ def test_transient_http_codes_set(): assert 422 not in _TRANSIENT_HTTP_CODES -@pytest.mark.parametrize("code", [408, 502, 503, 504]) +@pytest.mark.parametrize("code", [408, 429, 502, 503, 504]) def test_is_terminal_api_error_returns_false_for_transient(code): """Transient codes should NOT give up — backoff must retry them.""" exc = _make_api_status_error(code) @@ -52,6 +53,25 @@ def test_is_terminal_api_error_ignores_non_api_status_error(): assert _is_terminal_api_error(ValueError("unrelated")) is False +def test_is_terminal_api_error_rate_limit_never_terminal(): + """RateLimitError (429) is a dedicated backoff-tuple entry; giveup must not fire.""" + request = httpx.Request("POST", "http://localhost/v1/chat/completions") + response = httpx.Response(429, request=request) + exc = openai.RateLimitError("rate limited", response=response, body=None) + assert _is_terminal_api_error(exc) is False + + +def test_is_terminal_api_error_internal_server_error_never_terminal(): + """InternalServerError (500/503) is a dedicated backoff-tuple entry; giveup must not fire.""" + request = httpx.Request("POST", "http://localhost/v1/chat/completions") + for code in (500, 503): + response = httpx.Response(code, request=request) + exc = openai.InternalServerError(f"server error {code}", response=response, body=None) + assert _is_terminal_api_error(exc) is False, ( + f"InternalServerError({code}) must not be treated as terminal" + ) + + @pytest.fixture def nim_generator(monkeypatch): """NVOpenAIChat with mocked client; no real API key required.""" From a0a731c05fb30159a5d93239019c2b621f5aa99d Mon Sep 17 00:00:00 2001 From: Aftabbs Date: Mon, 10 Aug 2026 22:42:57 +0530 Subject: [PATCH 3/3] fix(generators): catch APIStatusError inside _call_model instead of giveup predicate Transient HTTP errors (408, 429, 502, 503, 504) that arrive as openai.APIStatusError are now caught inside _call_model. Transient codes raise GeneratorBackoffTrigger so the existing backoff decorator retries them; non-transient codes log a warning and return [None] so the probe run continues gracefully. This replaces the giveup= predicate approach, which stopped retrying but re-raised the exception rather than returning [None] for terminal errors. The status codes are exposed as transient_retry_codes in DEFAULT_PARAMS so users can adjust them via config. The dead openai.APIStatusError handler in NVOpenAIChat._call_model is removed; the parent class now handles all APIStatusError cases before they reach the NIM override. Tests call _call_model.__wrapped__ to exercise the exception-handling logic without backoff retry delays. Fixes #1967 Signed-off-by: Aftabbs --- garak/generators/nim.py | 7 --- garak/generators/openai.py | 31 +++------- tests/generators/test_nim.py | 116 ++++++++++++----------------------- 3 files changed, 46 insertions(+), 108 deletions(-) diff --git a/garak/generators/nim.py b/garak/generators/nim.py index 9e4151177..42e4c7909 100644 --- a/garak/generators/nim.py +++ b/garak/generators/nim.py @@ -95,13 +95,6 @@ def _call_model( msg = "NIM endpoint not found. Is the model name spelled correctly and the endpoint URI correct?" logging.critical(msg, exc_info=nfe) raise GarakException(f"🛑 {msg}") from nfe - except openai.APIStatusError as oe: - msg = ( - f"NIM generation failed with HTTP {oe.status_code} from {oe.request.url}: " - f"{oe.message}" - ) - logging.critical(msg, exc_info=oe) - raise GarakException(f"🛑 {msg}") from oe except Exception as oe: msg = f"NIM generation failed: {type(oe).__name__}: {oe}" logging.critical(msg, exc_info=oe) diff --git a/garak/generators/openai.py b/garak/generators/openai.py index d85818888..a94f662ba 100644 --- a/garak/generators/openai.py +++ b/garak/generators/openai.py @@ -25,28 +25,6 @@ import garak.exception from garak.generators.base import Generator -# HTTP status codes that indicate a transient server-side condition worth retrying. -# 408 (Request Timeout), 502 (Bad Gateway), 503 (Service Unavailable), 504 (Gateway Timeout) -# arrive as openai.APIStatusError and are not caught by InternalServerError (5xx only) -# or APITimeoutError (client-side read timeout, not an HTTP-level status code). -_TRANSIENT_HTTP_CODES = frozenset({408, 429, 502, 503, 504}) - - -def _is_terminal_api_error(exc: Exception) -> bool: - """Return True when an APIStatusError should NOT be retried (terminal, non-transient). - - RateLimitError (429) and InternalServerError (5xx) subclass APIStatusError but have - dedicated retry entries in the backoff tuple; always return False for them so the - backoff decorator continues retrying without interruption. - """ - if not isinstance(exc, openai.APIStatusError): - return False - # Subtypes with explicit backoff entries should never be treated as terminal. - if isinstance(exc, (openai.RateLimitError, openai.InternalServerError)): - return False - return exc.status_code not in _TRANSIENT_HTTP_CODES - - # lists derived from https://platform.openai.com/docs/models chat_models = ( "gpt-5-nano", @@ -174,6 +152,7 @@ class OpenAICompatible(Generator): "suppressed_params": set(), "retry_json": True, "extra_params": {}, + "transient_retry_codes": [408, 429, 502, 503, 504], } _unsafe_attributes = ["client", "generator"] @@ -292,11 +271,9 @@ def _conversation_to_list(conversation: Conversation) -> list[dict]: openai.InternalServerError, openai.APITimeoutError, openai.APIConnectionError, - openai.APIStatusError, garak.exception.GeneratorBackoffTrigger, ), max_value=70, - giveup=_is_terminal_api_error, ) def _call_model( self, prompt: Union[Conversation, List[dict]], generations_this_call: int = 1 @@ -378,6 +355,12 @@ def _call_model( logging.exception(e) logging.error(msg) return [None] + except openai.APIStatusError as e: + if e.status_code in self.transient_retry_codes: + raise garak.exception.GeneratorBackoffTrigger from e + msg = f"HTTP {e.status_code} from {e.request.url}: {e.message}" + logging.warning(msg) + return [None] except json.decoder.JSONDecodeError as e: logging.exception(e) if self.retry_json: diff --git a/tests/generators/test_nim.py b/tests/generators/test_nim.py index bc3688075..3d16da50d 100644 --- a/tests/generators/test_nim.py +++ b/tests/generators/test_nim.py @@ -7,69 +7,25 @@ import openai from unittest.mock import MagicMock, patch +import garak.exception from garak.attempt import Message, Turn, Conversation from garak.exception import GarakException import garak.cli from garak.generators.nim import NVOpenAIChat -from garak.generators.openai import _is_terminal_api_error, _TRANSIENT_HTTP_CODES +from garak.generators.openai import OpenAICompatible -def _make_api_status_error(status_code: int, url: str = "http://localhost/v1/chat/completions") -> openai.APIStatusError: +def _make_api_status_error( + status_code: int, url: str = "http://localhost/v1/chat/completions" +) -> openai.APIStatusError: """Build an openai.APIStatusError with a real httpx.Response for a given HTTP status.""" request = httpx.Request("POST", url) response = httpx.Response(status_code, request=request) return openai.APIStatusError(f"HTTP {status_code}", response=response, body=None) -def test_transient_http_codes_set(): - assert 408 in _TRANSIENT_HTTP_CODES - assert 429 in _TRANSIENT_HTTP_CODES - assert 502 in _TRANSIENT_HTTP_CODES - assert 503 in _TRANSIENT_HTTP_CODES - assert 504 in _TRANSIENT_HTTP_CODES - assert 400 not in _TRANSIENT_HTTP_CODES - assert 401 not in _TRANSIENT_HTTP_CODES - assert 403 not in _TRANSIENT_HTTP_CODES - assert 404 not in _TRANSIENT_HTTP_CODES - assert 422 not in _TRANSIENT_HTTP_CODES - - -@pytest.mark.parametrize("code", [408, 429, 502, 503, 504]) -def test_is_terminal_api_error_returns_false_for_transient(code): - """Transient codes should NOT give up — backoff must retry them.""" - exc = _make_api_status_error(code) - assert _is_terminal_api_error(exc) is False - - -@pytest.mark.parametrize("code", [400, 401, 403, 404, 409, 422]) -def test_is_terminal_api_error_returns_true_for_fatal(code): - """Fatal/non-retryable codes should give up immediately.""" - exc = _make_api_status_error(code) - assert _is_terminal_api_error(exc) is True - - -def test_is_terminal_api_error_ignores_non_api_status_error(): - """Non-APIStatusError exceptions are not touched by the giveup function.""" - assert _is_terminal_api_error(ValueError("unrelated")) is False - - -def test_is_terminal_api_error_rate_limit_never_terminal(): - """RateLimitError (429) is a dedicated backoff-tuple entry; giveup must not fire.""" - request = httpx.Request("POST", "http://localhost/v1/chat/completions") - response = httpx.Response(429, request=request) - exc = openai.RateLimitError("rate limited", response=response, body=None) - assert _is_terminal_api_error(exc) is False - - -def test_is_terminal_api_error_internal_server_error_never_terminal(): - """InternalServerError (500/503) is a dedicated backoff-tuple entry; giveup must not fire.""" - request = httpx.Request("POST", "http://localhost/v1/chat/completions") - for code in (500, 503): - response = httpx.Response(code, request=request) - exc = openai.InternalServerError(f"server error {code}", response=response, body=None) - assert _is_terminal_api_error(exc) is False, ( - f"InternalServerError({code}) must not be treated as terminal" - ) +def _make_prompt() -> Conversation: + return Conversation([Turn(role="user", content=Message("test prompt"))]) @pytest.fixture @@ -83,41 +39,47 @@ def nim_generator(monkeypatch): return g -def test_nim_408_error_message_includes_status_code(nim_generator): - """A server-side HTTP 408 should surface its status code in the GarakException, not - the generic 'Is the model name spelled correctly?' message.""" - error_408 = _make_api_status_error(408) - prompt = Conversation([Turn(role="user", content=Message("test"))]) +def test_transient_retry_codes_in_default_params(): + """transient_retry_codes should be present in DEFAULT_PARAMS and contain expected codes.""" + codes = OpenAICompatible.DEFAULT_PARAMS["transient_retry_codes"] + for code in (408, 429, 502, 503, 504): + assert code in codes, f"Expected {code} in transient_retry_codes" + for code in (400, 401, 403, 404): + assert code not in codes, f"Expected {code} NOT in transient_retry_codes" - with patch( - "garak.generators.openai.OpenAICompatible._call_model", side_effect=error_408 - ): - with pytest.raises(GarakException) as exc_info: - nim_generator._call_model(prompt) - error_text = str(exc_info.value) - assert "408" in error_text, f"Expected '408' in error message; got: {error_text}" - assert "Is the model name spelled correctly?" not in error_text +@pytest.mark.parametrize("code", [408, 502, 503, 504]) +def test_transient_http_error_raises_backoff_trigger(nim_generator, code): + """A transient status code should cause _call_model to raise GeneratorBackoffTrigger + so that the backoff decorator can schedule a retry.""" + prompt = _make_prompt() + nim_generator.generator = MagicMock() + nim_generator.generator.create.side_effect = _make_api_status_error(code) + # Call the underlying function without the backoff decorator so the test does not + # need to wait for retry delays or exhaust the fibonacci sequence. + unwrapped = OpenAICompatible._call_model.__wrapped__ + with pytest.raises(garak.exception.GeneratorBackoffTrigger): + unwrapped(nim_generator, prompt) -def test_nim_502_error_message_includes_status_code(nim_generator): - """A server-side HTTP 502 should also surface its status code.""" - error_502 = _make_api_status_error(502) - prompt = Conversation([Turn(role="user", content=Message("test"))]) - with patch( - "garak.generators.openai.OpenAICompatible._call_model", side_effect=error_502 - ): - with pytest.raises(GarakException) as exc_info: - nim_generator._call_model(prompt) +@pytest.mark.parametrize("code", [400, 403, 404, 422]) +def test_terminal_http_error_returns_none(nim_generator, code): + """A terminal (non-transient) status code should cause _call_model to return [None] + so that the current attempt is skipped and the probe run continues.""" + prompt = _make_prompt() + nim_generator.generator = MagicMock() + nim_generator.generator.create.side_effect = _make_api_status_error(code) - error_text = str(exc_info.value) - assert "502" in error_text, f"Expected '502' in error message; got: {error_text}" + unwrapped = OpenAICompatible._call_model.__wrapped__ + result = unwrapped(nim_generator, prompt) + assert result == [None], f"Expected [None] for HTTP {code}, got {result!r}" def test_nim_generic_exception_message_improved(nim_generator): - """Non-APIStatusError exceptions should report the exception type, not the old generic message.""" - prompt = Conversation([Turn(role="user", content=Message("test"))]) + """Non-APIStatusError exceptions should report the exception type, not the old + generic 'Is the model name spelled correctly?' message.""" + prompt = _make_prompt() with patch( "garak.generators.openai.OpenAICompatible._call_model",