This is an automated email from the ASF dual-hosted git repository. jamesnetherton pushed a commit to branch 3.40.x in repository https://gitbox.apache.org/repos/asf/camel-quarkus.git
commit fa6951e0700eecec23379354430051aea8dd81c7 Author: James Netherton <[email protected]> AuthorDate: Thu Oct 15 16:10:24 2026 +0100 Fix OpenAI integration tests against real and OpenAI compatible services (cherry picked from commit 1e708670552f0a328370c7d1949486134f1d4510) --- integration-tests/openai/README.adoc | 7 +++ .../component/openai/it/OpenaiResource.java | 35 +++++++++++-- .../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 | 55 ++++++++------------- .../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, 97 insertions(+), 65 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 f1a7409323..c93d8c8a04 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,24 @@ 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); + } + + private Map<String, Object> requestWithToolCalls(String endpointUri, String chatMessageContent) { + Exchange exchange = producerTemplate.request(endpointUri, + e -> e.getMessage().setBody(chatMessageContent)); + if (exchange.getException() != null) { + throw new RuntimeException(exchange.getException()); + } + + Message message = exchange.getMessage(); + Map<String, Object> result = new HashMap<>(); + result.put("body", message.getBody(String.class)); + result.put("toolCalls", message.getHeader(OpenAIConstants.MCP_TOOL_CALLS, List.class)); + result.put("returnDirect", message.getHeader(OpenAIConstants.MCP_RETURN_DIRECT, false, Boolean.class)); + return result; } @Path("/audio/transcription") 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 26672ddce0..b9a6f2118f 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 22eb6b71a5..84b652a095 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; @@ -45,6 +45,7 @@ import org.junit.jupiter.api.TestInfo; import static org.hamcrest.Matchers.containsStringIgnoringCase; 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; @@ -99,10 +100,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 @@ -171,32 +169,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"); - - if (response.getStatusCode() == 200) { - String result = response.getBody().asString(); - if (result != null) { - receivedNumbers.add(result.trim()); - } - } - return receivedNumbers.size() == 9; - }); + .statusCode(200) + .body("chunks", greaterThan(1)) + .extract() + .path("content"); + + 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) @@ -235,26 +222,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")); } } 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" }
