This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch fix/CAMEL-23960 in repository https://gitbox.apache.org/repos/asf/camel.git
commit 3acfb23c4dfbbd3ff7dfe8584af0574589ae36a8 Author: Claus Ibsen <[email protected]> AuthorDate: Sat Jul 18 15:04:28 2026 +0200 CAMEL-23960: camel-openai - Fail fast when outputClass cannot be resolved Previously, if the outputClass option (or CamelOpenAIOutputClass header) contained an unresolvable class name, it was silently ignored and no structured output format was applied. Now uses resolveMandatoryClass which throws ClassNotFoundException for unknown classes. Endpoint-level outputClass is resolved once at startup for efficiency. Header-level overrides are still resolved dynamically per exchange. Also fixes the header metadata javaType from Class to String since the header value is a FQCN string, not a Class object. Co-Authored-By: Claude Opus 4.6 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../apache/camel/catalog/components/openai.json | 2 +- .../org/apache/camel/component/openai/openai.json | 2 +- .../camel/component/openai/OpenAIConstants.java | 2 +- .../camel/component/openai/OpenAIProducer.java | 18 ++++- .../component/openai/OpenAIOutputClassTest.java | 85 ++++++++++++++++++++++ .../endpoint/dsl/OpenAIEndpointBuilderFactory.java | 4 +- 6 files changed, 105 insertions(+), 8 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/openai.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/openai.json index 11942d539af7..ae4a63a23c81 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/openai.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/openai.json @@ -42,7 +42,7 @@ "CamelOpenAITopP": { "index": 5, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "Double", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "An alternative to temperature for controlling randomness. Uses nucleus sampling where the model considers tokens with top_p probability mass", "constantName": "org.apache.camel.component.openai.OpenAIConstants#TOP_P" }, "CamelOpenAIMaxTokens": { "index": 6, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "Integer", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The maximum number of tokens to generate in the completion", "constantName": "org.apache.camel.component.openai.OpenAIConstants#MAX_TOKENS" }, "CamelOpenAIStreaming": { "index": 7, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "Boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Whether to stream the response back incrementally", "constantName": "org.apache.camel.component.openai.OpenAIConstants#STREAMING" }, - "CamelOpenAIOutputClass": { "index": 8, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "Class", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Java class to use for structured output parsing", "constantName": "org.apache.camel.component.openai.OpenAIConstants#OUTPUT_CLASS" }, + "CamelOpenAIOutputClass": { "index": 8, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Java class name (FQCN) to use for structured output parsing", "constantName": "org.apache.camel.component.openai.OpenAIConstants#OUTPUT_CLASS" }, "CamelOpenAIJsonSchema": { "index": 9, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The JSON schema to use for structured output validation", "constantName": "org.apache.camel.component.openai.OpenAIConstants#JSON_SCHEMA" }, "CamelOpenAIStripThinking": { "index": 10, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "Boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Whether to strip ... blocks from the response body", "constantName": "org.apache.camel.component.openai.OpenAIConstants#STRIP_THINKING" }, "CamelOpenAIMediaType": { "index": 11, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The MIME type of the message body when sending a file or binary content (File, WrappedFile, byte or InputStream) to the model. Takes precedence over component content-type headers and automatic MIME type detection", "constantName": "org.apa [...] diff --git a/components/camel-ai/camel-openai/src/generated/resources/META-INF/org/apache/camel/component/openai/openai.json b/components/camel-ai/camel-openai/src/generated/resources/META-INF/org/apache/camel/component/openai/openai.json index 11942d539af7..ae4a63a23c81 100644 --- a/components/camel-ai/camel-openai/src/generated/resources/META-INF/org/apache/camel/component/openai/openai.json +++ b/components/camel-ai/camel-openai/src/generated/resources/META-INF/org/apache/camel/component/openai/openai.json @@ -42,7 +42,7 @@ "CamelOpenAITopP": { "index": 5, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "Double", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "An alternative to temperature for controlling randomness. Uses nucleus sampling where the model considers tokens with top_p probability mass", "constantName": "org.apache.camel.component.openai.OpenAIConstants#TOP_P" }, "CamelOpenAIMaxTokens": { "index": 6, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "Integer", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The maximum number of tokens to generate in the completion", "constantName": "org.apache.camel.component.openai.OpenAIConstants#MAX_TOKENS" }, "CamelOpenAIStreaming": { "index": 7, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "Boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Whether to stream the response back incrementally", "constantName": "org.apache.camel.component.openai.OpenAIConstants#STREAMING" }, - "CamelOpenAIOutputClass": { "index": 8, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "Class", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Java class to use for structured output parsing", "constantName": "org.apache.camel.component.openai.OpenAIConstants#OUTPUT_CLASS" }, + "CamelOpenAIOutputClass": { "index": 8, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Java class name (FQCN) to use for structured output parsing", "constantName": "org.apache.camel.component.openai.OpenAIConstants#OUTPUT_CLASS" }, "CamelOpenAIJsonSchema": { "index": 9, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The JSON schema to use for structured output validation", "constantName": "org.apache.camel.component.openai.OpenAIConstants#JSON_SCHEMA" }, "CamelOpenAIStripThinking": { "index": 10, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "Boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Whether to strip ... blocks from the response body", "constantName": "org.apache.camel.component.openai.OpenAIConstants#STRIP_THINKING" }, "CamelOpenAIMediaType": { "index": 11, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The MIME type of the message body when sending a file or binary content (File, WrappedFile, byte or InputStream) to the model. Takes precedence over component content-type headers and automatic MIME type detection", "constantName": "org.apa [...] diff --git a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIConstants.java b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIConstants.java index 09472bf975f7..a6bac9df580b 100644 --- a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIConstants.java +++ b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIConstants.java @@ -42,7 +42,7 @@ public final class OpenAIConstants { public static final String MAX_TOKENS = "CamelOpenAIMaxTokens"; @Metadata(description = "Whether to stream the response back incrementally", javaType = "Boolean") public static final String STREAMING = "CamelOpenAIStreaming"; - @Metadata(description = "The Java class to use for structured output parsing", javaType = "Class") + @Metadata(description = "The Java class name (FQCN) to use for structured output parsing", javaType = "String") public static final String OUTPUT_CLASS = "CamelOpenAIOutputClass"; @Metadata(description = "The JSON schema to use for structured output validation", javaType = "String") public static final String JSON_SCHEMA = "CamelOpenAIJsonSchema"; diff --git a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java index 07393c4adc05..37a79794a6ab 100644 --- a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java +++ b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java @@ -76,6 +76,8 @@ public class OpenAIProducer extends DefaultAsyncProducer { private static final Pattern THINK_PATTERN = Pattern.compile("^\\s*<think>(.*?)</think>\\s*", Pattern.DOTALL); private static final String PENDING_USER_MESSAGE = "CamelOpenAIPendingUserMessage"; + private Class<?> outputClassResolved; + public OpenAIProducer(OpenAIEndpoint endpoint) { super(endpoint); } @@ -84,6 +86,11 @@ public class OpenAIProducer extends DefaultAsyncProducer { protected void doStart() throws Exception { OpenAIConfiguration config = getEndpoint().getConfiguration(); + if (ObjectHelper.isNotEmpty(config.getOutputClass())) { + outputClassResolved = getEndpoint().getCamelContext().getClassResolver() + .resolveMandatoryClass(config.getOutputClass()); + } + if (ObjectHelper.isNotEmpty(config.getJsonSchema())) { String resolved = getEndpoint().getCamelContext().resolvePropertyPlaceholders(config.getJsonSchema()); String content = resolveResourceContent(resolved); @@ -160,10 +167,15 @@ public class OpenAIProducer extends DefaultAsyncProducer { // Structured output handling if (ObjectHelper.isNotEmpty(outputClass)) { - Class<?> responseClass = getEndpoint().getCamelContext().getClassResolver().resolveClass(outputClass); - if (responseClass != null) { - paramsBuilder.responseFormat(responseClass); + Class<?> responseClass; + String headerOutputClass = in.getHeader(OpenAIConstants.OUTPUT_CLASS, String.class); + if (ObjectHelper.isNotEmpty(headerOutputClass)) { + responseClass = getEndpoint().getCamelContext().getClassResolver() + .resolveMandatoryClass(headerOutputClass); + } else { + responseClass = outputClassResolved; } + paramsBuilder.responseFormat(responseClass); } else if (ObjectHelper.isNotEmpty(jsonSchema)) { // Build OpenAI JSON schema response format from provided schema string try { diff --git a/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIOutputClassTest.java b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIOutputClassTest.java new file mode 100644 index 000000000000..670df8105def --- /dev/null +++ b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIOutputClassTest.java @@ -0,0 +1,85 @@ +/* + * 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.component.openai; + +import org.apache.camel.CamelContext; +import org.apache.camel.Exchange; +import org.apache.camel.FailedToStartRouteException; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.test.infra.openai.mock.OpenAIMock; +import org.apache.camel.test.junit6.CamelTestSupport; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class OpenAIOutputClassTest extends CamelTestSupport { + + @RegisterExtension + public OpenAIMock openAIMock = new OpenAIMock().builder() + .when("hello") + .replyWith("Hi from mock") + .end() + .build(); + + @Override + protected RouteBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + from("direct:chat") + .to("openai:chat-completion?model=gpt-5&apiKey=dummy&baseUrl=" + + openAIMock.getBaseUrl() + "/v1"); + } + }; + } + + @Test + void unknownOutputClassViaHeaderShouldFail() { + Exchange result = template.request("direct:chat", e -> { + e.getIn().setBody("hello"); + e.getIn().setHeader(OpenAIConstants.OUTPUT_CLASS, "com.does.not.Exist"); + }); + + assertThat(result.getException()) + .isInstanceOf(ClassNotFoundException.class) + .hasMessageContaining("com.does.not.Exist"); + } + + @Test + void unknownOutputClassOnEndpointShouldFailOnStartup() throws Exception { + CamelContext ctx = createCamelContext(); + ctx.addRoutes(new RouteBuilder() { + @Override + public void configure() { + from("direct:bad") + .to("openai:chat-completion?model=gpt-5&apiKey=dummy&outputClass=com.does.not.Exist&baseUrl=" + + openAIMock.getBaseUrl() + "/v1"); + } + }); + + Exception thrown = assertThrows(FailedToStartRouteException.class, ctx::start); + + assertThat(thrown) + .rootCause() + .isInstanceOf(ClassNotFoundException.class) + .hasMessageContaining("com.does.not.Exist"); + + ctx.stop(); + } +} diff --git a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/OpenAIEndpointBuilderFactory.java b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/OpenAIEndpointBuilderFactory.java index 013cfd3e5ea7..5d353f4ddde8 100644 --- a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/OpenAIEndpointBuilderFactory.java +++ b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/OpenAIEndpointBuilderFactory.java @@ -1294,9 +1294,9 @@ public interface OpenAIEndpointBuilderFactory { return "CamelOpenAIStreaming"; } /** - * The Java class to use for structured output parsing. + * The Java class name (FQCN) to use for structured output parsing. * - * The option is a: {@code Class} type. + * The option is a: {@code String} type. * * Group: producer *
