Conversation
Wrapped the Python Library LLM (for OpenAI, Anthropic’s Claude, Google’s Gemini etc) as a garak generator.
|
@leondz Please check |
|
thanks, will take a look! |
|
@Nakul-Rajpal This isn't passing tests - can you amend? |
|
@leondz It should be good now? The tests were failing due to me not adding the llm library to the requirements so it ran without the module. |
|
@leondz Should be ready to go now; really sorry about the errors before I request to do another issue I should familiarize myself with the repo further. |
|
Yeah, it's in the review queue, thank you |
jmartin-tech
left a comment
There was a problem hiding this comment.
This looks like a great start. I noted a few edge case and a mismatch in how a system_prompt is handled.
Please take a look, happy to offer further detail or answer question about how things flow.
garak/generators/llm.py
Outdated
| if self.system: | ||
| prompt_kwargs["system"] = self.system |
There was a problem hiding this comment.
Current system prompt support in garak is tied to the conversation passed as part of prompt. The DEFAULT_PARAMS entry here should likely be removed in favor of extracting the system_prompt from the prompt via prompt.last_message("system"). That is if passing a conversation that includes the system message would not apply it.
garak/generators/llm.py
Outdated
| "max_tokens": None, | ||
| "top_p": None, | ||
| "stop": [], | ||
| "system": None, |
There was a problem hiding this comment.
Remove, the system prompt is set via the run configuration and pass to generators as part of the prompt conversation.
| "system": None, |
garak/generators/llm.py
Outdated
| if self.max_tokens is not None: | ||
| prompt_kwargs["max_tokens"] = self.max_tokens | ||
| if self.temperature is not None: | ||
| prompt_kwargs["temperature"] = self.temperature | ||
| if self.top_p is not None: | ||
| prompt_kwargs["top_p"] = self.top_p | ||
| if self.stop: | ||
| prompt_kwargs["stop"] = self.stop |
There was a problem hiding this comment.
None == False and all keys defined in DEFAULT_PARAMS will exist on self.
| if self.max_tokens is not None: | |
| prompt_kwargs["max_tokens"] = self.max_tokens | |
| if self.temperature is not None: | |
| prompt_kwargs["temperature"] = self.temperature | |
| if self.top_p is not None: | |
| prompt_kwargs["top_p"] = self.top_p | |
| if self.stop: | |
| prompt_kwargs["stop"] = self.stop | |
| if self.max_tokens: | |
| prompt_kwargs["max_tokens"] = self.max_tokens | |
| if self.temperature: | |
| prompt_kwargs["temperature"] = self.temperature | |
| if self.top_p: | |
| prompt_kwargs["top_p"] = self.top_p | |
| if self.stop: | |
| prompt_kwargs["stop"] = self.stop |
garak/generators/llm.py
Outdated
|
|
||
| This calls model.prompt() once per generation and materializes the text(). | ||
| """ | ||
| text_prompt = prompt.last_message().text |
There was a problem hiding this comment.
This does not grab out the full conversation. There is an existing helper function in the base class Generator._conversation_to_list() that will format the garak Conversation object as a list of dictionaries meeting the HuggingFace and OpenAI conversation list. Looking at how the llm library handles what it considers to be conversation I don't know if there is a way to load a prefilled history in a similar pattern to how chat completions APIs for other generators are working.
For best adoption, this generator should at least validate the conversation has at most one user and one system message to know if the prompt passed will be fully processed during inference.
garak/generators/llm.py
Outdated
| "temperature": None, | ||
| "max_tokens": None, |
There was a problem hiding this comment.
temperature and max_tokens are already in Generator.DEFAULT_PARAMS is there a reason to include here?
| "temperature": None, | |
| "max_tokens": None, |
There was a problem hiding this comment.
This question is still pending, is there a reason that max_tokens is still overridden here? This deviates from other generators fragmenting expectations as the default inference generation limits will not be consistent with other generators. This is not a blocking issue simply one that needs to be explained to be sure this is the best approach for users of this generator.
leondz
left a comment
There was a problem hiding this comment.
Looks pretty good. Requests to add a pattern supporting parallelisation, some renaming, and vars ensuring test consistency
tests/generators/test_llm.py
Outdated
| # SPDX-FileCopyrightText: Portions Copyright (c) 2025 NVIDIA CORPORATION & | ||
| # AFFILIATES. All rights reserved. |
There was a problem hiding this comment.
| # SPDX-FileCopyrightText: Portions Copyright (c) 2025 NVIDIA CORPORATION & | |
| # AFFILIATES. All rights reserved. | |
| # SPDX-FileCopyrightText: Portions Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. |
tests/generators/test_llm.py
Outdated
| def test_generate_returns_message(cfg, fake_llm): | ||
| gen = LLMGenerator(name="alias", config_root=cfg) | ||
|
|
||
| conv = Conversation([Turn("user", Message(text="ping"))]) |
There was a problem hiding this comment.
| conv = Conversation([Turn("user", Message(text="ping"))]) | |
| test_txt = "ping" | |
| conv = Conversation([Turn("user", Message(text=test_txt))]) |
tests/generators/test_llm.py
Outdated
| assert out[0].text == "OK_FAKE" | ||
|
|
||
| prompt_text, kwargs = fake_llm.calls[0] | ||
| assert prompt_text == "ping" |
There was a problem hiding this comment.
| assert prompt_text == "ping" | |
| assert prompt_text == test_txt |
tests/generators/test_llm.py
Outdated
| gen.temperature = 0.2 | ||
| gen.max_tokens = 64 | ||
| gen.top_p = 0.9 | ||
| gen.stop = ["\n\n"] | ||
| gen.system = "you are testy" |
There was a problem hiding this comment.
use vars for these values (and the checks later)
tests/generators/test_llm.py
Outdated
| assert kwargs["temperature"] == 0.2 | ||
| assert kwargs["max_tokens"] == 64 | ||
| assert kwargs["top_p"] == 0.9 | ||
| assert kwargs["stop"] == ["\n\n"] | ||
| assert kwargs["system"] == "you are testy" |
There was a problem hiding this comment.
vars here. could do a list assignment / check likex,y = 1,2 for brevity
| class BoomModel: | ||
| def prompt(self, *a, **k): | ||
| raise RuntimeError("boom") | ||
| monkeypatch.setattr(llm, "get_model", lambda *a, **k: BoomModel()) |
jmartin-tech
left a comment
There was a problem hiding this comment.
Some tweaks to ensure consistent behaviour.
Code suggestions are untested.
garak/generators/llm.py
Outdated
| prompt_kwargs = { | ||
| key: getattr(self, key) | ||
| for key in ("max_tokens", "temperature", "top_p") | ||
| if getattr(self, key) is not None | ||
| } | ||
| if self.stop: | ||
| prompt_kwargs["stop"] = self.stop |
There was a problem hiding this comment.
Could this inspect the accepted arguments to self.target.prompt() vs a hard coded list here? Something similar exists in the OpenAICompatible class, where we collect all options set on the generator that the target API accepts.
Adjusted LLMGenerator, Updated generator tests accordingly.
- Remove duplicate documentation in llm.py - Remove duplicate license header in test_llm.py - Add InjectLeet to CLEAR_TRIGGER_PROBES to fix CI failure (Leetspeak doesn't encode special characters like <>/. so triggers appear in prompts)
Signed-off-by: Nakul Rajpal <66713174+Nakul-Rajpal@users.noreply.github.com>
|
@jmartin-tech @leondz Hi. I removed the duplicated documentation and license header in the test file. I also fixed the CI test failure. Everything should work now. |
|
@leondz @jmartin-tech Just wanted to check whether this can be merged. |
Signed-off-by: Jeffrey Martin <jemartin@nvidia.com>
leondz
left a comment
There was a problem hiding this comment.
Almost there. Some doubts around multiple generation & parallelisation setup
| "top_p": None, | ||
| "stop": [], | ||
| } | ||
|
|
There was a problem hiding this comment.
Looks like this generator is set with two defaults True - supports_multiple_generations and parallel_capable. I'm not sure either of these are sensible in this case.
There was a problem hiding this comment.
Defaults are currently:
supports_multiple_generations = False
parallel_capable = True
If these are left in place the implementation here handles multiprocessing support, though maybe it should not if using an ollama or some other locally executed model stack is specified.
Since this generator only supports one-shot single message prompts I think the setting both to False may be a valid conservative way to proceed.
garak/generators/llm.py
Outdated
| try: | ||
| response = self.target.prompt(text_prompt, **prompt_kwargs) | ||
| out = response.text() | ||
| return [Message(out)] |
There was a problem hiding this comment.
ignores generations_this_call, which should be respected
garak/generators/llm.py
Outdated
| return [Message(out)] | ||
| except Exception as e: | ||
| logging.error("`llm` generation failed: %s", repr(e)) | ||
| return [None] |
There was a problem hiding this comment.
ignores generations_this_call, which should be respected
|
|
||
|
|
There was a problem hiding this comment.
can we get a test on multiple generation, i.e. _call_model with generations_this_call > 1 ?
There was a problem hiding this comment.
supports_multiple_generations is currently False so generations_this_call would always be 1.
Wrapped the Python Library LLM (for OpenAI, Anthropic’s Claude, Google’s Gemini etc) as a garak generator.
Fixes Issue #463