Skip to content

Commit c77e797

Browse files
authored
Merge pull request #1575 from WebFuzzing/llm-mock
basic mock scaffolding for LLM use
2 parents f935b73 + f2a5710 commit c77e797

9 files changed

Lines changed: 171 additions & 8 deletions

File tree

core/src/main/kotlin/org/evomaster/core/BaseModule.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package org.evomaster.core
33
import com.google.inject.AbstractModule
44
import com.google.inject.Provides
55
import com.google.inject.Singleton
6+
import org.evomaster.core.llm.service.DictionaryService
7+
import org.evomaster.core.llm.service.LlmService
68
import org.evomaster.core.output.service.PartialOracles
79
import org.evomaster.core.search.service.mutator.genemutation.ArchiveImpactSelector
810
import org.evomaster.core.search.service.*
@@ -95,6 +97,12 @@ class BaseModule(val args: Array<String>, val noTests: Boolean = false) : Abstra
9597
bind(WarningsAggregator::class.java)
9698
.asEagerSingleton()
9799

100+
bind(LlmService::class.java)
101+
.asEagerSingleton()
102+
103+
bind(DictionaryService::class.java)
104+
.asEagerSingleton()
105+
98106
//no longer needed if TestSuiteWriter is moved out?
99107
// if(noTests){
100108
// bind(TestCaseWriter::class.java)

core/src/main/kotlin/org/evomaster/core/llm/LlmProvider.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@ enum class LlmProvider {
55
OPENAI,
66
DEEPSEEK, // OpenAI-compatible
77
OLLAMA,
8-
AZURE_OPENAI
8+
AZURE_OPENAI,
9+
MOCK
910
}

core/src/main/kotlin/org/evomaster/core/llm/LlmSupport.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import dev.langchain4j.model.ollama.OllamaChatModel
1414
import dev.langchain4j.model.ollama.OllamaStreamingChatModel
1515
import dev.langchain4j.model.openai.OpenAiChatModel
1616
import dev.langchain4j.model.openai.OpenAiStreamingChatModel
17+
import org.evomaster.core.llm.mock.MockChatModel
18+
import org.evomaster.core.llm.mock.MockStreamingChatModel
1719
import java.util.concurrent.CompletableFuture
1820

1921

@@ -35,6 +37,13 @@ object LlmSupport {
3537

3638
}
3739

