Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions components/camel-ai/camel-openai/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ public boolean configure(CamelContext camelContext, Object obj, String name, Obj
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;
Expand Down Expand Up @@ -271,6 +272,7 @@ public Class<?> getOptionType(String name, boolean ignoreCase) {
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;
Expand Down Expand Up @@ -403,6 +405,7 @@ public Object getOptionValue(Object obj, String name, boolean ignoreCase) {
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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) "
Expand Down Expand Up @@ -682,6 +689,14 @@ public void setSpeechInstructions(String speechInstructions) {
this.speechInstructions = speechInstructions;
}

public String getTags() {
return tags;
}

public void setTags(String tags) {
this.tags = tags;
}

public Map<String, Object> getMcpServer() {
return mcpServer;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@
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;
Expand Down Expand Up @@ -59,8 +61,14 @@
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;
Expand Down Expand Up @@ -204,15 +212,26 @@ private void processInternal(Exchange exchange) throws Exception {
}
}

// 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);
}
}

Expand Down Expand Up @@ -437,17 +456,20 @@ private ChatCompletionContentPart createTextContentPart(String text) {
.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);
}
}

Expand All @@ -474,12 +496,17 @@ private void processNonStreamingSimple(
}

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<>();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FQCN violation: new java.util.LinkedHashSet<>() should use the simple class name with an import. The project convention states: "Do NOT use fully qualified class names in Java code." Note that OpenAIToolExecutionProducer in this same PR correctly imports java.util.LinkedHashSet.

Suggested change
Set<String> availableToolNames = new java.util.LinkedHashSet<>();
Set<String> availableToolNames = new LinkedHashSet<>();

(Also add import java.util.LinkedHashSet; alongside the existing import java.util.LinkedHashMap;)

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();
Expand Down Expand Up @@ -542,15 +569,26 @@ private void processNonStreamingAgentic(
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);
Expand Down Expand Up @@ -633,6 +671,89 @@ private void processNonStreamingAgentic(
"Max tool iterations (%d) exceeded. Tools called: %s".formatted(maxIterations, toolCallsLog));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code duplication: discoverCamelRouteTools(), executeCamelRouteTool(), and the result-handling logic are duplicated nearly verbatim between OpenAIProducer and OpenAIToolExecutionProducer (~80 lines each). The duplication is slightly inconsistent — this class extracts result handling into handleCamelToolResult(), while OpenAIToolExecutionProducer inlines the same logic.

Consider extracting the shared code into a package-private helper class (similar to how AiToolSpecToOpenAI is already a shared utility).

}

/**
* 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();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security nit: The AiToolResult Javadoc warns: "Framework adapters MUST NOT return [ExecutionError.message()] verbatim to the LLM without sanitization." The handleCamelToolResult() method correctly returns the generic "Error: Tool execution failed" for ExecutionError, but this outer catch returns e.getMessage() which could include internal details. Consider using the same sanitized message:

Suggested change
return "Error: Tool execution failed: " + e.getMessage();
return "Error: Tool execution failed";

The same pattern applies to OpenAIToolExecutionProducer at line 291.

}
}

/**
* 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());
Expand Down
Loading