You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by fm...@apache.org on 2025/07/28 13:40:23 UTC
(camel) 01/02: Use mock instead of ollama
This is an automated email from the ASF dual-hosted git repository.
fmariani pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
commit 4a3eccf75b5c916625869a9a3693680dab620cde
Author: Croway <fe...@gmail.com>
AuthorDate: Thu Jul 24 12:34:17 2025 +0200
Use mock instead of ollama
---
.../camel-ai/camel-langchain4j-tools/pom.xml | 6 +
.../langchain4j/tools/LangChain4jToolIT.java | 13 +-
.../tools/LangChain4jToolMultipleCallsIT.java | 24 ++-
.../tools/LangChain4jToolMultipleGroupsIT.java | 26 ++-
.../LangChain4jToolMultipleMatchingGroupsIT.java | 22 ++-
.../tools/LangChain4jToolNoToolsExistIT.java | 18 +-
.../tools/LangChain4jToolNoToolsToBeCalledIT.java | 18 +-
test-infra/camel-test-infra-all/pom.xml | 11 ++
test-infra/camel-test-infra-openai-mock/README.md | 66 +++++++
test-infra/camel-test-infra-openai-mock/pom.xml | 69 +++++++
.../test/infra/openai/mock/MockExpectation.java | 121 ++++++++++++
.../test/infra/openai/mock/MockResponseType.java | 27 +++
.../camel/test/infra/openai/mock/OpenAIMock.java | 83 ++++++++
.../test/infra/openai/mock/OpenAIMockBuilder.java | 149 ++++++++++++++
.../infra/openai/mock/OpenAIMockServerHandler.java | 53 +++++
.../test/infra/openai/mock/RequestContext.java | 68 +++++++
.../test/infra/openai/mock/RequestHandler.java | 141 +++++++++++++
.../test/infra/openai/mock/ResponseBuilder.java | 143 ++++++++++++++
.../test/infra/openai/mock/ToolCallDefinition.java | 51 +++++
.../infra/openai/mock/ToolExecutionSequence.java | 76 +++++++
.../test/infra/openai/mock/ToolExecutionStep.java | 61 ++++++
.../src/main/resources/META-INF/MANIFEST.MF | 0
.../infra/openai/mock/OpenAIMockFailuresTest.java | 75 +++++++
.../openai/mock/OpenAIMockMultipleToolsTest.java | 100 ++++++++++
.../test/infra/openai/mock/OpenAIMockTest.java | 219 +++++++++++++++++++++
test-infra/pom.xml | 1 +
26 files changed, 1607 insertions(+), 34 deletions(-)
diff --git a/components/camel-ai/camel-langchain4j-tools/pom.xml b/components/camel-ai/camel-langchain4j-tools/pom.xml
index 1dd2662af8e..33c8498ba88 100644
--- a/components/camel-ai/camel-langchain4j-tools/pom.xml
+++ b/components/camel-ai/camel-langchain4j-tools/pom.xml
@@ -74,6 +74,12 @@
</dependency>
<!-- for testing -->
+ <dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-test-infra-openai-mock</artifactId>
+ <version>${project.version}</version>
+ <scope>test</scope>
+ </dependency>
<dependency>
<groupId>org.apache.camel</groupId>
<artifactId>camel-test-spring-junit5</artifactId>
diff --git a/components/camel-ai/camel-langchain4j-tools/src/test/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolIT.java b/components/camel-ai/camel-langchain4j-tools/src/test/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolIT.java
index ce2fbee1651..76f9b825133 100644
--- a/components/camel-ai/camel-langchain4j-tools/src/test/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolIT.java
+++ b/components/camel-ai/camel-langchain4j-tools/src/test/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolIT.java
@@ -26,8 +26,7 @@ import dev.langchain4j.model.chat.ChatModel;
import org.apache.camel.CamelContext;
import org.apache.camel.Exchange;
import org.apache.camel.builder.RouteBuilder;
-import org.apache.camel.test.infra.ollama.services.OllamaService;
-import org.apache.camel.test.infra.ollama.services.OllamaServiceFactory;
+import org.apache.camel.test.infra.openai.mock.OpenAIMock;
import org.apache.camel.test.junit5.CamelTestSupport;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.RepeatedTest;
@@ -41,13 +40,17 @@ public class LangChain4jToolIT extends CamelTestSupport {
private ChatModel chatModel;
@RegisterExtension
- static OllamaService OLLAMA = OllamaServiceFactory.createServiceWithConfiguration(() -> ToolsHelper.modelName());
+ static OpenAIMock openAIMock = new OpenAIMock().builder()
+ .when("What is the name of the user 1?\n")
+ .invokeTool("QueryUserByNumber")
+ .withParam("number", 1)
+ .build();
@Override
protected void setupResources() throws Exception {
super.setupResources();
- chatModel = ToolsHelper.createModel(OLLAMA.getEndpoint());
+ chatModel = ToolsHelper.createModel(openAIMock.getBaseUrl());
}
@Override
@@ -84,7 +87,7 @@ public class LangChain4jToolIT extends CamelTestSupport {
};
}
- @RepeatedTest(10)
+ @RepeatedTest(1)
public void testSimpleInvocation() {
List<ChatMessage> messages = new ArrayList<>();
messages.add(new SystemMessage(
diff --git a/components/camel-ai/camel-langchain4j-tools/src/test/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolMultipleCallsIT.java b/components/camel-ai/camel-langchain4j-tools/src/test/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolMultipleCallsIT.java
index b0d4e7ed591..44c36a6ceeb 100644
--- a/components/camel-ai/camel-langchain4j-tools/src/test/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolMultipleCallsIT.java
+++ b/components/camel-ai/camel-langchain4j-tools/src/test/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolMultipleCallsIT.java
@@ -26,8 +26,7 @@ import dev.langchain4j.model.chat.ChatModel;
import org.apache.camel.CamelContext;
import org.apache.camel.Exchange;
import org.apache.camel.builder.RouteBuilder;
-import org.apache.camel.test.infra.ollama.services.OllamaService;
-import org.apache.camel.test.infra.ollama.services.OllamaServiceFactory;
+import org.apache.camel.test.infra.openai.mock.OpenAIMock;
import org.apache.camel.test.junit5.CamelTestSupport;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.RepeatedTest;
@@ -40,7 +39,14 @@ public class LangChain4jToolMultipleCallsIT extends CamelTestSupport {
private ChatModel chatModel;
@RegisterExtension
- static OllamaService OLLAMA = OllamaServiceFactory.createServiceWithConfiguration(() -> ToolsHelper.modelName());
+ static OpenAIMock openAIMock = new OpenAIMock().builder()
+ .when("What is the weather in london ??\n")
+ .invokeTool("FindsTheLatitudeAndLongitudeOfAGivenCity")
+ .withParam("name", "London")
+ .andThenInvokeTool("ForecastsTheWeatherForTheGivenLatitudeAndLongitude")
+ .withParam("latitude", "51.50758961965397")
+ .withParam("longitude", "-0.13388057363742217")
+ .build();
private volatile boolean intermediateCalled = false;
private volatile boolean intermediateHasValidBody = false;
@@ -49,7 +55,7 @@ public class LangChain4jToolMultipleCallsIT extends CamelTestSupport {
protected void setupResources() throws Exception {
super.setupResources();
- chatModel = ToolsHelper.createModel(OLLAMA.getEndpoint());
+ chatModel = ToolsHelper.createModel(openAIMock.getBaseUrl());
}
@Override
@@ -76,12 +82,12 @@ public class LangChain4jToolMultipleCallsIT extends CamelTestSupport {
from("langchain4j-tools:test1?tags=geo&description=Forecasts the weather for the given latitude and longitude¶meter.latitude=integer¶meters.longitude=integer")
.log("intermediate body is: ${body}")
.process(exchange -> {
- intermediateCalled = true;
String body = exchange.getIn().getBody(String.class);
- if (body != null) {
- if (body.contains("51.50758961965397") && body.contains("-0.13388057363742217")) {
- intermediateHasValidBody = true;
- }
+ intermediateCalled = true;
+ if (exchange.getIn().getHeader("longitude", String.class).contains("-0.13388057363742217") &&
+ exchange.getIn().getHeader("latitude", String.class).contains("51.50758961965397") &&
+ body.contains("51.50758961965397") && body.contains("-0.13388057363742217")) {
+ intermediateHasValidBody = true;
}
})
.setBody(simple("""
diff --git a/components/camel-ai/camel-langchain4j-tools/src/test/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolMultipleGroupsIT.java b/components/camel-ai/camel-langchain4j-tools/src/test/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolMultipleGroupsIT.java
index 3ade30e5428..521c51f7e8f 100644
--- a/components/camel-ai/camel-langchain4j-tools/src/test/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolMultipleGroupsIT.java
+++ b/components/camel-ai/camel-langchain4j-tools/src/test/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolMultipleGroupsIT.java
@@ -26,8 +26,7 @@ import dev.langchain4j.model.chat.ChatModel;
import org.apache.camel.CamelContext;
import org.apache.camel.Exchange;
import org.apache.camel.builder.RouteBuilder;
-import org.apache.camel.test.infra.ollama.services.OllamaService;
-import org.apache.camel.test.infra.ollama.services.OllamaServiceFactory;
+import org.apache.camel.test.infra.openai.mock.OpenAIMock;
import org.apache.camel.test.junit5.CamelTestSupport;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.RepeatedTest;
@@ -41,13 +40,28 @@ public class LangChain4jToolMultipleGroupsIT extends CamelTestSupport {
private ChatModel chatModel;
@RegisterExtension
- static OllamaService OLLAMA = OllamaServiceFactory.createServiceWithConfiguration(() -> ToolsHelper.modelName());
+ static OpenAIMock openAIMock = new OpenAIMock().builder()
+ .when("What is the name of the user 1?\n")
+ .assertRequest(request -> {
+ // The tools has to be included in the request
+ Assertions.assertThat(request).contains(
+ "QueryUserDatabaseByNumber",
+ "DoesNotReallyDoAnything");
+
+ // The NOT included in the request
+ Assertions.assertThat(request).doesNotContain(
+ "QueryCompanyUserDatabaseByNumber",
+ "QuerySomethingelseUserDatabaseByNumber");
+ })
+ .invokeTool("{\"name\": \"pippo\"}")
+ .withParam("number", "1")
+ .build();
@Override
protected void setupResources() throws Exception {
super.setupResources();
- chatModel = ToolsHelper.createModel(OLLAMA.getEndpoint());
+ chatModel = ToolsHelper.createModel(openAIMock.getBaseUrl());
}
@Override
@@ -77,10 +91,10 @@ public class LangChain4jToolMultipleGroupsIT extends CamelTestSupport {
from("langchain4j-tools:test1?tags=user&description=Does not really do anything")
.setBody(constant("Hello World"));
- from("langchain4j-tools:test1?tags=companies&description=Query user database by number¶meter.number=integer")
+ from("langchain4j-tools:test1?tags=companies&description=Query company user database by number¶meter.number=integer")
.setBody(constant("Hello World"));
- from("langchain4j-tools:test1?tags=somethingelse&description=Query user database by number¶meter.number=integer")
+ from("langchain4j-tools:test1?tags=somethingelse&description=Query somethingelse user database by number¶meter.number=integer")
.setBody(constant("Hello World"));
}
};
diff --git a/components/camel-ai/camel-langchain4j-tools/src/test/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolMultipleMatchingGroupsIT.java b/components/camel-ai/camel-langchain4j-tools/src/test/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolMultipleMatchingGroupsIT.java
index a322db183ce..4be91e7e81c 100644
--- a/components/camel-ai/camel-langchain4j-tools/src/test/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolMultipleMatchingGroupsIT.java
+++ b/components/camel-ai/camel-langchain4j-tools/src/test/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolMultipleMatchingGroupsIT.java
@@ -26,8 +26,7 @@ import dev.langchain4j.model.chat.ChatModel;
import org.apache.camel.CamelContext;
import org.apache.camel.Exchange;
import org.apache.camel.builder.RouteBuilder;
-import org.apache.camel.test.infra.ollama.services.OllamaService;
-import org.apache.camel.test.infra.ollama.services.OllamaServiceFactory;
+import org.apache.camel.test.infra.openai.mock.OpenAIMock;
import org.apache.camel.test.junit5.CamelTestSupport;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -41,13 +40,28 @@ public class LangChain4jToolMultipleMatchingGroupsIT extends CamelTestSupport {
private ChatModel chatModel;
@RegisterExtension
- static OllamaService OLLAMA = OllamaServiceFactory.createServiceWithConfiguration(() -> ToolsHelper.modelName());
+ static OpenAIMock openAIMock = new OpenAIMock().builder()
+ .when("What is the name of the user 1 and what department he is part of?\n")
+ .assertRequest(request -> {
+ // The tools has to be included in the request
+ Assertions.assertThat(request).contains(
+ "QueryUserByNumberUsingTheInternalDB",
+ "QueryUserInformationByNumberUsingTheExternalDB");
+
+ // The NOT included in the request
+ Assertions.assertThat(request).doesNotContain(
+ "DoesNotReallyDoAnything",
+ "QueryCarInformationByPlate");
+ })
+ .invokeTool("{\"name\": \"Paul McFly\", \"department\": \"engineering\"}")
+ .withParam("number", 1)
+ .build();
@Override
protected void setupResources() throws Exception {
super.setupResources();
- chatModel = ToolsHelper.createModel(OLLAMA.getEndpoint());
+ chatModel = ToolsHelper.createModel(openAIMock.getBaseUrl());
}
@Override
diff --git a/components/camel-ai/camel-langchain4j-tools/src/test/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolNoToolsExistIT.java b/components/camel-ai/camel-langchain4j-tools/src/test/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolNoToolsExistIT.java
index 907c5b23867..26d92ed300c 100644
--- a/components/camel-ai/camel-langchain4j-tools/src/test/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolNoToolsExistIT.java
+++ b/components/camel-ai/camel-langchain4j-tools/src/test/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolNoToolsExistIT.java
@@ -26,8 +26,7 @@ import dev.langchain4j.model.chat.ChatModel;
import org.apache.camel.CamelContext;
import org.apache.camel.Exchange;
import org.apache.camel.builder.RouteBuilder;
-import org.apache.camel.test.infra.ollama.services.OllamaService;
-import org.apache.camel.test.infra.ollama.services.OllamaServiceFactory;
+import org.apache.camel.test.infra.openai.mock.OpenAIMock;
import org.apache.camel.test.junit5.CamelTestSupport;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.RepeatedTest;
@@ -40,13 +39,22 @@ public class LangChain4jToolNoToolsExistIT extends CamelTestSupport {
private ChatModel chatModel;
@RegisterExtension
- static OllamaService OLLAMA = OllamaServiceFactory.createServiceWithConfiguration(() -> ToolsHelper.modelName());
+ static OpenAIMock openAIMock = new OpenAIMock().builder()
+ .when("How can you help? DO NOT CALL TOOLS.\n")
+ .assertRequest(request -> {
+ // The tools should not be included in the request
+ Assertions.assertThat(request).doesNotContain(
+ "QueryUserDatabaseByNumber",
+ "DoesNotDoAnything,Really");
+ })
+ .replyWith("No tool is invoked")
+ .build();
@Override
protected void setupResources() throws Exception {
super.setupResources();
- chatModel = ToolsHelper.createModel(OLLAMA.getEndpoint());
+ chatModel = ToolsHelper.createModel(openAIMock.getBaseUrl());
}
@Override
@@ -93,7 +101,7 @@ public class LangChain4jToolNoToolsExistIT extends CamelTestSupport {
"""));
messages.add(new UserMessage("""
How can you help? DO NOT CALL TOOLS.
- """));
+ """));
Exchange message = fluentTemplate.to("direct:test").withBody(messages).request(Exchange.class);
diff --git a/components/camel-ai/camel-langchain4j-tools/src/test/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolNoToolsToBeCalledIT.java b/components/camel-ai/camel-langchain4j-tools/src/test/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolNoToolsToBeCalledIT.java
index 2728d35479e..df84f6b459f 100644
--- a/components/camel-ai/camel-langchain4j-tools/src/test/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolNoToolsToBeCalledIT.java
+++ b/components/camel-ai/camel-langchain4j-tools/src/test/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolNoToolsToBeCalledIT.java
@@ -26,8 +26,7 @@ import dev.langchain4j.model.chat.ChatModel;
import org.apache.camel.CamelContext;
import org.apache.camel.Exchange;
import org.apache.camel.builder.RouteBuilder;
-import org.apache.camel.test.infra.ollama.services.OllamaService;
-import org.apache.camel.test.infra.ollama.services.OllamaServiceFactory;
+import org.apache.camel.test.infra.openai.mock.OpenAIMock;
import org.apache.camel.test.junit5.CamelTestSupport;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.RepeatedTest;
@@ -40,13 +39,22 @@ public class LangChain4jToolNoToolsToBeCalledIT extends CamelTestSupport {
private ChatModel chatModel;
@RegisterExtension
- static OllamaService OLLAMA = OllamaServiceFactory.createServiceWithConfiguration(() -> ToolsHelper.modelName());
+ static OpenAIMock openAIMock = new OpenAIMock().builder()
+ .when("How can you help? DO NOT CALL TOOLS.\n")
+ .assertRequest(request -> {
+ // The tools has to be included in the request
+ Assertions.assertThat(request).contains(
+ "QueryUserDatabaseByNumber",
+ "QueryUserDatabaseByNickname");
+ })
+ .replyWith("No tool is invoked")
+ .build();
@Override
protected void setupResources() throws Exception {
super.setupResources();
- chatModel = ToolsHelper.createModel(OLLAMA.getEndpoint());
+ chatModel = ToolsHelper.createModel(openAIMock.getBaseUrl());
}
@Override
@@ -93,7 +101,7 @@ public class LangChain4jToolNoToolsToBeCalledIT extends CamelTestSupport {
"""));
messages.add(new UserMessage("""
How can you help? DO NOT CALL TOOLS.
- """));
+ """));
Exchange message = fluentTemplate.to("direct:test").withBody(messages).request(Exchange.class);
diff --git a/test-infra/camel-test-infra-all/pom.xml b/test-infra/camel-test-infra-all/pom.xml
index 6a34d9b5e7c..70826527683 100644
--- a/test-infra/camel-test-infra-all/pom.xml
+++ b/test-infra/camel-test-infra-all/pom.xml
@@ -181,6 +181,11 @@
<artifactId>camel-test-infra-chatscript</artifactId>
<version>${project.version}</version>
</dependency>
+ <dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-test-infra-openai-mock</artifactId>
+ <version>${project.version}</version>
+ </dependency>
<dependency>
<groupId>org.apache.camel</groupId>
<artifactId>camel-test-infra-openldap</artifactId>
@@ -437,6 +442,12 @@
<artifactId>camel-test-infra-chatscript</artifactId>
</dependency>
</fileSet>
+ <fileSet>
+ <dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-test-infra-openai-mock</artifactId>
+ </dependency>
+ </fileSet>
<fileSet>
<dependency>
<groupId>org.apache.camel</groupId>
diff --git a/test-infra/camel-test-infra-openai-mock/README.md b/test-infra/camel-test-infra-openai-mock/README.md
new file mode 100644
index 00000000000..b566a1a5aae
--- /dev/null
+++ b/test-infra/camel-test-infra-openai-mock/README.md
@@ -0,0 +1,66 @@
+# OpenAI Mock
+
+OpenAI Mock is a lightweight Java library for mocking OpenAI's chat completions API (`/v1/chat/completions`) within your unit tests. It allows you to simulate responses from an LLM accessed through OpenAI API without making actual network calls, making your tests faster, more reliable, and independent of external services.
+
+It uses the native Java `HttpServer` to run a local web server that intercepts requests to the OpenAI API and returns predefined responses.
+
+## Usage
+
+Here's an example of how to use `OpenAIMock` in your JUnit 5 tests:
+
+```java
+import com.example.OpenAIMock;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+// ... other imports
+
+public class MyOpenAIApiTest {
+
+ @RegisterExtension
+ public OpenAIMock openAIMock = new OpenAIMock().builder()
+ .when("any sentence")
+ .invokeTool("toolName")
+ .withParam("param1", "value1")
+ .end()
+ .when("another sentence")
+ .replyWith("hello World")
+ .end()
+ .when("multiple sequential tools")
+ .invokeTool("tool3")
+ .withParam("p3", "v3")
+ .withParam("p4", "v5")
+ .andThenInvokeTool("tool 4")
+ .withParam("p4", "v4")
+ .when("multiple tools")
+ .invokeTool("tool1")
+ .withParam("p1", "v1")
+ .andInvokeTool("tool2")
+ .withParam("p2", "v2")
+ .end()
+ .when("custom response")
+ .thenRespondWith((exchange, input) -> {
+ try {
+ String responseBody = "Custom response for: " + input;
+ byte[] responseBytes = responseBody.getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().set("Content-Type", "text/plain");
+ exchange.sendResponseHeaders(200, responseBytes.length);
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(responseBytes);
+ }
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ return null;
+ })
+ .build();
+
+ @Test
+ public void testMyApi() throws Exception {
+ // Your code that calls the OpenAI API
+ // Make sure your HTTP client is configured to use openAIMock.getBaseUrl()
+ // For example:
+ String baseUrl = openAIMock.getBaseUrl();
+ // ... rest of your test code
+ }
+}
+```
\ No newline at end of file
diff --git a/test-infra/camel-test-infra-openai-mock/pom.xml b/test-infra/camel-test-infra-openai-mock/pom.xml
new file mode 100644
index 00000000000..c0c7cf7b7a2
--- /dev/null
+++ b/test-infra/camel-test-infra-openai-mock/pom.xml
@@ -0,0 +1,69 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <parent>
+ <artifactId>camel-test-infra-parent</artifactId>
+ <groupId>org.apache.camel</groupId>
+ <relativePath>../camel-test-infra-parent/pom.xml</relativePath>
+ <version>4.14.0-SNAPSHOT</version>
+ </parent>
+ <modelVersion>4.0.0</modelVersion>
+
+ <artifactId>camel-test-infra-openai-mock</artifactId>
+ <name>Camel :: Test Infra :: OpenAI Mock</name>
+
+ <properties>
+ <assembly.skipAssembly>false</assembly.skipAssembly>
+ </properties>
+
+ <dependencies>
+ <dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-test-infra-common</artifactId>
+ <version>${project.version}</version>
+ <type>test-jar</type>
+ </dependency>
+
+ <dependency>
+ <groupId>org.junit.jupiter</groupId>
+ <artifactId>junit-jupiter-api</artifactId>
+ <version>${junit-jupiter-version}</version>
+ </dependency>
+
+ <dependency>
+ <groupId>com.fasterxml.jackson.core</groupId>
+ <artifactId>jackson-databind</artifactId>
+ <version>${jackson2-version}</version>
+ </dependency>
+ <dependency>
+ <groupId>org.slf4j</groupId>
+ <artifactId>slf4j-api</artifactId>
+ <version>${slf4j-api-version}</version>
+ </dependency>
+
+ <dependency>
+ <groupId>org.junit.jupiter</groupId>
+ <artifactId>junit-jupiter-engine</artifactId>
+ <version>${junit-jupiter-version}</version>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+
+</project>
diff --git a/test-infra/camel-test-infra-openai-mock/src/main/java/org/apache/camel/test/infra/openai/mock/MockExpectation.java b/test-infra/camel-test-infra-openai-mock/src/main/java/org/apache/camel/test/infra/openai/mock/MockExpectation.java
new file mode 100644
index 00000000000..e84c936f081
--- /dev/null
+++ b/test-infra/camel-test-infra-openai-mock/src/main/java/org/apache/camel/test/infra/openai/mock/MockExpectation.java
@@ -0,0 +1,121 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.test.infra.openai.mock;
+
+import java.util.function.BiFunction;
+import java.util.function.Consumer;
+
+import com.sun.net.httpserver.HttpExchange;
+
+/**
+ * Represents a mock expectation for a specific user input. Contains the expected input, tool execution sequence, and
+ * response configuration.
+ */
+public class MockExpectation {
+ private final String expectedInput;
+ private final ToolExecutionSequence toolSequence;
+ private String expectedResponse;
+ private BiFunction<HttpExchange, String, String> customResponseFunction;
+ private Consumer<String> requestAssertion;
+
+ public MockExpectation(String expectedInput) {
+ this.expectedInput = expectedInput;
+ this.toolSequence = new ToolExecutionSequence();
+ }
+
+ // Getters
+ public String getExpectedInput() {
+ return expectedInput;
+ }
+
+ public String getExpectedResponse() {
+ return expectedResponse;
+ }
+
+ public BiFunction<HttpExchange, String, String> getCustomResponseFunction() {
+ return customResponseFunction;
+ }
+
+ public Consumer<String> getRequestAssertion() {
+ return requestAssertion;
+ }
+
+ public ToolExecutionSequence getToolSequence() {
+ return toolSequence;
+ }
+
+ // Setters
+ public void setExpectedResponse(String expectedResponse) {
+ this.expectedResponse = expectedResponse;
+ }
+
+ public void setCustomResponseFunction(BiFunction<HttpExchange, String, String> customResponseFunction) {
+ this.customResponseFunction = customResponseFunction;
+ }
+
+ public void setRequestAssertion(Consumer<String> requestAssertion) {
+ this.requestAssertion = requestAssertion;
+ }
+
+ // Tool sequence delegation methods
+ public void addToolExecutionStep(ToolExecutionStep step) {
+ toolSequence.addStep(step);
+ }
+
+ public ToolExecutionStep getCurrentToolStep() {
+ return toolSequence.getCurrentStep();
+ }
+
+ public void advanceToNextToolStep() {
+ toolSequence.advanceToNextStep();
+ }
+
+ public boolean hasMoreToolSteps() {
+ return toolSequence.hasMoreSteps();
+ }
+
+ public boolean isInToolSequence() {
+ return toolSequence.isInProgress();
+ }
+
+ public void resetToolSequence() {
+ toolSequence.reset();
+ }
+
+ // Response type determination
+ public MockResponseType getResponseType() {
+ if (customResponseFunction != null) {
+ return MockResponseType.CUSTOM_FUNCTION;
+ }
+
+ if (!toolSequence.isEmpty() && toolSequence.hasCurrentStep()) {
+ return MockResponseType.TOOL_CALLS;
+ }
+
+ return MockResponseType.SIMPLE_TEXT;
+ }
+
+ public boolean matches(String input) {
+ return expectedInput.equals(input);
+ }
+
+ @Override
+ public String toString() {
+ return String.format("MockExpectation{input='%s', response='%s', toolSteps=%d}",
+ expectedInput, expectedResponse, toolSequence.getTotalSteps());
+ }
+}
diff --git a/test-infra/camel-test-infra-openai-mock/src/main/java/org/apache/camel/test/infra/openai/mock/MockResponseType.java b/test-infra/camel-test-infra-openai-mock/src/main/java/org/apache/camel/test/infra/openai/mock/MockResponseType.java
new file mode 100644
index 00000000000..2cd0d0b0df8
--- /dev/null
+++ b/test-infra/camel-test-infra-openai-mock/src/main/java/org/apache/camel/test/infra/openai/mock/MockResponseType.java
@@ -0,0 +1,27 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.test.infra.openai.mock;
+
+/**
+ * Enum representing different types of mock responses.
+ */
+public enum MockResponseType {
+ SIMPLE_TEXT,
+ TOOL_CALLS,
+ CUSTOM_FUNCTION,
+ FINAL_TOOL_RESPONSE
+}
diff --git a/test-infra/camel-test-infra-openai-mock/src/main/java/org/apache/camel/test/infra/openai/mock/OpenAIMock.java b/test-infra/camel-test-infra-openai-mock/src/main/java/org/apache/camel/test/infra/openai/mock/OpenAIMock.java
new file mode 100644
index 00000000000..4f301b89b0c
--- /dev/null
+++ b/test-infra/camel-test-infra-openai-mock/src/main/java/org/apache/camel/test/infra/openai/mock/OpenAIMock.java
@@ -0,0 +1,83 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.test.infra.openai.mock;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sun.net.httpserver.HttpServer;
+import org.junit.jupiter.api.extension.AfterEachCallback;
+import org.junit.jupiter.api.extension.BeforeEachCallback;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Main mock server for OpenAI API testing. Implements JUnit 5 extension lifecycle methods.
+ */
+public class OpenAIMock implements BeforeEachCallback, AfterEachCallback {
+ private static final Logger LOG = LoggerFactory.getLogger(OpenAIMock.class);
+
+ private HttpServer server;
+ private final List<MockExpectation> expectations;
+ private final OpenAIMockBuilder builder;
+ private final ObjectMapper objectMapper;
+ private ExecutorService executor;
+
+ public OpenAIMock() {
+ this.expectations = new ArrayList<>();
+ this.objectMapper = new ObjectMapper();
+ this.builder = new OpenAIMockBuilder(this, this.expectations);
+ }
+
+ public OpenAIMockBuilder builder() {
+ return this.builder;
+ }
+
+ public String getBaseUrl() {
+ if (server == null) {
+ throw new IllegalStateException("Mock server not started. Call beforeEach() first.");
+ }
+ return "http://localhost:" + server.getAddress().getPort();
+ }
+
+ @Override
+ public void beforeEach(ExtensionContext context) throws Exception {
+ server = HttpServer.create(new InetSocketAddress(0), 0);
+ server.createContext("/", new OpenAIMockServerHandler(expectations, objectMapper));
+
+ executor = Executors.newSingleThreadExecutor();
+ server.setExecutor(executor);
+ server.start();
+
+ LOG.info("Mock web server started on {}", server.getAddress());
+ }
+
+ @Override
+ public void afterEach(ExtensionContext context) throws IOException {
+ if (server != null) {
+ server.stop(0);
+ executor.shutdownNow();
+ LOG.info("Mock web server shut down");
+ }
+ }
+}
diff --git a/test-infra/camel-test-infra-openai-mock/src/main/java/org/apache/camel/test/infra/openai/mock/OpenAIMockBuilder.java b/test-infra/camel-test-infra-openai-mock/src/main/java/org/apache/camel/test/infra/openai/mock/OpenAIMockBuilder.java
new file mode 100644
index 00000000000..b52dc8a16cc
--- /dev/null
+++ b/test-infra/camel-test-infra-openai-mock/src/main/java/org/apache/camel/test/infra/openai/mock/OpenAIMockBuilder.java
@@ -0,0 +1,149 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.test.infra.openai.mock;
+
+import java.util.List;
+import java.util.function.BiFunction;
+import java.util.function.Consumer;
+
+import com.sun.net.httpserver.HttpExchange;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Fluent builder for creating OpenAI mock expectations.
+ */
+public class OpenAIMockBuilder {
+ private static final Logger log = LoggerFactory.getLogger(OpenAIMockBuilder.class);
+
+ private final OpenAIMock mock;
+ private final List<MockExpectation> expectations;
+ private MockExpectation currentExpectation;
+
+ public OpenAIMockBuilder(OpenAIMock mock, List<MockExpectation> expectations) {
+ this.mock = mock;
+ this.expectations = expectations;
+ }
+
+ public OpenAIMockBuilder when(String expectedInput) {
+ log.debug("Setting up expectation for input: {}", expectedInput);
+ currentExpectation = new MockExpectation(expectedInput);
+ return this;
+ }
+
+ public OpenAIMockBuilder replyWith(String expectedResponse) {
+ validateCurrentExpectation("replyWith()");
+ log.debug("Setting expected response: {}", expectedResponse);
+ currentExpectation.setExpectedResponse(expectedResponse);
+ return this;
+ }
+
+ public OpenAIMockBuilder invokeTool(String toolName) {
+ validateCurrentExpectation("invokeTool()");
+ log.debug("Adding new tool execution step with tool: {}", toolName);
+
+ ToolExecutionStep newStep = new ToolExecutionStep();
+ newStep.addToolCall(new ToolCallDefinition(toolName));
+ currentExpectation.addToolExecutionStep(newStep);
+
+ return this;
+ }
+
+ public OpenAIMockBuilder andInvokeTool(String toolName) {
+ validateCurrentExpectation("andInvokeTool()");
+ validateHasToolSteps("andInvokeTool()");
+
+ log.debug("Adding parallel tool to current step: {}", toolName);
+ ToolExecutionStep currentStep = currentExpectation.getCurrentToolStep();
+ currentStep.addToolCall(new ToolCallDefinition(toolName));
+
+ return this;
+ }
+
+ public OpenAIMockBuilder withParam(String key, Object value) {
+ validateCurrentExpectation("withParam()");
+ validateHasToolSteps("withParam()");
+
+ ToolExecutionStep currentStep = currentExpectation.getCurrentToolStep();
+ if (currentStep.isEmpty()) {
+ throw new IllegalStateException("No tool calls in current step to add parameters to");
+ }
+
+ ToolCallDefinition lastTool = currentStep.getLastToolCall();
+ log.debug("Adding parameter {} = {} to tool: {}", key, value, lastTool.getName());
+ lastTool.addArgument(key, value);
+
+ return this;
+ }
+
+ public OpenAIMockBuilder thenRespondWith(BiFunction<HttpExchange, String, String> responseFunction) {
+ validateCurrentExpectation("thenRespondWith()");
+ log.debug("Setting custom response function");
+ currentExpectation.setCustomResponseFunction(responseFunction);
+ return this;
+ }
+
+ public OpenAIMockBuilder assertRequest(Consumer<String> requestAssertion) {
+ validateCurrentExpectation("assertRequest()");
+ log.debug("Setting request assertion");
+ currentExpectation.setRequestAssertion(requestAssertion);
+ return this;
+ }
+
+ public OpenAIMockBuilder andThenInvokeTool(String toolName) {
+ validateCurrentExpectation("andThenInvokeTool()");
+ validateHasToolSteps("andThenInvokeTool()");
+
+ log.debug("Creating new sequential step with tool: {}", toolName);
+ ToolExecutionStep newStep = new ToolExecutionStep();
+ newStep.addToolCall(new ToolCallDefinition(toolName));
+ currentExpectation.addToolExecutionStep(newStep);
+ currentExpectation.advanceToNextToolStep();
+
+ return this;
+ }
+
+ public OpenAIMockBuilder end() {
+ validateCurrentExpectation("end()");
+ log.debug("Finalizing expectation for input: {}", currentExpectation.getExpectedInput());
+ expectations.add(currentExpectation);
+ currentExpectation = null;
+ return this;
+ }
+
+ public OpenAIMock build() {
+ if (currentExpectation != null) {
+ log.debug("Auto-finalizing current expectation during build");
+ expectations.add(currentExpectation);
+ currentExpectation = null;
+ }
+ log.info("Built OpenAIMock with {} expectations", expectations.size());
+ return mock;
+ }
+
+ private void validateCurrentExpectation(String methodName) {
+ if (currentExpectation == null) {
+ throw new IllegalStateException("Call when() before " + methodName);
+ }
+ }
+
+ private void validateHasToolSteps(String methodName) {
+ if (currentExpectation.getToolSequence().isEmpty()) {
+ throw new IllegalStateException("Call invokeTool() before " + methodName);
+ }
+ }
+}
diff --git a/test-infra/camel-test-infra-openai-mock/src/main/java/org/apache/camel/test/infra/openai/mock/OpenAIMockServerHandler.java b/test-infra/camel-test-infra-openai-mock/src/main/java/org/apache/camel/test/infra/openai/mock/OpenAIMockServerHandler.java
new file mode 100644
index 00000000000..b7808aa71b3
--- /dev/null
+++ b/test-infra/camel-test-infra-openai-mock/src/main/java/org/apache/camel/test/infra/openai/mock/OpenAIMockServerHandler.java
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.test.infra.openai.mock;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.List;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpHandler;
+
+/**
+ * Dispatcher that routes incoming requests to the appropriate request handler.
+ */
+public class OpenAIMockServerHandler implements HttpHandler {
+ private final RequestHandler requestHandler;
+
+ public OpenAIMockServerHandler(List<MockExpectation> expectations, ObjectMapper objectMapper) {
+ this.requestHandler = new RequestHandler(expectations, objectMapper);
+ }
+
+ @Override
+ public void handle(HttpExchange exchange) throws IOException {
+ if ("POST".equalsIgnoreCase(exchange.getRequestMethod())) {
+ try {
+ byte[] response = requestHandler.handleRequest(exchange).getBytes();
+ if (exchange.getResponseCode() == -1) {
+ exchange.sendResponseHeaders(200, response.length);
+ }
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(response);
+ }
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+ }
+}
diff --git a/test-infra/camel-test-infra-openai-mock/src/main/java/org/apache/camel/test/infra/openai/mock/RequestContext.java b/test-infra/camel-test-infra-openai-mock/src/main/java/org/apache/camel/test/infra/openai/mock/RequestContext.java
new file mode 100644
index 00000000000..ee191ab9f5e
--- /dev/null
+++ b/test-infra/camel-test-infra-openai-mock/src/main/java/org/apache/camel/test/infra/openai/mock/RequestContext.java
@@ -0,0 +1,68 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.test.infra.openai.mock;
+
+import com.fasterxml.jackson.databind.JsonNode;
+
+/**
+ * Context object that parses and provides easy access to request information.
+ */
+public class RequestContext {
+ private final JsonNode rootNode;
+ private final JsonNode messagesNode;
+
+ public RequestContext(JsonNode rootNode) {
+ this.rootNode = rootNode;
+ this.messagesNode = rootNode.path("messages");
+ }
+
+ public boolean hasToolRole() {
+ if (!messagesNode.isArray()) {
+ return false;
+ }
+
+ for (JsonNode messageNode : messagesNode) {
+ String role = messageNode.path("role").asText();
+ if ("tool".equals(role)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public String getFirstUserMessage() {
+ if (!messagesNode.isArray()) {
+ return null;
+ }
+
+ for (JsonNode messageNode : messagesNode) {
+ String role = messageNode.path("role").asText();
+ if ("user".equals(role)) {
+ return messageNode.path("content").asText();
+ }
+ }
+ return null;
+ }
+
+ public JsonNode getMessagesNode() {
+ return messagesNode;
+ }
+
+ public JsonNode getRootNode() {
+ return rootNode;
+ }
+}
diff --git a/test-infra/camel-test-infra-openai-mock/src/main/java/org/apache/camel/test/infra/openai/mock/RequestHandler.java b/test-infra/camel-test-infra-openai-mock/src/main/java/org/apache/camel/test/infra/openai/mock/RequestHandler.java
new file mode 100644
index 00000000000..b10c5b1bd1b
--- /dev/null
+++ b/test-infra/camel-test-infra-openai-mock/src/main/java/org/apache/camel/test/infra/openai/mock/RequestHandler.java
@@ -0,0 +1,141 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.test.infra.openai.mock;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sun.net.httpserver.HttpExchange;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Handles incoming requests and matches them to appropriate mock expectations.
+ */
+public class RequestHandler {
+ private static final Logger LOG = LoggerFactory.getLogger(RequestHandler.class);
+
+ private final List<MockExpectation> expectations;
+ private final ResponseBuilder responseBuilder;
+ private final ObjectMapper objectMapper;
+
+ public RequestHandler(List<MockExpectation> expectations, ObjectMapper objectMapper) {
+ this.expectations = expectations;
+ this.objectMapper = objectMapper;
+ this.responseBuilder = new ResponseBuilder(objectMapper);
+ }
+
+ public String handleRequest(HttpExchange exchange) throws IOException {
+ try {
+ String requestBody = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
+ LOG.debug("Processing request: {}", requestBody);
+
+ JsonNode rootNode = objectMapper.readTree(requestBody);
+ RequestContext context = new RequestContext(rootNode);
+
+ if (context.hasToolRole()) {
+ return handleToolSequenceResponse(context, exchange);
+ } else {
+ return handleUserInput(requestBody, context, exchange);
+ }
+ } catch (Exception e) {
+ String errorMessage = "Error processing request: " + e.getMessage();
+ LOG.error(errorMessage);
+ return responseBuilder.createErrorResponse(500, errorMessage, exchange);
+ }
+ }
+
+ private String handleToolSequenceResponse(RequestContext context, HttpExchange exchange) throws Exception {
+ String originalInput = context.getFirstUserMessage();
+ if (originalInput == null) {
+ LOG.warn("Could not find original user input in message history");
+ return responseBuilder.createErrorResponse(400, "Original user input not found", exchange);
+ }
+
+ MockExpectation expectation = findExpectationByInput(originalInput);
+ if (expectation == null) {
+ LOG.warn("No matching expectation found for tool sequence with input: {}", originalInput);
+ return responseBuilder.createErrorResponse(404, "No matching expectation found for tool sequence", exchange);
+ }
+
+ expectation.advanceToNextToolStep();
+
+ if (expectation.hasMoreToolSteps()) {
+ LOG.debug("Executing next tool step for expectation: {}", originalInput);
+ return createToolCallResponse(expectation);
+ } else {
+ LOG.debug("Tool sequence completed for expectation: {}", originalInput);
+ return responseBuilder.createFinalToolResponse(context.getMessagesNode(), expectation.getExpectedResponse());
+ }
+ }
+
+ private String handleUserInput(String requestBody, RequestContext context, HttpExchange exchange) throws Exception {
+ String userInput = context.getFirstUserMessage();
+ if (userInput == null) {
+ LOG.warn("User message content not found in request");
+ throw new IllegalArgumentException("User message content not found in request");
+ }
+
+ MockExpectation expectation = findExpectationByInput(userInput);
+
+ expectation.resetToolSequence();
+
+ // Execute request assertion if present
+ if (expectation.getRequestAssertion() != null) {
+ expectation.getRequestAssertion().accept(requestBody);
+ }
+
+ return createResponse(expectation, userInput, exchange);
+ }
+
+ private String createResponse(MockExpectation expectation, String userInput, HttpExchange exchange)
+ throws Exception {
+ MockResponseType responseType = expectation.getResponseType();
+
+ switch (responseType) {
+ case CUSTOM_FUNCTION:
+ LOG.debug("Using custom response function");
+ return expectation.getCustomResponseFunction().apply(exchange, userInput);
+
+ case TOOL_CALLS:
+ return createToolCallResponse(expectation);
+
+ case SIMPLE_TEXT:
+ default:
+ LOG.debug("Creating simple text response");
+ return responseBuilder.createSimpleTextResponse(expectation.getExpectedResponse());
+ }
+ }
+
+ private String createToolCallResponse(MockExpectation expectation) throws Exception {
+ ToolExecutionStep currentStep = expectation.getCurrentToolStep();
+ return responseBuilder.createToolCallResponse(
+ expectation.getExpectedResponse(),
+ currentStep.getToolCalls());
+ }
+
+ private MockExpectation findExpectationByInput(String input) {
+ return expectations.stream()
+ .filter(expectation -> expectation.matches(input))
+ .findFirst()
+ .orElseThrow(() -> new IllegalStateException(
+ String.format("No matching mock expectation found for input: %s", input)));
+ }
+}
diff --git a/test-infra/camel-test-infra-openai-mock/src/main/java/org/apache/camel/test/infra/openai/mock/ResponseBuilder.java b/test-infra/camel-test-infra-openai-mock/src/main/java/org/apache/camel/test/infra/openai/mock/ResponseBuilder.java
new file mode 100644
index 00000000000..f1618c316fd
--- /dev/null
+++ b/test-infra/camel-test-infra-openai-mock/src/main/java/org/apache/camel/test/infra/openai/mock/ResponseBuilder.java
@@ -0,0 +1,143 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.test.infra.openai.mock;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.stream.StreamSupport;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sun.net.httpserver.HttpExchange;
+
+/**
+ * Builder class for creating different types of OpenAI API mock responses.
+ */
+public class ResponseBuilder {
+ private final ObjectMapper objectMapper;
+
+ public ResponseBuilder(ObjectMapper objectMapper) {
+ this.objectMapper = objectMapper;
+ }
+
+ public String createSimpleTextResponse(String content) throws Exception {
+ Map<String, Object> responseMessage = createBaseMessage();
+ responseMessage.put("content", content);
+
+ Map<String, Object> choice = createBaseChoice("stop", responseMessage);
+ Map<String, Object> chatCompletion = createBaseChatCompletion(choice);
+
+ return objectMapper.writeValueAsString(chatCompletion);
+ }
+
+ public String createToolCallResponse(String content, List<ToolCallDefinition> toolCalls) throws Exception {
+ Map<String, Object> responseMessage = createBaseMessage();
+ responseMessage.put("content", content);
+ responseMessage.put("tool_calls", buildToolCallsList(toolCalls));
+
+ Map<String, Object> choice = createBaseChoice("tool_calls", responseMessage);
+ Map<String, Object> chatCompletion = createBaseChatCompletion(choice);
+
+ return objectMapper.writeValueAsString(chatCompletion);
+ }
+
+ public String createFinalToolResponse(JsonNode messagesNode, String fallbackContent) throws Exception {
+ Map<String, Object> responseMessage = createBaseMessage();
+
+ String content = extractLastToolContent(messagesNode)
+ .orElse(fallbackContent != null ? fallbackContent : "All tools processed");
+ responseMessage.put("content", content);
+
+ Map<String, Object> choice = createBaseChoice("stop", responseMessage);
+ Map<String, Object> chatCompletion = createBaseChatCompletion(choice);
+ chatCompletion.put("history", messagesNode);
+
+ return objectMapper.writeValueAsString(chatCompletion);
+ }
+
+ public String createErrorResponse(int statusCode, String errorMessage, HttpExchange exchange) {
+ String jsonErrorMessage = String.format("{\"error\": \"%s\"}", errorMessage);
+
+ try {
+ exchange.sendResponseHeaders(statusCode, jsonErrorMessage.length());
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+
+ return String.format("{\"error\": \"%s\"}", errorMessage);
+ }
+
+ private Map<String, Object> createBaseMessage() {
+ Map<String, Object> message = new HashMap<>();
+ message.put("role", "assistant");
+ message.put("refusal", null);
+ return message;
+ }
+
+ private Map<String, Object> createBaseChoice(String finishReason, Map<String, Object> message) {
+ Map<String, Object> choice = new HashMap<>();
+ choice.put("finish_reason", finishReason);
+ choice.put("index", 0);
+ choice.put("message", message);
+ return choice;
+ }
+
+ private Map<String, Object> createBaseChatCompletion(Map<String, Object> choice) {
+ Map<String, Object> chatCompletion = new HashMap<>();
+ chatCompletion.put("id", UUID.randomUUID().toString());
+ chatCompletion.put("choices", Collections.singletonList(choice));
+ chatCompletion.put("created", System.currentTimeMillis() / 1000L);
+ chatCompletion.put("model", "openai-mock");
+ chatCompletion.put("object", "chat.completion");
+ return chatCompletion;
+ }
+
+ private List<Map<String, Object>> buildToolCallsList(List<ToolCallDefinition> toolCalls) throws Exception {
+ List<Map<String, Object>> toolCallsList = new ArrayList<>();
+
+ for (ToolCallDefinition toolCall : toolCalls) {
+ String argumentsJson = objectMapper.writeValueAsString(toolCall.getArguments());
+
+ Map<String, Object> functionObject = new HashMap<>();
+ functionObject.put("name", toolCall.getName());
+ functionObject.put("arguments", argumentsJson);
+
+ Map<String, Object> toolCallItem = new HashMap<>();
+ toolCallItem.put("id", UUID.randomUUID().toString());
+ toolCallItem.put("type", "function");
+ toolCallItem.put("function", functionObject);
+
+ toolCallsList.add(toolCallItem);
+ }
+
+ return toolCallsList;
+ }
+
+ private Optional<String> extractLastToolContent(JsonNode messagesNode) {
+ return StreamSupport.stream(messagesNode.spliterator(), false)
+ .filter(entry -> entry.has("role") && "tool".equals(entry.get("role").asText()))
+ .reduce((first, second) -> second) // Get the last one
+ .map(entry -> entry.get("content"))
+ .map(JsonNode::asText);
+ }
+}
diff --git a/test-infra/camel-test-infra-openai-mock/src/main/java/org/apache/camel/test/infra/openai/mock/ToolCallDefinition.java b/test-infra/camel-test-infra-openai-mock/src/main/java/org/apache/camel/test/infra/openai/mock/ToolCallDefinition.java
new file mode 100644
index 00000000000..35f6363c0dd
--- /dev/null
+++ b/test-infra/camel-test-infra-openai-mock/src/main/java/org/apache/camel/test/infra/openai/mock/ToolCallDefinition.java
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.test.infra.openai.mock;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Represents a tool call with its name and parameters.
+ */
+public class ToolCallDefinition {
+ private final String name;
+ private final Map<String, Object> arguments;
+
+ public ToolCallDefinition(String name) {
+ this.name = Objects.requireNonNull(name, "Tool name cannot be null");
+ this.arguments = new HashMap<>();
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public Map<String, Object> getArguments() {
+ return new HashMap<>(arguments); // Return defensive copy
+ }
+
+ public void addArgument(String key, Object value) {
+ arguments.put(key, value);
+ }
+
+ @Override
+ public String toString() {
+ return String.format("ToolCall{name='%s', arguments=%s}", name, arguments);
+ }
+}
diff --git a/test-infra/camel-test-infra-openai-mock/src/main/java/org/apache/camel/test/infra/openai/mock/ToolExecutionSequence.java b/test-infra/camel-test-infra-openai-mock/src/main/java/org/apache/camel/test/infra/openai/mock/ToolExecutionSequence.java
new file mode 100644
index 00000000000..cd808108aae
--- /dev/null
+++ b/test-infra/camel-test-infra-openai-mock/src/main/java/org/apache/camel/test/infra/openai/mock/ToolExecutionSequence.java
@@ -0,0 +1,76 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.test.infra.openai.mock;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Manages a sequence of tool execution steps. Each step can contain multiple parallel tool calls.
+ */
+public class ToolExecutionSequence {
+ private final List<ToolExecutionStep> steps;
+ private int currentStepIndex;
+
+ public ToolExecutionSequence() {
+ this.steps = new ArrayList<>();
+ this.currentStepIndex = 0;
+ }
+
+ public void addStep(ToolExecutionStep step) {
+ steps.add(step);
+ }
+
+ public ToolExecutionStep getCurrentStep() {
+ if (hasCurrentStep()) {
+ return steps.get(currentStepIndex);
+ }
+ return new ToolExecutionStep(); // Return empty step if no current step
+ }
+
+ public void advanceToNextStep() {
+ currentStepIndex++;
+ }
+
+ public boolean hasCurrentStep() {
+ return currentStepIndex < steps.size();
+ }
+
+ public boolean hasMoreSteps() {
+ return currentStepIndex < steps.size();
+ }
+
+ public boolean isInProgress() {
+ return currentStepIndex > 0 && currentStepIndex < steps.size();
+ }
+
+ public void reset() {
+ currentStepIndex = 0;
+ }
+
+ public boolean isEmpty() {
+ return steps.isEmpty();
+ }
+
+ public int getTotalSteps() {
+ return steps.size();
+ }
+
+ public int getCurrentStepIndex() {
+ return currentStepIndex;
+ }
+}
diff --git a/test-infra/camel-test-infra-openai-mock/src/main/java/org/apache/camel/test/infra/openai/mock/ToolExecutionStep.java b/test-infra/camel-test-infra-openai-mock/src/main/java/org/apache/camel/test/infra/openai/mock/ToolExecutionStep.java
new file mode 100644
index 00000000000..f4afe6aef6b
--- /dev/null
+++ b/test-infra/camel-test-infra-openai-mock/src/main/java/org/apache/camel/test/infra/openai/mock/ToolExecutionStep.java
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.test.infra.openai.mock;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Represents a single step in a tool execution sequence. A step can contain multiple tools that should be executed in
+ * parallel.
+ */
+public class ToolExecutionStep {
+ private final List<ToolCallDefinition> toolCalls;
+
+ public ToolExecutionStep() {
+ this.toolCalls = new ArrayList<>();
+ }
+
+ public void addToolCall(ToolCallDefinition toolCall) {
+ this.toolCalls.add(Objects.requireNonNull(toolCall, "Tool call cannot be null"));
+ }
+
+ public List<ToolCallDefinition> getToolCalls() {
+ return new ArrayList<>(toolCalls); // Return defensive copy
+ }
+
+ public boolean isEmpty() {
+ return toolCalls.isEmpty();
+ }
+
+ public int size() {
+ return toolCalls.size();
+ }
+
+ public ToolCallDefinition getLastToolCall() {
+ if (toolCalls.isEmpty()) {
+ throw new IllegalStateException("No tool calls in this step");
+ }
+ return toolCalls.get(toolCalls.size() - 1);
+ }
+
+ @Override
+ public String toString() {
+ return String.format("ToolExecutionStep{toolCalls=%s}", toolCalls);
+ }
+}
diff --git a/test-infra/camel-test-infra-openai-mock/src/main/resources/META-INF/MANIFEST.MF b/test-infra/camel-test-infra-openai-mock/src/main/resources/META-INF/MANIFEST.MF
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/test-infra/camel-test-infra-openai-mock/src/test/java/org/apache/camel/test/infra/openai/mock/OpenAIMockFailuresTest.java b/test-infra/camel-test-infra-openai-mock/src/test/java/org/apache/camel/test/infra/openai/mock/OpenAIMockFailuresTest.java
new file mode 100644
index 00000000000..74aa778a504
--- /dev/null
+++ b/test-infra/camel-test-infra-openai-mock/src/test/java/org/apache/camel/test/infra/openai/mock/OpenAIMockFailuresTest.java
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.test.infra.openai.mock;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class OpenAIMockFailuresTest {
+
+ @RegisterExtension
+ public OpenAIMock openAIMock = new OpenAIMock();
+
+ @Test
+ public void testBadRequest() throws Exception {
+ HttpClient client = HttpClient.newHttpClient();
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(openAIMock.getBaseUrl() + "/v1/chat/completions"))
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers
+ .ofString("{\"messages\": [{\"role\": \"assistant\", \"content\": \"any sentence\"}]}"))
+ .build();
+
+ HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
+ Assertions.assertEquals(500, response.statusCode());
+ }
+
+ @Test
+ public void testNotFound() throws Exception {
+ HttpClient client = HttpClient.newHttpClient();
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(openAIMock.getBaseUrl() + "/v1/chat/completions"))
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers
+ .ofString("{\"messages\": [{\"role\": \"user\", \"content\": \"not found sentence\"}]}"))
+ .build();
+
+ HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
+ Assertions.assertEquals(500, response.statusCode());
+ }
+
+ @Test
+ public void testBuilderExceptions() {
+ OpenAIMockBuilder builder = new OpenAIMock().builder();
+ assertThrows(IllegalStateException.class, () -> builder.replyWith("test"));
+ assertThrows(IllegalStateException.class, () -> builder.invokeTool("test"));
+ assertThrows(IllegalStateException.class, () -> builder.withParam("key", "value"));
+ assertThrows(IllegalStateException.class, () -> builder.thenRespondWith((req, in) -> null));
+ assertThrows(IllegalStateException.class, () -> builder.end());
+
+ builder.when("sentence");
+ assertThrows(IllegalStateException.class, () -> builder.withParam("key", "value"));
+ }
+}
diff --git a/test-infra/camel-test-infra-openai-mock/src/test/java/org/apache/camel/test/infra/openai/mock/OpenAIMockMultipleToolsTest.java b/test-infra/camel-test-infra-openai-mock/src/test/java/org/apache/camel/test/infra/openai/mock/OpenAIMockMultipleToolsTest.java
new file mode 100644
index 00000000000..436a2fac58d
--- /dev/null
+++ b/test-infra/camel-test-infra-openai-mock/src/test/java/org/apache/camel/test/infra/openai/mock/OpenAIMockMultipleToolsTest.java
@@ -0,0 +1,100 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.test.infra.openai.mock;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+public class OpenAIMockMultipleToolsTest {
+
+ @RegisterExtension
+ OpenAIMock openAIMock = new OpenAIMock().builder()
+ .when("What is the weather in london?")
+ .invokeTool("FindsTheLatitudeAndLongitudeOfAGivenCity")
+ .withParam("name", "London")
+ .andThenInvokeTool("ForecastsTheWeatherForTheGivenLatitudeAndLongitude")
+ .withParam("latitude", "51.50758961965397")
+ .withParam("longitude", "-0.13388057363742217")
+ .build();
+
+ @Test
+ void testInvokeToolAndThenInvokeTool() throws Exception {
+ HttpClient client = HttpClient.newHttpClient();
+ ObjectMapper objectMapper = new ObjectMapper();
+
+ // First request: User asks for weather in London
+ HttpRequest request1 = HttpRequest.newBuilder()
+ .uri(URI.create(openAIMock.getBaseUrl() + "/v1/chat/completions"))
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers
+ .ofString("{\"messages\": [{\"role\": \"user\", \"content\": \"What is the weather in london?\"}]}"))
+ .build();
+
+ HttpResponse<String> response1 = client.send(request1, HttpResponse.BodyHandlers.ofString());
+ String responseBody1 = response1.body();
+ JsonNode responseJson1 = objectMapper.readTree(responseBody1);
+
+ JsonNode choice1 = responseJson1.path("choices").get(0);
+ JsonNode message1 = choice1.path("message");
+
+ // Assert first tool call
+ Assertions.assertEquals("assistant", message1.path("role").asText());
+ JsonNode toolCalls1 = message1.path("tool_calls");
+ Assertions.assertEquals(1, toolCalls1.size());
+ JsonNode toolCall1 = toolCalls1.get(0);
+ Assertions.assertEquals("FindsTheLatitudeAndLongitudeOfAGivenCity", toolCall1.path("function").path("name").asText());
+ Assertions.assertEquals("{\"name\":\"London\"}", toolCall1.path("function").path("arguments").asText());
+ String toolCallId1 = toolCall1.path("id").asText();
+ Assertions.assertEquals("tool_calls", choice1.path("finish_reason").asText());
+
+ // Second request: LLM provides tool output for the first tool call
+ String secondRequestBody = String.format(
+ "{\"messages\": [{\"role\": \"user\", \"content\": \"What is the weather in london?\"}, {\"role\":\"assistant\", \"tool_calls\": [{\"id\":\"%s\", \"type\":\"function\", \"function\":{\"name\":\"FindsTheLatitudeAndLongitudeOfAGivenCity\", \"arguments\":\"{\\\"name\\\":\\\"London\\\"}\"}}]}, {\"role\":\"tool\", \"tool_call_id\":\"%s\", \"content\":\"{\\\"latitude\\\": \\\"51.50758961965397\\\", \\\"longitude\\\": \\\"-0.13388057363742217\\\"}\"}]}",
+ toolCallId1, toolCallId1);
+ HttpRequest request2 = HttpRequest.newBuilder()
+ .uri(URI.create(openAIMock.getBaseUrl() + "/v1/chat/completions"))
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString(secondRequestBody))
+ .build();
+
+ HttpResponse<String> response2 = client.send(request2, HttpResponse.BodyHandlers.ofString());
+ String responseBody2 = response2.body();
+ JsonNode responseJson2 = objectMapper.readTree(responseBody2);
+
+ JsonNode choice2 = responseJson2.path("choices").get(0);
+ JsonNode message2 = choice2.path("message");
+
+ // Assert second tool call
+ Assertions.assertEquals("assistant", message2.path("role").asText());
+ JsonNode toolCalls2 = message2.path("tool_calls");
+ Assertions.assertEquals(1, toolCalls2.size());
+ JsonNode toolCall2 = toolCalls2.get(0);
+ Assertions.assertEquals("ForecastsTheWeatherForTheGivenLatitudeAndLongitude",
+ toolCall2.path("function").path("name").asText());
+ Assertions.assertEquals("{\"latitude\":\"51.50758961965397\",\"longitude\":\"-0.13388057363742217\"}",
+ toolCall2.path("function").path("arguments").asText());
+ Assertions.assertEquals("tool_calls", choice2.path("finish_reason").asText());
+ }
+}
diff --git a/test-infra/camel-test-infra-openai-mock/src/test/java/org/apache/camel/test/infra/openai/mock/OpenAIMockTest.java b/test-infra/camel-test-infra-openai-mock/src/test/java/org/apache/camel/test/infra/openai/mock/OpenAIMockTest.java
new file mode 100644
index 00000000000..5e00046b5db
--- /dev/null
+++ b/test-infra/camel-test-infra-openai-mock/src/test/java/org/apache/camel/test/infra/openai/mock/OpenAIMockTest.java
@@ -0,0 +1,219 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.test.infra.openai.mock;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.testcontainers.shaded.com.fasterxml.jackson.databind.JsonNode;
+import org.testcontainers.shaded.com.fasterxml.jackson.databind.ObjectMapper;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+public class OpenAIMockTest {
+
+ @RegisterExtension
+ public OpenAIMock openAIMock = new OpenAIMock().builder()
+ .when("any sentence")
+ .invokeTool("toolName")
+ .withParam("param1", "value1")
+ .end()
+ .when("another sentence")
+ .replyWith("hello World")
+ .end()
+ .when("multiple tools")
+ .invokeTool("tool1")
+ .withParam("p1", "v1")
+ .andInvokeTool("tool2")
+ .withParam("p2", "v2")
+ .withParam("p3", "v3")
+ .end()
+ .when("custom response")
+ .thenRespondWith(
+ (request, input) -> "Custom response for: " + input)
+ .end()
+ .when("assert request")
+ .assertRequest(request -> {
+ Assertions.assertEquals("test", request);
+ })
+ .replyWith("Request asserted successfully")
+ .build();
+
+ @Test
+ public void testToolResponse() throws Exception {
+ HttpClient client = HttpClient.newHttpClient();
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(openAIMock.getBaseUrl() + "/v1/chat/completions"))
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers
+ .ofString("{\"messages\": [{\"role\": \"user\", \"content\": \"any sentence\"}]}"))
+ .build();
+
+ HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
+ String responseBody = response.body();
+
+ ObjectMapper objectMapper = new ObjectMapper();
+ JsonNode responseJson = objectMapper.readTree(responseBody);
+
+ JsonNode choice = responseJson.path("choices").get(0);
+ JsonNode message = choice.path("message");
+
+ assertEquals("assistant", message.path("role").asText());
+ assertEquals(true, message.path("content").isNull());
+ assertEquals(true, message.path("refusal").isNull());
+
+ JsonNode toolCalls = message.path("tool_calls");
+ assertEquals(1, toolCalls.size());
+
+ JsonNode toolCall = toolCalls.get(0);
+ assertEquals("function", toolCall.path("type").asText());
+ assertEquals("toolName", toolCall.path("function").path("name").asText());
+ assertEquals("{\"param1\":\"value1\"}", toolCall.path("function").path("arguments").asText());
+ }
+
+ @Test
+ public void testChatResponse() throws Exception {
+ HttpClient client = HttpClient.newHttpClient();
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(openAIMock.getBaseUrl() + "/v1/chat/completions"))
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers
+ .ofString("{\"messages\": [{\"role\": \"user\", \"content\": \"another sentence\"}]}"))
+ .build();
+
+ HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
+ String responseBody = response.body();
+
+ ObjectMapper objectMapper = new ObjectMapper();
+ JsonNode responseJson = objectMapper.readTree(responseBody);
+
+ JsonNode choice = responseJson.path("choices").get(0);
+ JsonNode message = choice.path("message");
+
+ assertEquals("assistant", message.path("role").asText());
+ assertEquals("hello World", message.path("content").asText());
+ assertEquals(true, message.path("refusal").isNull());
+ assertEquals(true, message.path("tool_calls").isMissingNode());
+ }
+
+ @Test
+ public void testMultipleToolCallsResponse() throws Exception {
+ HttpClient client = HttpClient.newHttpClient();
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(openAIMock.getBaseUrl() + "/v1/chat/completions"))
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers
+ .ofString("{\"messages\": [{\"role\": \"user\", \"content\": \"multiple tools\"}]}"))
+ .build();
+
+ HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
+ String responseBody = response.body();
+
+ ObjectMapper objectMapper = new ObjectMapper();
+ JsonNode responseJson = objectMapper.readTree(responseBody);
+
+ JsonNode choice = responseJson.path("choices").get(0);
+ JsonNode message = choice.path("message");
+
+ assertEquals("assistant", message.path("role").asText());
+ assertEquals(true, message.path("content").isNull());
+ assertEquals(true, message.path("refusal").isNull());
+
+ JsonNode toolCalls = message.path("tool_calls");
+ assertEquals(2, toolCalls.size());
+
+ JsonNode toolCall1 = toolCalls.get(0);
+ assertEquals("function", toolCall1.path("type").asText());
+ assertEquals("tool1", toolCall1.path("function").path("name").asText());
+ assertEquals("{\"p1\":\"v1\"}", toolCall1.path("function").path("arguments").asText());
+
+ JsonNode toolCall2 = toolCalls.get(1);
+ assertEquals("function", toolCall2.path("type").asText());
+ assertEquals("tool2", toolCall2.path("function").path("name").asText());
+ assertEquals("{\"p2\":\"v2\",\"p3\":\"v3\"}", toolCall2.path("function").path("arguments").asText());
+ }
+
+ @Test
+ public void testCustomResponse() throws Exception {
+ HttpClient client = HttpClient.newHttpClient();
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(openAIMock.getBaseUrl() + "/v1/chat/completions"))
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers
+ .ofString("{\"messages\": [{\"role\": \"user\", \"content\": \"custom response\"}]}"))
+ .build();
+
+ HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
+ String responseBody = response.body();
+
+ assertEquals("Custom response for: custom response", responseBody);
+ }
+
+ @Test
+ public void testToolResponseAndStop() throws Exception {
+ HttpClient client = HttpClient.newHttpClient();
+ HttpRequest request1 = HttpRequest.newBuilder()
+ .uri(URI.create(openAIMock.getBaseUrl() + "/v1/chat/completions"))
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers
+ .ofString("{\"messages\": [{\"role\": \"user\", \"content\": \"any sentence\"}]}"))
+ .build();
+
+ HttpResponse<String> response1 = client.send(request1, HttpResponse.BodyHandlers.ofString());
+ String responseBody1 = response1.body();
+
+ ObjectMapper objectMapper = new ObjectMapper();
+ JsonNode responseJson1 = objectMapper.readTree(responseBody1);
+
+ JsonNode choice1 = responseJson1.path("choices").get(0);
+ JsonNode message1 = choice1.path("message");
+
+ assertEquals("assistant", message1.path("role").asText());
+ assertEquals(true, message1.path("content").isNull());
+ assertEquals(true, message1.path("refusal").isNull());
+
+ JsonNode toolCalls = message1.path("tool_calls");
+ assertEquals(1, toolCalls.size());
+
+ JsonNode toolCall = toolCalls.get(0);
+ String toolCallId = toolCall.path("id").asText();
+ assertEquals("function", toolCall.path("type").asText());
+ assertEquals("toolName", toolCall.path("function").path("name").asText());
+ assertEquals("{\"param1\":\"value1\"}", toolCall.path("function").path("arguments").asText());
+
+ // Second request with tool result
+ String secondRequestBody = String.format(
+ "{\"messages\": [{\"role\": \"user\", \"content\": \"any sentence\"}, {\"role\":\"tool\", \"tool_call_id\":\"%s\", \"content\":\"{\\\"name\\\": \\\"pippo\\\"}\"}]}",
+ toolCallId);
+ HttpRequest request2 = HttpRequest.newBuilder()
+ .uri(URI.create(openAIMock.getBaseUrl() + "/v1/chat/completions"))
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString(secondRequestBody))
+ .build();
+ HttpResponse<String> response2 = client.send(request2, HttpResponse.BodyHandlers.ofString());
+ String responseBody2 = response2.body();
+ JsonNode responseJson2 = objectMapper.readTree(responseBody2);
+
+ JsonNode choice2 = responseJson2.path("choices").get(0);
+ assertEquals("stop", choice2.path("finish_reason").asText());
+ }
+}
diff --git a/test-infra/pom.xml b/test-infra/pom.xml
index 62404180bf8..22adcb199a5 100644
--- a/test-infra/pom.xml
+++ b/test-infra/pom.xml
@@ -74,6 +74,7 @@
<module>camel-test-infra-fhir</module>
<module>camel-test-infra-mosquitto</module>
<module>camel-test-infra-chatscript</module>
+ <module>camel-test-infra-openai-mock</module>
<module>camel-test-infra-openldap</module>
<module>camel-test-infra-ignite</module>
<module>camel-test-infra-hashicorp-vault</module>