40+
fun chat(model: ChatModel, userMessage: String): String {
41+
42+
val u = UserMessage.userMessage(userMessage)
43+
44+
return model.chat(listOf(u)).aiMessage().text()
45+
}
46+
3847
fun chat(model: ChatModel, systemMessage: String, userMessage: String): String {
3948

4049
val s = SystemMessage.systemMessage(systemMessage)
@@ -76,6 +85,8 @@ object LlmSupport {
7685
*/
7786

7887
return when (provider) {
88+
LlmProvider.MOCK -> MockChatModel()
89+
7990
LlmProvider.OPENAI -> OpenAiChatModel.builder()
8091
.apiKey(apiKey ?: error("API key required for OpenAI"))
8192
.baseUrl(url ?: "https://api.openai.com/v1")
@@ -138,6 +149,8 @@ object LlmSupport {
138149
*/
139150

140151
return when (provider) {
152+
LlmProvider.MOCK -> MockStreamingChatModel()
153+
141154
LlmProvider.OPENAI -> OpenAiStreamingChatModel.builder()
142155
.apiKey(apiKey ?: error("API key required for OpenAI"))
143156
.baseUrl(url ?: "https://api.openai.com/v1")
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package org.evomaster.core.llm.mock
2+
3+
class LlmMockResponse(
4+
5+
/**
6+
* The response producer that will be returned by the LLM, given an input text from user
7+
*/
8+
val responseProducer: (String) -> String,
9+
10+
/**
11+
* Lambda that, given as input a request from the client, decides whether this response should be returned
12+
*/
13+
val matcher: (String) -> Boolean
14+
)
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package org.evomaster.core.llm.mock
2+
3+
import dev.langchain4j.data.message.AiMessage
4+
import dev.langchain4j.model.chat.ChatModel
5+
import dev.langchain4j.model.chat.ChatRequestOptions
6+
import dev.langchain4j.model.chat.request.ChatRequest
7+
import dev.langchain4j.model.chat.response.ChatResponse
8+
9+
class MockChatModel : ChatModel {
10+
11+
companion object {
12+
13+
/*
14+
WARNING
15+
Global mutable state... but only used for testing.
16+
But out of the box would prevent parallel tests in same process, which we don't anyway,
17+
so shouldn't be a big deal
18+
*/
19+
private val responses : MutableList<LlmMockResponse> = mutableListOf()
20+
21+
/**
22+
* Clear the cache of mocked responses.
23+
* Important to avoid dependencies among tests.
24+
* Recall this is using mutable static state.
25+
*/
26+
fun reset(){
27+
responses.clear()
28+
}
29+
30+
/**
31+
* Specify the [response] that will be returned by the LLM,
32+
* based on [matcher] that, given as input a request from the client,
33+
* decides whether this response should be returned
34+
*/
35+
fun mockResponse(response: String, matcher: (String) -> Boolean){
36+
responses.add(LlmMockResponse({req -> response}, matcher))
37+
}
38+
39+
/**
40+
* Specify a producer of responses if the text of the query from user does match the matcher.
41+
* Input for both lambdas is such text.
42+
* This is useful when the response for same query might be different
43+
*/
44+
fun mockResponse(responseProducer: (String) -> String, matcher: (String) -> Boolean){
45+
responses.add(LlmMockResponse(responseProducer, matcher))
46+
}
47+
}
48+
49+
/**
50+
* Main method that needs to be mocked.
51+
* All other methods seem to call this one.
52+
*/
53+
override fun chat(chatRequest: ChatRequest, options: ChatRequestOptions) : ChatResponse {
54+
55+
val text = chatRequest.messages().joinToString { it.toString() }
56+
57+
val res = responses.find { mock -> mock.matcher(text) }?.responseProducer(text)
58+
/*
59+
TODO we could also have a boolen parameter to decide what to do in case of no match
60+
(eg response as now or throw exception), to make the mock more configurable
61+
*/
62+
?: "Hei! You forgot to setup the mock for me"
63+
64+
return ChatResponse.builder().aiMessage(AiMessage.from(res)).build()
65+
}
66+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package org.evomaster.core.llm.mock
2+
3+
import dev.langchain4j.model.chat.ChatRequestOptions
4+
import dev.langchain4j.model.chat.StreamingChatModel
5+
import dev.langchain4j.model.chat.request.ChatRequest
6+
import dev.langchain4j.model.chat.response.StreamingChatResponseHandler
7+
8+
/**
9+
* WARNING:
10+
* we wrote the code for it, but in the end we did not use StreamingChatModel.
11+
* TODO If one day we do, then we need to update this class if we want to have tests using it.
12+
*/
13+
class MockStreamingChatModel : StreamingChatModel {
14+
15+
override fun chat(request: ChatRequest?, options: ChatRequestOptions?, handler: StreamingChatResponseHandler?){
16+
17+
throw NotImplementedError()
18+
}
19+
}

core/src/main/kotlin/org/evomaster/core/problem/enterprise/service/EnterpriseModule.kt

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,6 @@ abstract class EnterpriseModule : AbstractModule() {
1414
bind(WFCReportWriter::class.java)
1515
.asEagerSingleton()
1616

17-
bind(LlmService::class.java)
18-
.asEagerSingleton()
19-
20-
bind(DictionaryService::class.java)
21-
.asEagerSingleton()
22-
2317
bind(SSRFAnalyser::class.java)
2418
.asEagerSingleton()
2519

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package org.evomaster.core.llm
2+
3+
import org.evomaster.core.llm.mock.MockChatModel
4+
import org.junit.jupiter.api.Assertions.assertEquals
5+
import org.junit.jupiter.api.Test
6+
7+
class LlmSupportMockTest {
8+
9+
@Test
10+
fun testBasePrompt(){
11+
val model = LlmSupport.createModel(LlmProvider.MOCK)
12+
13+
val firstPrompt = """
14+
Is A the first letter in the English alphabet? Answer this question with either a "YES" or a "NO".
15+
Do not add anything else in your response.
16+
""".trimIndent()
17+
18+
val secondPrompt = """
19+
Is Sweden the capital of Norway?
20+
""".trimIndent()
21+
22+
MockChatModel.reset()
23+
MockChatModel.mockResponse("YES"){ it.contains("English")}
24+
MockChatModel.mockResponse("NO"){ it.contains("Norway")}
25+
26+
27+
assertEquals("YES", LlmSupport.chat(model, firstPrompt))
28+
assertEquals("NO", LlmSupport.chat(model, secondPrompt))
29+
}
30+
31+
@Test
32+
fun testResponseProducer(){
33+
val model = LlmSupport.createModel(LlmProvider.MOCK)
34+
35+
val prompt = "Give me a random value"
36+
val x = arrayOf(0)
37+
38+
MockChatModel.reset()
39+
MockChatModel.mockResponse({_ -> "${x[0]++}"}){it.contains("random")}
40+
41+
val n = 10
42+
repeat(n){
43+
LlmSupport.chat(model, prompt)
44+
}
45+
46+
assertEquals(n, x[0])
47+
}
48+
}

docs/options.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ There are 3 types of options:
309309
|`llm`| __Boolean__. Enable the use of LLMs. *Default value*: `false`.|
310310
|`llmApiKey`| __String__. API KEY needed to authenticated toward the chosen LLM provider. *Depends on*: `llm=true`. *Default value*: `null`.|
311311
|`llmName`| __String__. LLM name. If not specified, default will be based on the LLM provider. *Depends on*: `llm=true`. *Default value*: `null`.|
312-
|`llmProvider`| __Enum__. Provider for the LLM. This could be a local one (e.g., run through Ollama), or a remote one like OpenAI. *Depends on*: `llm=true`. *Valid values*: `OPENAI, DEEPSEEK, OLLAMA, AZURE_OPENAI`. *Default value*: `OLLAMA`.|
312+
|`llmProvider`| __Enum__. Provider for the LLM. This could be a local one (e.g., run through Ollama), or a remote one like OpenAI. *Depends on*: `llm=true`. *Valid values*: `OPENAI, DEEPSEEK, OLLAMA, AZURE_OPENAI, MOCK`. *Default value*: `OLLAMA`.|
313313
|`llmTemperature`| __Double__. Temperature parameter for LLM. *Constraints*: `min=0.0, max=2.0`. *Depends on*: `llm=true`. *Default value*: `0.3`.|
314314
|`llmThreads`| __Int__. The number of threads to use when making calls towards an LLM, in configured. If connecting to Ollama, this value is ignored, and only 1 thread is used. *Depends on*: `llm=true`. *Default value*: `4`.|
315315
|`llmTimeoutSeconds`| __Long__. How long to wait for LLM's responses. *Constraints*: `min=0.0`. *Depends on*: `llm=true`. *Default value*: `60`.|

0 commit comments

Comments
 (0)