This is an automated email from the ASF dual-hosted git repository. gnodet pushed a commit to branch fix/CAMEL-24322 in repository https://gitbox.apache.org/repos/asf/camel.git
commit 474bf98d631bf1a3dc17b4b673b2a12ba650e373 Author: Guillaume Nodet <[email protected]> AuthorDate: Mon Aug 3 08:19:36 2026 +0200 CAMEL-24322: Add tool-calling support via AiToolRegistry Extend the camel-openai component to discover and execute Camel route tools registered via the shared AiToolRegistry, alongside existing MCP tools. This implements Step 6 of the unified AI tool abstraction design (CAMEL-23382). Changes: - Add camel-ai-tool compile dependency to camel-openai - Add 'tags' configuration parameter to OpenAIConfiguration for filtering tools by tag from the shared AiToolRegistry - Create AiToolSpecToOpenAI converter that transforms AiToolSpec into OpenAI ChatCompletionFunctionTool using parametersJsonSchema - Extend OpenAIProducer to discover Camel route tools and dispatch them via AiToolExecutor in the agentic loop, with exchange isolation - Extend OpenAIToolExecutionProducer similarly for manual tool loops - Add AiToolSpecToOpenAITest with 8 test cases covering full spec, no params, no description, default type, required arrays, empty schema, invalid schema, and additionalProperties:false - Update error messages to reference generic "tool source" instead of MCP-specific wording Co-Authored-By: Claude Opus 4.6 <[email protected]> --- components/camel-ai/camel-openai/pom.xml | 5 + .../component/openai/OpenAIEndpointConfigurer.java | 3 + .../component/openai/OpenAIEndpointUriFactory.java | 3 +- .../org/apache/camel/component/openai/openai.json | 37 +++-- .../camel/component/openai/AiToolSpecToOpenAI.java | 82 +++++++++ .../component/openai/OpenAIConfiguration.java | 15 ++ .../camel/component/openai/OpenAIProducer.java | 147 ++++++++++++++-- .../openai/OpenAIToolExecutionProducer.java | 184 ++++++++++++++++----- .../component/openai/AiToolSpecToOpenAITest.java | 137 +++++++++++++++ .../openai/OpenAIToolErrorStrategyTest.java | 2 +- 10 files changed, 537 insertions(+), 78 deletions(-) diff --git a/components/camel-ai/camel-openai/pom.xml b/components/camel-ai/camel-openai/pom.xml index 9bafddb02bd2..251a9ed5e275 100644 --- a/components/camel-ai/camel-openai/pom.xml +++ b/components/camel-ai/camel-openai/pom.xml @@ -43,6 +43,11 @@ <artifactId>camel-support</artifactId> </dependency> + <dependency> + <groupId>org.apache.camel</groupId> + <artifactId>camel-ai-tool</artifactId> + </dependency> + <dependency> <groupId>com.openai</groupId> <artifactId>openai-java</artifactId> diff --git a/components/camel-ai/camel-openai/src/generated/java/org/apache/camel/component/openai/OpenAIEndpointConfigurer.java b/components/camel-ai/camel-openai/src/generated/java/org/apache/camel/component/openai/OpenAIEndpointConfigurer.java index 825570b9a22b..8448d69b9bcd 100644 --- a/components/camel-ai/camel-openai/src/generated/java/org/apache/camel/component/openai/OpenAIEndpointConfigurer.java +++ b/components/camel-ai/camel-openai/src/generated/java/org/apache/camel/component/openai/OpenAIEndpointConfigurer.java @@ -140,6 +140,7 @@ public class OpenAIEndpointConfigurer extends PropertyConfigurerSupport implemen case "stripThinking": target.getConfiguration().setStripThinking(property(camelContext, boolean.class, value)); return true; case "systemmessage": case "systemMessage": target.getConfiguration().setSystemMessage(property(camelContext, java.lang.String.class, value)); return true; + case "tags": target.getConfiguration().setTags(property(camelContext, java.lang.String.class, value)); return true; case "temperature": target.getConfiguration().setTemperature(property(camelContext, java.lang.Double.class, value)); return true; case "toolexecutionerrorstrategy": case "toolExecutionErrorStrategy": target.getConfiguration().setToolExecutionErrorStrategy(property(camelContext, org.apache.camel.component.openai.ToolExecutionErrorStrategy.class, value)); return true; @@ -271,6 +272,7 @@ public class OpenAIEndpointConfigurer extends PropertyConfigurerSupport implemen case "stripThinking": return boolean.class; case "systemmessage": case "systemMessage": return java.lang.String.class; + case "tags": return java.lang.String.class; case "temperature": return java.lang.Double.class; case "toolexecutionerrorstrategy": case "toolExecutionErrorStrategy": return org.apache.camel.component.openai.ToolExecutionErrorStrategy.class; @@ -403,6 +405,7 @@ public class OpenAIEndpointConfigurer extends PropertyConfigurerSupport implemen case "stripThinking": return target.getConfiguration().isStripThinking(); case "systemmessage": case "systemMessage": return target.getConfiguration().getSystemMessage(); + case "tags": return target.getConfiguration().getTags(); case "temperature": return target.getConfiguration().getTemperature(); case "toolexecutionerrorstrategy": case "toolExecutionErrorStrategy": return target.getConfiguration().getToolExecutionErrorStrategy(); diff --git a/components/camel-ai/camel-openai/src/generated/java/org/apache/camel/component/openai/OpenAIEndpointUriFactory.java b/components/camel-ai/camel-openai/src/generated/java/org/apache/camel/component/openai/OpenAIEndpointUriFactory.java index 60bf0d2c653d..b0ecf1364fa6 100644 --- a/components/camel-ai/camel-openai/src/generated/java/org/apache/camel/component/openai/OpenAIEndpointUriFactory.java +++ b/components/camel-ai/camel-openai/src/generated/java/org/apache/camel/component/openai/OpenAIEndpointUriFactory.java @@ -24,7 +24,7 @@ public class OpenAIEndpointUriFactory extends org.apache.camel.support.component private static final Set<String> ENDPOINT_IDENTITY_PROPERTY_NAMES; private static final Map<String, String> MULTI_VALUE_PREFIXES; static { - Set<String> props = new HashSet<>(65); + Set<String> props = new HashSet<>(66); props.add("additionalBodyProperty"); props.add("additionalHeader"); props.add("additionalResponseHeader"); @@ -86,6 +86,7 @@ public class OpenAIEndpointUriFactory extends org.apache.camel.support.component props.add("streaming"); props.add("stripThinking"); props.add("systemMessage"); + props.add("tags"); props.add("temperature"); props.add("toolExecutionErrorStrategy"); props.add("topP"); 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 75596ffafff6..7bddb08018fd 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 @@ -133,23 +133,24 @@ "streaming": { "index": 44, "kind": "parameter", "displayName": "Streaming", "group": "producer", "label": "", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Enable streaming responses" }, "stripThinking": { "index": 45, "kind": "parameter", "displayName": "Strip Thinking", "group": "producer", "label": "", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Strip ... blocks from model responses (used by reasoning models like [...] "systemMessage": { "index": 46, "kind": "parameter", "displayName": "System Message", "group": "producer", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "System message to prepend. When set and conversationMemory is enabled, the conversat [...] - "temperature": { "index": 47, "kind": "parameter", "displayName": "Temperature", "group": "producer", "label": "", "required": false, "type": "number", "javaType": "java.lang.Double", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Temperature for response generation (0.0 to 2.0)" }, - "toolExecutionErrorStrategy": { "index": 48, "kind": "parameter", "displayName": "Tool Execution Error Strategy", "group": "producer", "label": "", "required": false, "type": "enum", "javaType": "org.apache.camel.component.openai.ToolExecutionErrorStrategy", "enum": [ "failExchange", "repromptModel" ], "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "failExchange", "configurationClass": "org.apache.camel.component.openai.OpenAIConfigur [...] - "topP": { "index": 49, "kind": "parameter", "displayName": "Top P", "group": "producer", "label": "", "required": false, "type": "number", "javaType": "java.lang.Double", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Top P for response generation (0.0 to 1.0)" }, - "userMessage": { "index": 50, "kind": "parameter", "displayName": "User Message", "group": "producer", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Default user message text to use when no prompt is provided" }, - "lazyStartProducer": { "index": 51, "kind": "parameter", "displayName": "Lazy Start Producer", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether the producer should be started lazy (on the first message). By starting lazy you can use this to allow CamelContext and routes to startup in situations where a produ [...] - "oauthProfile": { "index": 52, "kind": "parameter", "displayName": "Oauth Profile", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "OAuth profile name for obtaining an access token via the OAuth 2.0 Client Cred [...] - "sslContextParameters": { "index": 53, "kind": "parameter", "displayName": "Ssl Context Parameters", "group": "security", "label": "security", "required": false, "type": "object", "javaType": "org.apache.camel.support.jsse.SSLContextParameters", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "SSLContextParameters to use [...] - "sslEndpointAlgorithm": { "index": 54, "kind": "parameter", "displayName": "Ssl Endpoint Algorithm", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "security": "insecure:ssl", "insecureValue": "none", "defaultValue": "https", "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", [...] - "sslKeymanagerAlgorithm": { "index": 55, "kind": "parameter", "displayName": "Ssl Keymanager Algorithm", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "SunX509", "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "The algorithm used by the key [...] - "sslKeyPassword": { "index": 56, "kind": "parameter", "displayName": "Ssl Key Password", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "The password of the private key in the key store file" }, - "sslKeystoreLocation": { "index": 57, "kind": "parameter", "displayName": "Ssl Keystore Location", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "The location of the key store file. This is optional and can be [...] - "sslKeystorePassword": { "index": 58, "kind": "parameter", "displayName": "Ssl Keystore Password", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "The store password for the key store file" }, - "sslKeystoreType": { "index": 59, "kind": "parameter", "displayName": "Ssl Keystore Type", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "JKS", "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "The file format of the key store file" }, - "sslProtocol": { "index": 60, "kind": "parameter", "displayName": "Ssl Protocol", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "TLSv1.3", "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "The SSL protocol used to generate the SSLContext" }, - "sslTrustmanagerAlgorithm": { "index": 61, "kind": "parameter", "displayName": "Ssl Trustmanager Algorithm", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "PKIX", "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "The algorithm used by the tru [...] - "sslTruststoreLocation": { "index": 62, "kind": "parameter", "displayName": "Ssl Truststore Location", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "The location of the trust store file, used to validate the [...] - "sslTruststorePassword": { "index": 63, "kind": "parameter", "displayName": "Ssl Truststore Password", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "The password for the trust store file. [...] - "sslTruststoreType": { "index": 64, "kind": "parameter", "displayName": "Ssl Truststore Type", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "JKS", "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "The file format of the trust store file" } + "tags": { "index": 47, "kind": "parameter", "displayName": "Tags", "group": "producer", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Comma-separated tags to filter tools from the shared AiToolRegistry. Tools registered via ai-tool: cons [...] + "temperature": { "index": 48, "kind": "parameter", "displayName": "Temperature", "group": "producer", "label": "", "required": false, "type": "number", "javaType": "java.lang.Double", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Temperature for response generation (0.0 to 2.0)" }, + "toolExecutionErrorStrategy": { "index": 49, "kind": "parameter", "displayName": "Tool Execution Error Strategy", "group": "producer", "label": "", "required": false, "type": "enum", "javaType": "org.apache.camel.component.openai.ToolExecutionErrorStrategy", "enum": [ "failExchange", "repromptModel" ], "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "failExchange", "configurationClass": "org.apache.camel.component.openai.OpenAIConfigur [...] + "topP": { "index": 50, "kind": "parameter", "displayName": "Top P", "group": "producer", "label": "", "required": false, "type": "number", "javaType": "java.lang.Double", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Top P for response generation (0.0 to 1.0)" }, + "userMessage": { "index": 51, "kind": "parameter", "displayName": "User Message", "group": "producer", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Default user message text to use when no prompt is provided" }, + "lazyStartProducer": { "index": 52, "kind": "parameter", "displayName": "Lazy Start Producer", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether the producer should be started lazy (on the first message). By starting lazy you can use this to allow CamelContext and routes to startup in situations where a produ [...] + "oauthProfile": { "index": 53, "kind": "parameter", "displayName": "Oauth Profile", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "OAuth profile name for obtaining an access token via the OAuth 2.0 Client Cred [...] + "sslContextParameters": { "index": 54, "kind": "parameter", "displayName": "Ssl Context Parameters", "group": "security", "label": "security", "required": false, "type": "object", "javaType": "org.apache.camel.support.jsse.SSLContextParameters", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "SSLContextParameters to use [...] + "sslEndpointAlgorithm": { "index": 55, "kind": "parameter", "displayName": "Ssl Endpoint Algorithm", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "security": "insecure:ssl", "insecureValue": "none", "defaultValue": "https", "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", [...] + "sslKeymanagerAlgorithm": { "index": 56, "kind": "parameter", "displayName": "Ssl Keymanager Algorithm", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "SunX509", "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "The algorithm used by the key [...] + "sslKeyPassword": { "index": 57, "kind": "parameter", "displayName": "Ssl Key Password", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "The password of the private key in the key store file" }, + "sslKeystoreLocation": { "index": 58, "kind": "parameter", "displayName": "Ssl Keystore Location", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "The location of the key store file. This is optional and can be [...] + "sslKeystorePassword": { "index": 59, "kind": "parameter", "displayName": "Ssl Keystore Password", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "The store password for the key store file" }, + "sslKeystoreType": { "index": 60, "kind": "parameter", "displayName": "Ssl Keystore Type", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "JKS", "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "The file format of the key store file" }, + "sslProtocol": { "index": 61, "kind": "parameter", "displayName": "Ssl Protocol", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "TLSv1.3", "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "The SSL protocol used to generate the SSLContext" }, + "sslTrustmanagerAlgorithm": { "index": 62, "kind": "parameter", "displayName": "Ssl Trustmanager Algorithm", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "PKIX", "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "The algorithm used by the tru [...] + "sslTruststoreLocation": { "index": 63, "kind": "parameter", "displayName": "Ssl Truststore Location", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "The location of the trust store file, used to validate the [...] + "sslTruststorePassword": { "index": 64, "kind": "parameter", "displayName": "Ssl Truststore Password", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "The password for the trust store file. [...] + "sslTruststoreType": { "index": 65, "kind": "parameter", "displayName": "Ssl Truststore Type", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "JKS", "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "The file format of the trust store file" } } } diff --git a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/AiToolSpecToOpenAI.java b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/AiToolSpecToOpenAI.java new file mode 100644 index 000000000000..bb29b9c2e0ed --- /dev/null +++ b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/AiToolSpecToOpenAI.java @@ -0,0 +1,82 @@ +/* + * 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 java.util.Map; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.openai.core.JsonValue; +import com.openai.models.FunctionDefinition; +import com.openai.models.FunctionParameters; +import com.openai.models.chat.completions.ChatCompletionFunctionTool; +import org.apache.camel.component.ai.tool.AiToolSpec; + +/** + * Converts {@link AiToolSpec} instances to OpenAI {@link ChatCompletionFunctionTool} objects. + * <p> + * Uses the pre-built JSON Schema string from {@link AiToolSpec#getParametersJsonSchema()} and parses it into the OpenAI + * SDK's {@link FunctionParameters} format via {@link JsonValue#from(Object)}. + */ +final class AiToolSpecToOpenAI { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() { + }; + + private AiToolSpecToOpenAI() { + } + + /** + * Converts an {@link AiToolSpec} to an OpenAI {@link ChatCompletionFunctionTool}. + * + * @param spec the tool specification to convert + * @return the OpenAI function tool definition + */ + static ChatCompletionFunctionTool toFunctionTool(AiToolSpec spec) { + FunctionDefinition.Builder funcBuilder = FunctionDefinition.builder() + .name(spec.getName()); + + if (spec.getDescription() != null) { + funcBuilder.description(spec.getDescription()); + } + + String jsonSchema = spec.getParametersJsonSchema(); + if (jsonSchema != null && !jsonSchema.isEmpty()) { + try { + Map<String, Object> schemaMap = OBJECT_MAPPER.readValue(jsonSchema, MAP_TYPE); + FunctionParameters.Builder paramsBuilder = FunctionParameters.builder(); + + if (!schemaMap.containsKey("type")) { + paramsBuilder.putAdditionalProperty("type", JsonValue.from("object")); + } + for (Map.Entry<String, Object> entry : schemaMap.entrySet()) { + paramsBuilder.putAdditionalProperty(entry.getKey(), JsonValue.from(entry.getValue())); + } + + funcBuilder.parameters(paramsBuilder.build()); + } catch (Exception e) { + throw new IllegalArgumentException( + "Failed to parse JSON Schema for tool '" + spec.getName() + "': " + e.getMessage(), e); + } + } + + return ChatCompletionFunctionTool.builder() + .function(funcBuilder.build()) + .build(); + } +} diff --git a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIConfiguration.java b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIConfiguration.java index 0c866ce17662..f5080e90aa90 100644 --- a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIConfiguration.java +++ b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIConfiguration.java @@ -167,6 +167,13 @@ public class OpenAIConfiguration implements Cloneable { + "(e.g. additionalResponseHeader.reasoning_content=CamelMyReasoningHeader)") private Map<String, Object> additionalResponseHeader; + @UriParam + @Metadata(description = "Comma-separated tags to filter tools from the shared AiToolRegistry. " + + "Tools registered via ai-tool: consumer endpoints with matching tags " + + "are discovered and exposed as OpenAI function-calling tools alongside " + + "any MCP tools. Tools with no tags (default pool) are always included.") + private String tags; + @UriParam(prefix = "mcpServer.", multiValue = true) @Metadata(description = "MCP (Model Context Protocol) server configurations. " + "Define servers using prefix notation: mcpServer.<name>.transportType=stdio|sse|streamableHttp, (Note that sse is deprecated) " @@ -682,6 +689,14 @@ public class OpenAIConfiguration implements Cloneable { this.speechInstructions = speechInstructions; } + public String getTags() { + return tags; + } + + public void setTags(String tags) { + this.tags = tags; + } + public Map<String, Object> getMcpServer() { return mcpServer; } 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 d36c7d6e48e2..9240a3bf9418 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 @@ -24,8 +24,10 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.Base64; import java.util.Iterator; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -59,8 +61,14 @@ import org.apache.camel.CamelExchangeException; import org.apache.camel.Exchange; import org.apache.camel.Message; import org.apache.camel.WrappedFile; +import org.apache.camel.component.ai.tool.AiToolExecutor; +import org.apache.camel.component.ai.tool.AiToolParameterHelper; +import org.apache.camel.component.ai.tool.AiToolRegistry; +import org.apache.camel.component.ai.tool.AiToolResult; +import org.apache.camel.component.ai.tool.AiToolSpec; import org.apache.camel.spi.Synchronization; import org.apache.camel.support.DefaultAsyncProducer; +import org.apache.camel.support.ExchangeHelper; import org.apache.camel.support.ResourceHelper; import org.apache.camel.util.ObjectHelper; import org.slf4j.Logger; @@ -204,15 +212,26 @@ public class OpenAIProducer extends DefaultAsyncProducer { } } + // Discover Camel route tools from AiToolRegistry by tags + Map<String, AiToolSpec> camelRouteTools = discoverCamelRouteTools(config); + boolean hasCamelRouteTools = !camelRouteTools.isEmpty(); + if (hasCamelRouteTools) { + for (AiToolSpec spec : camelRouteTools.values()) { + paramsBuilder.addTool(AiToolSpecToOpenAI.toFunctionTool(spec)); + } + } + + boolean hasAnyTools = hasMcpTools || hasCamelRouteTools; + ChatCompletionCreateParams params = paramsBuilder.build(); - if (Boolean.TRUE.equals(streaming) && hasMcpTools && config.isAutoToolExecution()) { - LOG.info("Streaming with MCP tools is not supported; falling back to non-streaming for the agentic loop"); - processNonStreaming(exchange, params, config); + if (Boolean.TRUE.equals(streaming) && hasAnyTools && config.isAutoToolExecution()) { + LOG.info("Streaming with tools is not supported; falling back to non-streaming for the agentic loop"); + processNonStreaming(exchange, params, config, camelRouteTools); } else if (Boolean.TRUE.equals(streaming)) { processStreaming(exchange, params); } else { - processNonStreaming(exchange, params, config); + processNonStreaming(exchange, params, config, camelRouteTools); } } @@ -437,17 +456,20 @@ public class OpenAIProducer extends DefaultAsyncProducer { .build()); } - private void processNonStreaming(Exchange exchange, ChatCompletionCreateParams params, OpenAIConfiguration config) + private void processNonStreaming( + Exchange exchange, ChatCompletionCreateParams params, OpenAIConfiguration config, + Map<String, AiToolSpec> camelRouteTools) throws Exception { List<ChatCompletionFunctionTool> mcpTools = getEndpoint().getMcpToolState().tools(); boolean hasMcpTools = mcpTools != null && !mcpTools.isEmpty(); + boolean hasAnyTools = hasMcpTools || !camelRouteTools.isEmpty(); - if (!hasMcpTools || !config.isAutoToolExecution()) { - // Path A: No MCP tools or auto-execution disabled -- existing behavior + if (!hasAnyTools || !config.isAutoToolExecution()) { + // Path A: No tools or auto-execution disabled -- existing behavior processNonStreamingSimple(exchange, params, config); } else { - // Path B: MCP tools with agentic loop - processNonStreamingAgentic(exchange, params, config); + // Path B: Tools with agentic loop (MCP and/or Camel route tools) + processNonStreamingAgentic(exchange, params, config, camelRouteTools); } } @@ -474,12 +496,17 @@ public class OpenAIProducer extends DefaultAsyncProducer { } private void processNonStreamingAgentic( - Exchange exchange, ChatCompletionCreateParams params, OpenAIConfiguration config) + Exchange exchange, ChatCompletionCreateParams params, OpenAIConfiguration config, + Map<String, AiToolSpec> camelRouteTools) throws Exception { int maxIterations = config.getMaxToolIterations(); + + Set<String> availableToolNames = new java.util.LinkedHashSet<>(); + availableToolNames.addAll(getEndpoint().getMcpToolState().toolClientMap().keySet()); + availableToolNames.addAll(camelRouteTools.keySet()); LOG.debug("Starting agentic loop with maxToolIterations={}, available tools: {}", maxIterations, - getEndpoint().getMcpToolState().toolClientMap().keySet()); + availableToolNames); // Rebuild the builder from the immutable params so we can accumulate messages ChatCompletionCreateParams.Builder paramsBuilder = params.toBuilder(); @@ -542,15 +569,26 @@ public class OpenAIProducer extends DefaultAsyncProducer { String toolCallId = toolCall.asFunction().id(); toolCallsLog.add(toolName); + // Check if the tool is a Camel route tool first + AiToolSpec camelSpec = camelRouteTools.get(toolName); + if (camelSpec != null) { + String resultContent = executeCamelRouteTool(camelSpec, argsJson, exchange, config); + LOG.debug("Camel route tool '{}' result: {}", toolName, resultContent); + batchResults.add(new ToolResultEntry(toolCallId, resultContent)); + allReturnDirect = false; // Camel route tools do not support returnDirect + continue; + } + + // Fall back to MCP tool dispatch McpToolState mcpToolState = getEndpoint().getMcpToolState(); McpSyncClient mcpClient = mcpToolState.toolClientMap().get(toolName); if (mcpClient == null) { if (config.getHallucinatedToolNameStrategy() == HallucinatedToolNameStrategy.FAIL_EXCHANGE) { throw new IllegalStateException( - "Tool '" + toolName + "' not found in any configured MCP server"); + "Tool '" + toolName + "' not found in any configured tool source"); } // repromptModel: send a corrective tool result listing available tools - String available = String.join(", ", mcpToolState.toolClientMap().keySet()); + String available = String.join(", ", availableToolNames); String errorMsg = "Error: tool '" + toolName + "' does not exist. Available tools: " + available; LOG.warn("Hallucinated tool name '{}', sending corrective result to model", toolName); @@ -633,6 +671,89 @@ public class OpenAIProducer extends DefaultAsyncProducer { "Max tool iterations (%d) exceeded. Tools called: %s".formatted(maxIterations, toolCallsLog)); } + /** + * Executes a Camel route tool via {@link AiToolExecutor}, handling the exchange lifecycle and error strategies. + */ + private String executeCamelRouteTool( + AiToolSpec spec, String argsJson, Exchange exchange, OpenAIConfiguration config) + throws Exception { + LOG.debug("Executing Camel route tool '{}' with args: {}", spec.getName(), argsJson); + + Map<String, Object> argsMap; + try { + if (argsJson == null || argsJson.trim().isEmpty()) { + argsMap = Map.of(); + } else { + argsMap = OBJECT_MAPPER.readValue(argsJson, Map.class); + } + } catch (JsonProcessingException e) { + if (config.getToolExecutionErrorStrategy() == ToolExecutionErrorStrategy.FAIL_EXCHANGE) { + throw e; + } + LOG.warn("Invalid tool arguments for Camel route tool '{}': {}", spec.getName(), argsJson, e); + return "Error: invalid tool arguments: " + e.getMessage(); + } + + // Isolate tool execution in its own exchange copy + Exchange toolExchange = ExchangeHelper.createCopy(exchange, true); + try { + AiToolResult result = AiToolExecutor.execute(spec, argsMap, toolExchange); + return handleCamelToolResult(spec.getName(), result, config); + } catch (Exception e) { + if (config.getToolExecutionErrorStrategy() == ToolExecutionErrorStrategy.FAIL_EXCHANGE) { + throw e; + } + LOG.warn("Camel route tool '{}' execution failed: {}", spec.getName(), e.getMessage(), e); + return "Error: Tool execution failed: " + e.getMessage(); + } + } + + /** + * Converts an {@link AiToolResult} to a string for the LLM, respecting the configured error strategy. + */ + private String handleCamelToolResult(String toolName, AiToolResult result, OpenAIConfiguration config) + throws Exception { + if (result instanceof AiToolResult.Success success) { + return success.value(); + } else if (result instanceof AiToolResult.ArgumentError error) { + if (config.getToolExecutionErrorStrategy() == ToolExecutionErrorStrategy.FAIL_EXCHANGE) { + throw error.cause(); + } + LOG.warn("Camel route tool '{}' argument error: {}", toolName, error.message(), error.cause()); + return "Error: invalid tool arguments: " + error.message(); + } else if (result instanceof AiToolResult.ExecutionError error) { + if (config.getToolExecutionErrorStrategy() == ToolExecutionErrorStrategy.FAIL_EXCHANGE) { + throw error.cause(); + } + LOG.warn("Camel route tool '{}' execution error: {}", toolName, error.message(), error.cause()); + return "Error: Tool execution failed"; + } + return "Tool execution failed"; + } + + /** + * Discovers Camel route tools from the shared {@link AiToolRegistry} based on the configured tags. + */ + private Map<String, AiToolSpec> discoverCamelRouteTools(OpenAIConfiguration config) { + String tags = config.getTags(); + if (ObjectHelper.isEmpty(tags)) { + return Map.of(); + } + + AiToolRegistry registry = AiToolRegistry.getOrCreate(getEndpoint().getCamelContext()); + String[] tagArray = AiToolParameterHelper.splitTags(tags); + + Map<String, AiToolSpec> toolsByName = new LinkedHashMap<>(); + for (String tag : tagArray) { + for (AiToolSpec spec : registry.getToolsByTag(tag.trim())) { + toolsByName.putIfAbsent(spec.getName(), spec); + } + } + + LOG.debug("Discovered {} Camel route tools for tags: {}", toolsByName.size(), tags); + return toolsByName; + } + private void setAgenticTokenHeaders(Message message, OpenAIAgenticTokenTracker tokenTracker) { message.setHeader(OpenAIConstants.AGENTIC_PROMPT_TOKENS, tokenTracker.getPromptTokens()); message.setHeader(OpenAIConstants.AGENTIC_COMPLETION_TOKENS, tokenTracker.getCompletionTokens()); diff --git a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIToolExecutionProducer.java b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIToolExecutionProducer.java index a5313f0d7089..ca305c5c66ca 100644 --- a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIToolExecutionProducer.java +++ b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIToolExecutionProducer.java @@ -17,8 +17,11 @@ package org.apache.camel.component.openai; import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; import com.fasterxml.jackson.core.JsonProcessingException; @@ -32,7 +35,14 @@ import com.openai.models.chat.completions.ChatCompletionUserMessageParam; import io.modelcontextprotocol.client.McpSyncClient; import io.modelcontextprotocol.spec.McpSchema; import org.apache.camel.Exchange; +import org.apache.camel.component.ai.tool.AiToolExecutor; +import org.apache.camel.component.ai.tool.AiToolParameterHelper; +import org.apache.camel.component.ai.tool.AiToolRegistry; +import org.apache.camel.component.ai.tool.AiToolResult; +import org.apache.camel.component.ai.tool.AiToolSpec; import org.apache.camel.support.DefaultProducer; +import org.apache.camel.support.ExchangeHelper; +import org.apache.camel.util.ObjectHelper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -124,64 +134,80 @@ public class OpenAIToolExecutionProducer extends DefaultProducer { .toolCalls(toolCalls) .build())); - // Execute each tool call via MCP and add tool result messages - if (getEndpoint().getMcpToolState().toolClientMap().isEmpty()) { + // Discover Camel route tools from AiToolRegistry + Map<String, AiToolSpec> camelRouteTools = discoverCamelRouteTools(config); + boolean hasMcpTools = !getEndpoint().getMcpToolState().toolClientMap().isEmpty(); + boolean hasCamelRouteTools = !camelRouteTools.isEmpty(); + + if (!hasMcpTools && !hasCamelRouteTools) { throw new IllegalStateException( - "No MCP tool clients configured on the endpoint. Configure mcpServer.* parameters."); + "No tool sources configured on the endpoint. Configure mcpServer.* parameters or tags for Camel route tools."); } + // Build the available tool name set for hallucinated tool handling + Set<String> availableToolNames = new LinkedHashSet<>(); + availableToolNames.addAll(getEndpoint().getMcpToolState().toolClientMap().keySet()); + availableToolNames.addAll(camelRouteTools.keySet()); + int executedCount = 0; for (ChatCompletionMessageToolCall toolCall : toolCalls) { String toolName = toolCall.asFunction().function().name(); String argsJson = toolCall.asFunction().function().arguments(); String toolCallId = toolCall.asFunction().id(); - McpToolState mcpToolState = getEndpoint().getMcpToolState(); - McpSyncClient mcpClient = mcpToolState.toolClientMap().get(toolName); - if (mcpClient == null) { - if (config.getHallucinatedToolNameStrategy() == HallucinatedToolNameStrategy.FAIL_EXCHANGE) { - throw new IllegalStateException( - "Tool '" + toolName + "' not found in any configured MCP server"); - } - // repromptModel: send a corrective tool result listing available tools - String available = String.join(", ", mcpToolState.toolClientMap().keySet()); - String errorMsg = "Error: tool '" + toolName - + "' does not exist. Available tools: " + available; - LOG.warn("Hallucinated tool name '{}', sending corrective result to model", toolName); - history.add(ChatCompletionMessageParam.ofTool( - ChatCompletionToolMessageParam.builder() - .toolCallId(toolCallId) - .content(errorMsg) - .build())); - executedCount++; - continue; - } - String resultContent; - try { - Map<String, Object> argsMap = OBJECT_MAPPER.readValue(argsJson, Map.class); - McpSchema.CallToolResult toolResult - = getEndpoint().callTool(mcpClient, toolName, argsMap); - - if (Boolean.TRUE.equals(toolResult.isError())) { - resultContent = "Error: " + extractTextContent(toolResult.content()); - LOG.warn("MCP tool '{}' returned error: {}", toolName, resultContent); - } else { - resultContent = extractTextContent(toolResult.content()); - } - } catch (JsonProcessingException e) { - if (config.getToolExecutionErrorStrategy() == ToolExecutionErrorStrategy.FAIL_EXCHANGE) { - throw e; + // Check if the tool is a Camel route tool first + AiToolSpec camelSpec = camelRouteTools.get(toolName); + if (camelSpec != null) { + resultContent = executeCamelRouteTool(camelSpec, argsJson, exchange, config); + } else { + // Fall back to MCP tool dispatch + McpToolState mcpToolState = getEndpoint().getMcpToolState(); + McpSyncClient mcpClient = mcpToolState.toolClientMap().get(toolName); + if (mcpClient == null) { + if (config.getHallucinatedToolNameStrategy() == HallucinatedToolNameStrategy.FAIL_EXCHANGE) { + throw new IllegalStateException( + "Tool '" + toolName + "' not found in any configured tool source"); + } + // repromptModel: send a corrective tool result listing available tools + String available = String.join(", ", availableToolNames); + String errorMsg = "Error: tool '" + toolName + + "' does not exist. Available tools: " + available; + LOG.warn("Hallucinated tool name '{}', sending corrective result to model", toolName); + history.add(ChatCompletionMessageParam.ofTool( + ChatCompletionToolMessageParam.builder() + .toolCallId(toolCallId) + .content(errorMsg) + .build())); + executedCount++; + continue; } - LOG.warn("Invalid tool arguments for '{}': {}", toolName, argsJson, e); - resultContent = "Error: invalid tool arguments: " + e.getMessage(); - } catch (Exception e) { - if (config.getToolExecutionErrorStrategy() == ToolExecutionErrorStrategy.FAIL_EXCHANGE) { - throw e; + + try { + Map<String, Object> argsMap = OBJECT_MAPPER.readValue(argsJson, Map.class); + McpSchema.CallToolResult toolResult + = getEndpoint().callTool(mcpClient, toolName, argsMap); + + if (Boolean.TRUE.equals(toolResult.isError())) { + resultContent = "Error: " + extractTextContent(toolResult.content()); + LOG.warn("MCP tool '{}' returned error: {}", toolName, resultContent); + } else { + resultContent = extractTextContent(toolResult.content()); + } + } catch (JsonProcessingException e) { + if (config.getToolExecutionErrorStrategy() == ToolExecutionErrorStrategy.FAIL_EXCHANGE) { + throw e; + } + LOG.warn("Invalid tool arguments for '{}': {}", toolName, argsJson, e); + resultContent = "Error: invalid tool arguments: " + e.getMessage(); + } catch (Exception e) { + if (config.getToolExecutionErrorStrategy() == ToolExecutionErrorStrategy.FAIL_EXCHANGE) { + throw e; + } + LOG.warn("MCP tool '{}' execution failed: {}", toolName, e.getMessage(), e); + resultContent = "Error: Tool execution failed: " + e.getMessage(); } - LOG.warn("MCP tool '{}' execution failed: {}", toolName, e.getMessage(), e); - resultContent = "Error: Tool execution failed: " + e.getMessage(); } history.add(ChatCompletionMessageParam.ofTool( @@ -198,6 +224,74 @@ public class OpenAIToolExecutionProducer extends DefaultProducer { exchange.getMessage().setHeader(OpenAIConstants.TOOL_ITERATIONS, executedCount); } + private Map<String, AiToolSpec> discoverCamelRouteTools(OpenAIConfiguration config) { + String tags = config.getTags(); + if (ObjectHelper.isEmpty(tags)) { + return Map.of(); + } + + AiToolRegistry registry = AiToolRegistry.getOrCreate(getEndpoint().getCamelContext()); + String[] tagArray = AiToolParameterHelper.splitTags(tags); + + Map<String, AiToolSpec> toolsByName = new LinkedHashMap<>(); + for (String tag : tagArray) { + for (AiToolSpec spec : registry.getToolsByTag(tag.trim())) { + toolsByName.putIfAbsent(spec.getName(), spec); + } + } + + LOG.debug("Discovered {} Camel route tools for tags: {}", toolsByName.size(), tags); + return toolsByName; + } + + private String executeCamelRouteTool( + AiToolSpec spec, String argsJson, Exchange exchange, OpenAIConfiguration config) + throws Exception { + LOG.debug("Executing Camel route tool '{}' with args: {}", spec.getName(), argsJson); + + Map<String, Object> argsMap; + try { + if (argsJson == null || argsJson.trim().isEmpty()) { + argsMap = Map.of(); + } else { + argsMap = OBJECT_MAPPER.readValue(argsJson, Map.class); + } + } catch (JsonProcessingException e) { + if (config.getToolExecutionErrorStrategy() == ToolExecutionErrorStrategy.FAIL_EXCHANGE) { + throw e; + } + LOG.warn("Invalid tool arguments for Camel route tool '{}': {}", spec.getName(), argsJson, e); + return "Error: invalid tool arguments: " + e.getMessage(); + } + + Exchange toolExchange = ExchangeHelper.createCopy(exchange, true); + try { + AiToolResult result = AiToolExecutor.execute(spec, argsMap, toolExchange); + if (result instanceof AiToolResult.Success success) { + return success.value(); + } else if (result instanceof AiToolResult.ArgumentError error) { + if (config.getToolExecutionErrorStrategy() == ToolExecutionErrorStrategy.FAIL_EXCHANGE) { + throw error.cause(); + } + LOG.warn("Camel route tool '{}' argument error: {}", spec.getName(), error.message(), error.cause()); + return "Error: invalid tool arguments: " + error.message(); + } else if (result instanceof AiToolResult.ExecutionError error) { + if (config.getToolExecutionErrorStrategy() == ToolExecutionErrorStrategy.FAIL_EXCHANGE) { + throw error.cause(); + } + LOG.warn("Camel route tool '{}' execution error: {}", spec.getName(), error.message(), error.cause()); + return "Error: Tool execution failed"; + } + return "Tool execution failed"; + } catch (Exception e) { + if (config.getToolExecutionErrorStrategy() == ToolExecutionErrorStrategy.FAIL_EXCHANGE) { + throw e; + } + LOG.warn("Camel route tool '{}' execution failed: {}", spec.getName(), e.getMessage(), e); + return "Error: Tool execution failed: " + e.getMessage(); + } + } + private String extractTextContent(List<McpSchema.Content> contents) { if (contents == null || contents.isEmpty()) { return ""; diff --git a/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/AiToolSpecToOpenAITest.java b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/AiToolSpecToOpenAITest.java new file mode 100644 index 000000000000..1afdaa1a788a --- /dev/null +++ b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/AiToolSpecToOpenAITest.java @@ -0,0 +1,137 @@ +/* + * 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 java.util.Map; + +import com.openai.core.JsonValue; +import com.openai.models.FunctionParameters; +import com.openai.models.chat.completions.ChatCompletionFunctionTool; +import org.apache.camel.component.ai.tool.AiToolParameterHelper; +import org.apache.camel.component.ai.tool.AiToolSpec; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class AiToolSpecToOpenAITest { + + @Test + void convertFullSpec() { + Map<String, String> params = Map.of( + "city", "string", + "city.description", "The city name", + "city.required", "true", + "unit", "string", + "unit.enum", "celsius,fahrenheit", + "unit.description", "Temperature unit"); + + Map<String, AiToolParameterHelper.ParameterDef> defs = AiToolParameterHelper.parseParameterMetadata(params); + String jsonSchema = AiToolParameterHelper.buildJsonSchema(params); + + AiToolSpec spec = new AiToolSpec("getWeather", "Get current weather", defs, jsonSchema, null); + + ChatCompletionFunctionTool result = AiToolSpecToOpenAI.toFunctionTool(spec); + + assertThat(result.function().name()).isEqualTo("getWeather"); + assertThat(result.function().description()).hasValue("Get current weather"); + assertThat(result.function().parameters()).isPresent(); + + FunctionParameters parameters = result.function().parameters().get(); + Map<String, JsonValue> props = parameters._additionalProperties(); + + assertThat(props.get("type").asString()).contains("object"); + assertThat(props).containsKey("properties"); + } + + @Test + void convertSpecWithoutParameters() { + AiToolSpec spec = new AiToolSpec("noParams", "A tool with no parameters", Map.of(), null, null); + + ChatCompletionFunctionTool result = AiToolSpecToOpenAI.toFunctionTool(spec); + + assertThat(result.function().name()).isEqualTo("noParams"); + assertThat(result.function().description()).hasValue("A tool with no parameters"); + assertThat(result.function().parameters()).isEmpty(); + } + + @Test + void convertSpecWithoutDescription() { + AiToolSpec spec = new AiToolSpec("bareTool", null, Map.of(), null, null); + + ChatCompletionFunctionTool result = AiToolSpecToOpenAI.toFunctionTool(spec); + + assertThat(result.function().name()).isEqualTo("bareTool"); + assertThat(result.function().description()).isEmpty(); + } + + @Test + void convertSpecDefaultsTypeToObject() { + // JSON Schema without "type" key should get "object" defaulted + String jsonSchema = "{\"properties\":{\"x\":{\"type\":\"string\"}}}"; + AiToolSpec spec = new AiToolSpec("testTool", "Test", Map.of(), jsonSchema, null); + + ChatCompletionFunctionTool result = AiToolSpecToOpenAI.toFunctionTool(spec); + + assertThat(result.function().parameters()).isPresent(); + FunctionParameters parameters = result.function().parameters().get(); + assertThat(parameters._additionalProperties().get("type").asString()).contains("object"); + } + + @Test + void convertSpecPreservesRequiredArray() { + String jsonSchema = "{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"]}"; + AiToolSpec spec = new AiToolSpec("withRequired", "Has required", Map.of(), jsonSchema, null); + + ChatCompletionFunctionTool result = AiToolSpecToOpenAI.toFunctionTool(spec); + + FunctionParameters parameters = result.function().parameters().get(); + assertThat(parameters._additionalProperties()).containsKey("required"); + assertThat(parameters._additionalProperties().get("required").asArray()).isNotEmpty(); + } + + @Test + void convertSpecWithEmptyJsonSchema() { + AiToolSpec spec = new AiToolSpec("emptySchema", "Empty", Map.of(), "", null); + + ChatCompletionFunctionTool result = AiToolSpecToOpenAI.toFunctionTool(spec); + + assertThat(result.function().name()).isEqualTo("emptySchema"); + assertThat(result.function().parameters()).isEmpty(); + } + + @Test + void convertSpecWithInvalidJsonSchemaThrows() { + AiToolSpec spec = new AiToolSpec("badSchema", "Bad", Map.of(), "not valid json", null); + + assertThatThrownBy(() -> AiToolSpecToOpenAI.toFunctionTool(spec)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Failed to parse JSON Schema for tool 'badSchema'"); + } + + @Test + void convertSpecPreservesAdditionalPropertiesFalse() { + String jsonSchema = "{\"type\":\"object\",\"properties\":{\"q\":{\"type\":\"string\"}},\"additionalProperties\":false}"; + AiToolSpec spec = new AiToolSpec("strict", "Strict tool", Map.of(), jsonSchema, null); + + ChatCompletionFunctionTool result = AiToolSpecToOpenAI.toFunctionTool(spec); + + FunctionParameters parameters = result.function().parameters().get(); + assertThat(parameters._additionalProperties()).containsKey("additionalProperties"); + assertThat(parameters._additionalProperties().get("additionalProperties").asBoolean()).contains(false); + } +} diff --git a/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIToolErrorStrategyTest.java b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIToolErrorStrategyTest.java index 9a1cbcad3df1..f09d18f69109 100644 --- a/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIToolErrorStrategyTest.java +++ b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIToolErrorStrategyTest.java @@ -185,7 +185,7 @@ class OpenAIToolErrorStrategyTest extends CamelTestSupport { .isInstanceOf(CamelExecutionException.class) .hasCauseInstanceOf(IllegalStateException.class) .cause() - .hasMessageContaining("not found in any configured MCP server"); + .hasMessageContaining("not found in any configured tool source"); } @Test
