This is an automated email from the ASF dual-hosted git repository.
jamesnetherton pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel-quarkus.git
The following commit(s) were added to refs/heads/main by this push:
new 1e70867055 Fix OpenAI integration tests against real and OpenAI
compatible services
1e70867055 is described below
commit 1e708670552f0a328370c7d1949486134f1d4510
Author: James Netherton <[email protected]>
AuthorDate: Thu Oct 15 16:10:24 2026 +0100
Fix OpenAI integration tests against real and OpenAI compatible services
---
integration-tests/openai/README.adoc | 7 +++
.../component/openai/it/OpenaiResource.java | 24 ++++++++--
.../quarkus/component/openai/it/OpenaiRoutes.java | 50 +++++++++++--------
.../src/main/resources/application.properties | 3 ++
.../quarkus/component/openai/it/OpenaiAudioIT.java | 2 +
.../component/openai/it/OpenaiAudioTest.java | 2 +
.../component/openai/it/OpenaiChatTest.java | 53 ++++++++-------------
.../openai/src/test/resources/audio/test-audio.wav | Bin 44 -> 71964 bytes
...step1-c3d4e5f6-0000-0000-0000-000000000001.json | 2 +-
...step2-c3d4e5f6-0000-0000-0000-000000000002.json | 2 +-
...calls-a1b2c3d4-0000-0000-0000-000000000001.json | 4 +-
11 files changed, 85 insertions(+), 64 deletions(-)
diff --git a/integration-tests/openai/README.adoc
b/integration-tests/openai/README.adoc
index f619546f41..4a6d9a71fe 100644
--- a/integration-tests/openai/README.adoc
+++ b/integration-tests/openai/README.adoc
@@ -31,6 +31,13 @@ export OPENAI_CHAT_MODEL=your-chat-model-name
export OPENAI_EMBEDDING_MODEL=your-embedding-model-name
----
+If the provider does not support audio transcription, skip the audio tests
with:
+
+[source,shell]
+----
+export OPENAI_AUDIO_ENABLED=false
+----
+
If the WireMock stub recordings need updating, then remove the existing files
from `src/test/resources/mappings` and run tests with either:
System property `-Dwiremock.record=true`
diff --git
a/integration-tests/openai/src/main/java/org/apache/camel/quarkus/component/openai/it/OpenaiResource.java
b/integration-tests/openai/src/main/java/org/apache/camel/quarkus/component/openai/it/OpenaiResource.java
index 4cbe16b732..bb01bac5fb 100644
---
a/integration-tests/openai/src/main/java/org/apache/camel/quarkus/component/openai/it/OpenaiResource.java
+++
b/integration-tests/openai/src/main/java/org/apache/camel/quarkus/component/openai/it/OpenaiResource.java
@@ -71,8 +71,18 @@ public class OpenaiResource {
@Path("/chat/streaming")
@POST
@Consumes(MediaType.TEXT_PLAIN)
- public void chat(String chatMessageContent) {
- producerTemplate.sendBody("direct:chatStreaming", chatMessageContent);
+ @Produces(MediaType.APPLICATION_JSON)
+ public Map<String, Object> chatStreaming(String chatMessageContent) {
+ Exchange exchange = producerTemplate.request("direct:chatStreaming",
+ e -> e.getMessage().setBody(chatMessageContent));
+ if (exchange.getException() != null) {
+ throw new RuntimeException(exchange.getException());
+ }
+
+ Message message = exchange.getMessage();
+ return Map.of(
+ "content", message.getBody(String.class),
+ "chunks",
message.getHeader(OpenaiRoutes.STREAMING_CHUNK_COUNT, Integer.class));
}
@Path("/chat/image")
@@ -118,9 +128,9 @@ public class OpenaiResource {
@Path("/chat/agentic")
@POST
@Consumes(MediaType.TEXT_PLAIN)
- @Produces(MediaType.TEXT_PLAIN)
- public String chatAgentic(String chatMessageContent) {
- return producerTemplate.requestBody("direct:chatAgentic",
chatMessageContent, String.class);
+ @Produces(MediaType.APPLICATION_JSON)
+ public Map<String, Object> chatAgentic(String chatMessageContent) {
+ return requestWithToolCalls("direct:chatAgentic", chatMessageContent);
}
@Path("/chat/routeTools")
@@ -129,6 +139,10 @@ public class OpenaiResource {
@Produces(MediaType.APPLICATION_JSON)
public Map<String, Object> chatRouteTools(@QueryParam("returnDirect")
boolean returnDirect, String chatMessageContent) {
String endpointUri = returnDirect ?
"direct:chatRouteToolsReturnDirect" : "direct:chatRouteTools";
+ return requestWithToolCalls(endpointUri, chatMessageContent);
+ }
+
+ private Map<String, Object> requestWithToolCalls(String endpointUri,
String chatMessageContent) {
Exchange exchange = producerTemplate.request(endpointUri,
e -> e.getMessage().setBody(chatMessageContent));
if (exchange.getException() != null) {
diff --git
a/integration-tests/openai/src/main/java/org/apache/camel/quarkus/component/openai/it/OpenaiRoutes.java
b/integration-tests/openai/src/main/java/org/apache/camel/quarkus/component/openai/it/OpenaiRoutes.java
index dab8f1e0f4..08ffc720fe 100644
---
a/integration-tests/openai/src/main/java/org/apache/camel/quarkus/component/openai/it/OpenaiRoutes.java
+++
b/integration-tests/openai/src/main/java/org/apache/camel/quarkus/component/openai/it/OpenaiRoutes.java
@@ -16,19 +16,15 @@
*/
package org.apache.camel.quarkus.component.openai.it;
-import java.util.List;
-
import com.openai.models.chat.completions.ChatCompletionChunk;
-import com.openai.models.chat.completions.ChatCompletionChunk.Choice;
import org.apache.camel.Exchange;
import org.apache.camel.Message;
import org.apache.camel.Processor;
import org.apache.camel.builder.RouteBuilder;
import org.apache.camel.quarkus.component.openai.it.model.Product;
-import org.apache.camel.util.ObjectHelper;
-import org.apache.camel.util.StringHelper;
public class OpenaiRoutes extends RouteBuilder {
+ public static final String STREAMING_CHUNK_COUNT = "streamingChunkCount";
@Override
public void configure() throws Exception {
@@ -38,29 +34,37 @@ public class OpenaiRoutes extends RouteBuilder {
from("direct:chatStreaming")
.to("openai:chat-completion?streaming=true")
- .split(body())
+ // Providers split tokens into chunks differently, so join the
streamed content rather than inspecting individual chunks
+ .split(body(), (oldExchange, newExchange) -> {
+ String content =
newExchange.getMessage().getBody(String.class);
+ if (oldExchange == null) {
+
newExchange.getMessage().setHeader(STREAMING_CHUNK_COUNT, content.isEmpty() ? 0
: 1);
+ return newExchange;
+ }
+
+ if (!content.isEmpty()) {
+ Message message = oldExchange.getMessage();
+ message.setBody(message.getBody(String.class) +
content);
+ message.setHeader(STREAMING_CHUNK_COUNT,
message.getHeader(STREAMING_CHUNK_COUNT, Integer.class) + 1);
+ }
+ return oldExchange;
+ })
.streaming()
.process(new Processor() {
@Override
public void process(Exchange exchange) throws Exception {
Message message = exchange.getMessage();
ChatCompletionChunk chunk =
message.getBody(ChatCompletionChunk.class);
- message.setBody(null);
-
- List<Choice> choices = chunk.choices();
- if (!choices.isEmpty()) {
- Choice choice = choices.get(0);
- choice.delta().content().ifPresent(content -> {
- if (ObjectHelper.isNotEmpty(content) &&
StringHelper.isDigit(content)) {
- message.setBody(content);
- }
- });
- }
+ String content = chunk.choices()
+ .stream()
+ .findFirst()
+ .flatMap(choice -> choice.delta().content())
+ .orElse("");
+ message.setBody(content);
}
})
- .filter().simple("${body} != null")
- .log("Streaming chat chunk: ${body}")
- .wireTap("seda:chatStreamingResult");
+ .end()
+ .log("Streaming chat response received in ${header." +
STREAMING_CHUNK_COUNT + "} chunks: ${body}");
from("direct:chatStructuredOutputWithSchema")
.to("openai:chat-completion?jsonSchema=resource:classpath:schema/product.json")
@@ -80,13 +84,17 @@ public class OpenaiRoutes extends RouteBuilder {
.setHeader("CamelOpenAIMaxTokens", constant(10))
.to("openai:chat-completion?conversationMemory=true")
.log("Chat response 1: ${body}")
+ // reasoning models may spend the whole token budget before
producing any content
+ .removeHeader("CamelOpenAIMaxTokens")
// here we will limit it via instruction, so the model can
"reorganize" the word "John" to fit in the shorter answer
.setBody(constant("What is my name? Respond maximum 5 words."))
.to("openai:chat-completion?conversationMemory=true")
.log("Chat response 2: ${body}");
from("direct:chatToolCalls")
- .to("openai:chat-completion")
+ .to("openai:chat-completion?autoToolExecution=false"
+ + "&mcpServer.tools.transportType=streamableHttp"
+ +
"&mcpServer.tools.url=http://localhost:{{quarkus.http.test-port:8081}}")
.log("Tool calls response: ${body}");
from("direct:chatAgentic")
diff --git a/integration-tests/openai/src/main/resources/application.properties
b/integration-tests/openai/src/main/resources/application.properties
index f376a66421..ce006b27b3 100644
--- a/integration-tests/openai/src/main/resources/application.properties
+++ b/integration-tests/openai/src/main/resources/application.properties
@@ -19,3 +19,6 @@
quarkus.http.test-timeout=120S
quarkus.native.resources.includes=schema/product.json
+
+# Set to false to skip OpenaiAudioTest when the target endpoint has no audio
transcription support
+openai.audio.enabled=true
diff --git
a/integration-tests/openai/src/test/java/org/apache/camel/quarkus/component/openai/it/OpenaiAudioIT.java
b/integration-tests/openai/src/test/java/org/apache/camel/quarkus/component/openai/it/OpenaiAudioIT.java
index 3c9547a0be..990b88b631 100644
---
a/integration-tests/openai/src/test/java/org/apache/camel/quarkus/component/openai/it/OpenaiAudioIT.java
+++
b/integration-tests/openai/src/test/java/org/apache/camel/quarkus/component/openai/it/OpenaiAudioIT.java
@@ -17,7 +17,9 @@
package org.apache.camel.quarkus.component.openai.it;
import io.quarkus.test.junit.QuarkusIntegrationTest;
+import io.quarkus.test.junit.condition.EnabledIfConfig;
+@EnabledIfConfig(named = "openai.audio.enabled", matches = "true")
@QuarkusIntegrationTest
class OpenaiAudioIT extends OpenaiAudioTest {
diff --git
a/integration-tests/openai/src/test/java/org/apache/camel/quarkus/component/openai/it/OpenaiAudioTest.java
b/integration-tests/openai/src/test/java/org/apache/camel/quarkus/component/openai/it/OpenaiAudioTest.java
index e5b22e71c6..3f9781fbd4 100644
---
a/integration-tests/openai/src/test/java/org/apache/camel/quarkus/component/openai/it/OpenaiAudioTest.java
+++
b/integration-tests/openai/src/test/java/org/apache/camel/quarkus/component/openai/it/OpenaiAudioTest.java
@@ -26,6 +26,7 @@ import java.nio.file.Paths;
import io.quarkus.test.common.QuarkusTestResource;
import io.quarkus.test.junit.QuarkusTest;
+import io.quarkus.test.junit.condition.EnabledIfConfig;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import org.apache.camel.util.FileUtil;
@@ -36,6 +37,7 @@ import org.junit.jupiter.api.TestInfo;
import static org.hamcrest.Matchers.containsStringIgnoringCase;
+@EnabledIfConfig(named = "openai.audio.enabled", matches = "true")
@QuarkusTestResource(OpenaiTestResource.class)
@QuarkusTest
class OpenaiAudioTest {
diff --git
a/integration-tests/openai/src/test/java/org/apache/camel/quarkus/component/openai/it/OpenaiChatTest.java
b/integration-tests/openai/src/test/java/org/apache/camel/quarkus/component/openai/it/OpenaiChatTest.java
index ab7905036c..da56f95238 100644
---
a/integration-tests/openai/src/test/java/org/apache/camel/quarkus/component/openai/it/OpenaiChatTest.java
+++
b/integration-tests/openai/src/test/java/org/apache/camel/quarkus/component/openai/it/OpenaiChatTest.java
@@ -24,8 +24,9 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
-import java.util.HashSet;
import java.util.Set;
+import java.util.regex.MatchResult;
+import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
@@ -33,7 +34,6 @@ import io.quarkus.test.common.QuarkusTestResource;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
-import io.restassured.response.Response;
import org.apache.camel.util.FileUtil;
import org.apache.commons.lang3.StringUtils;
import org.awaitility.Awaitility;
@@ -47,6 +47,7 @@ import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.containsStringIgnoringCase;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.greaterThan;
+import static org.hamcrest.Matchers.hasItem;
import static org.hamcrest.Matchers.notNullValue;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -101,10 +102,7 @@ class OpenaiChatTest {
.post("/openai/chat/image")
.then()
.statusCode(200)
- .body(
- containsStringIgnoringCase("camel"),
- containsStringIgnoringCase("silhouette"),
- containsStringIgnoringCase("icon"));
+ .body(containsStringIgnoringCase("camel"));
} finally {
if (FileUtil.isWindows()) {
// File may be locked by the Quarkus process, so clean up on
VM exit
@@ -173,32 +171,21 @@ class OpenaiChatTest {
@Test
void streamingChat() {
- // Send the streaming request
- RestAssured.given()
+ String content = RestAssured.given()
.contentType(ContentType.TEXT)
.body("Stream the numbers 1 to 9 on a new line each time and
nothing else.")
.post("/openai/chat/streaming")
.then()
- .statusCode(204);
-
- // Assert the streamed results
- Set<String> receivedNumbers = new HashSet<>();
- Awaitility.await()
- .atMost(Duration.ofMinutes(1))
- .pollInterval(Duration.ofMillis(200))
- .until(() -> {
- Response response = RestAssured.given()
- .queryParam("endpointUri",
"seda:chatStreamingResult")
- .get("/openai/chat/results");
+ .statusCode(200)
+ .body("chunks", greaterThan(1))
+ .extract()
+ .path("content");
- if (response.getStatusCode() == 200) {
- String result = response.getBody().asString();
- if (result != null) {
- receivedNumbers.add(result.trim());
- }
- }
- return receivedNumbers.size() == 9;
- });
+ Set<String> receivedNumbers = Pattern.compile("\\d+")
+ .matcher(content)
+ .results()
+ .map(MatchResult::group)
+ .collect(Collectors.toSet());
Set<String> expectedNumbers = IntStream.rangeClosed(1, 9)
.mapToObj(String::valueOf)
@@ -237,26 +224,24 @@ class OpenaiChatTest {
void chatToolCalls() {
RestAssured.given()
.contentType(ContentType.TEXT)
- .body("What is 2 + 3?")
+ .body("What is 2 + 3? Use the add_numbers tool.")
.post("/openai/chat/toolCalls")
.then()
.statusCode(200)
- .body(
- containsStringIgnoringCase("add"),
- containsStringIgnoringCase("call_abc123"));
+ .body(containsStringIgnoringCase("add_numbers"));
}
@Test
void chatAgentic() {
RestAssured.given()
.contentType(ContentType.TEXT)
- .body("What is 17 + 25?")
+ .body("What is 17 + 25? Use the add_numbers tool.")
.post("/openai/chat/agentic")
.then()
.statusCode(200)
.body(
- containsStringIgnoringCase("42"),
- containsStringIgnoringCase("sum"));
+ "body", containsStringIgnoringCase("42"),
+ "toolCalls", hasItem("add_numbers"));
}
@Test
diff --git a/integration-tests/openai/src/test/resources/audio/test-audio.wav
b/integration-tests/openai/src/test/resources/audio/test-audio.wav
index d26b87bdbb..a7769d28bc 100644
Binary files a/integration-tests/openai/src/test/resources/audio/test-audio.wav
and b/integration-tests/openai/src/test/resources/audio/test-audio.wav differ
diff --git
a/integration-tests/openai/src/test/resources/mappings/chat_completions-agentic-step1-c3d4e5f6-0000-0000-0000-000000000001.json
b/integration-tests/openai/src/test/resources/mappings/chat_completions-agentic-step1-c3d4e5f6-0000-0000-0000-000000000001.json
index f568cbbf62..2697fa07a8 100644
---
a/integration-tests/openai/src/test/resources/mappings/chat_completions-agentic-step1-c3d4e5f6-0000-0000-0000-000000000001.json
+++
b/integration-tests/openai/src/test/resources/mappings/chat_completions-agentic-step1-c3d4e5f6-0000-0000-0000-000000000001.json
@@ -8,7 +8,7 @@
"url" : "/chat/completions",
"method" : "POST",
"bodyPatterns" : [ {
- "equalToJson" : "{\"messages\":[{\"content\":\"What is 17 +
25?\",\"role\":\"user\"}],\"model\":\"gpt-5\"}",
+ "equalToJson" : "{\"messages\":[{\"content\":\"What is 17 + 25? Use the
add_numbers tool.\",\"role\":\"user\"}],\"model\":\"gpt-5\"}",
"ignoreArrayOrder" : true,
"ignoreExtraElements" : true
} ]
diff --git
a/integration-tests/openai/src/test/resources/mappings/chat_completions-agentic-step2-c3d4e5f6-0000-0000-0000-000000000002.json
b/integration-tests/openai/src/test/resources/mappings/chat_completions-agentic-step2-c3d4e5f6-0000-0000-0000-000000000002.json
index af64a62927..10c1c2908e 100644
---
a/integration-tests/openai/src/test/resources/mappings/chat_completions-agentic-step2-c3d4e5f6-0000-0000-0000-000000000002.json
+++
b/integration-tests/openai/src/test/resources/mappings/chat_completions-agentic-step2-c3d4e5f6-0000-0000-0000-000000000002.json
@@ -8,7 +8,7 @@
"url" : "/chat/completions",
"method" : "POST",
"bodyPatterns" : [ {
- "equalToJson" : "{\"messages\":[{\"content\":\"What is 17 +
25?\",\"role\":\"user\"}],\"model\":\"gpt-5\"}",
+ "equalToJson" : "{\"messages\":[{\"content\":\"What is 17 + 25? Use the
add_numbers tool.\",\"role\":\"user\"}],\"model\":\"gpt-5\"}",
"ignoreArrayOrder" : true,
"ignoreExtraElements" : true
}, {
diff --git
a/integration-tests/openai/src/test/resources/mappings/chat_completions-tool-calls-a1b2c3d4-0000-0000-0000-000000000001.json
b/integration-tests/openai/src/test/resources/mappings/chat_completions-tool-calls-a1b2c3d4-0000-0000-0000-000000000001.json
index 235b0237f4..72f8e92e54 100644
---
a/integration-tests/openai/src/test/resources/mappings/chat_completions-tool-calls-a1b2c3d4-0000-0000-0000-000000000001.json
+++
b/integration-tests/openai/src/test/resources/mappings/chat_completions-tool-calls-a1b2c3d4-0000-0000-0000-000000000001.json
@@ -5,14 +5,14 @@
"url" : "/chat/completions",
"method" : "POST",
"bodyPatterns" : [ {
- "equalToJson" : "{\"messages\":[{\"content\":\"What is 2 +
3?\",\"role\":\"user\"}],\"model\":\"gpt-5\"}",
+ "equalToJson" : "{\"messages\":[{\"content\":\"What is 2 + 3? Use the
add_numbers tool.\",\"role\":\"user\"}],\"model\":\"gpt-5\"}",
"ignoreArrayOrder" : true,
"ignoreExtraElements" : true
} ]
},
"response" : {
"status" : 200,
- "body" : "{\n \"id\": \"chatcmpl-tool-calls\",\n \"object\":
\"chat.completion\",\n \"created\": 1770382622,\n \"model\":
\"gpt-4o-2025-08-07\",\n \"choices\": [\n {\n \"index\": 0,\n
\"message\": {\n \"role\": \"assistant\",\n \"content\": null,\n
\"tool_calls\": [\n {\n \"id\": \"call_abc123\",\n
\"type\": \"function\",\n \"function\": {\n
\"name\": \"add\",\n \"arguments\": \ [...]
+ "body" : "{\n \"id\": \"chatcmpl-tool-calls\",\n \"object\":
\"chat.completion\",\n \"created\": 1770382622,\n \"model\":
\"gpt-4o-2025-08-07\",\n \"choices\": [\n {\n \"index\": 0,\n
\"message\": {\n \"role\": \"assistant\",\n \"content\": null,\n
\"tool_calls\": [\n {\n \"id\": \"call_abc123\",\n
\"type\": \"function\",\n \"function\": {\n
\"name\": \"add_numbers\",\n \"argume [...]
"headers" : {
"Content-Type" : "application/json"
}