Skip to content
Open

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ public boolean configure(CamelContext camelContext, Object obj, String name, Obj
case "jsonSchema": target.getConfiguration().setJsonSchema(property(camelContext, java.lang.String.class, value)); return true;
case "lazystartproducer":
case "lazyStartProducer": target.setLazyStartProducer(property(camelContext, boolean.class, value)); return true;
case "maxagentictokens":
case "maxAgenticTokens": target.getConfiguration().setMaxAgenticTokens(property(camelContext, long.class, value)); return true;
case "maxhistorymessages":
case "maxHistoryMessages": target.getConfiguration().setMaxHistoryMessages(property(camelContext, int.class, value)); return true;
case "maxhistorytokens":
Expand Down Expand Up @@ -169,6 +171,8 @@ public Class<?> getOptionType(String name, boolean ignoreCase) {
case "jsonSchema": return java.lang.String.class;
case "lazystartproducer":
case "lazyStartProducer": return boolean.class;
case "maxagentictokens":
case "maxAgenticTokens": return long.class;
case "maxhistorymessages":
case "maxHistoryMessages": return int.class;
case "maxhistorytokens":
Expand Down Expand Up @@ -277,6 +281,8 @@ public Object getOptionValue(Object obj, String name, boolean ignoreCase) {
case "jsonSchema": return target.getConfiguration().getJsonSchema();
case "lazystartproducer":
case "lazyStartProducer": return target.isLazyStartProducer();
case "maxagentictokens":
case "maxAgenticTokens": return target.getConfiguration().getMaxAgenticTokens();
case "maxhistorymessages":
case "maxHistoryMessages": return target.getConfiguration().getMaxHistoryMessages();
case "maxhistorytokens":
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<>(53);
Set<String> props = new HashSet<>(54);
props.add("additionalBodyProperty");
props.add("additionalHeader");
props.add("additionalResponseHeader");
Expand All @@ -45,6 +45,7 @@ public class OpenAIEndpointUriFactory extends org.apache.camel.support.component
props.add("encodingFormat");
props.add("jsonSchema");
props.add("lazyStartProducer");
props.add("maxAgenticTokens");
props.add("maxHistoryMessages");
props.add("maxHistoryTokens");
props.add("maxRetries");
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,14 @@ When the model responds with tool calls, the component automatically:

The `maxToolIterations` option (default: 50) prevents infinite loops. If exceeded, an `IllegalStateException` is thrown.

The `maxAgenticTokens` option (default: `0`, unlimited) caps cumulative prompt plus completion tokens across the agentic loop.
When the budget is exceeded after an API call that requests further tool execution, an `IllegalStateException` is thrown and no additional API calls are made.
Enforcement is post-call, so actual spend may exceed the configured budget by up to one API call.
A final text response is still returned when cumulative usage exceeds the budget, because no further spend occurs after that point.

During the agentic loop, `CamelOpenAIAgenticPromptTokens`, `CamelOpenAIAgenticCompletionTokens`, and `CamelOpenAIAgenticTotalTokens` expose cumulative usage across all iterations.
The per-call headers `CamelOpenAIPromptTokens`, `CamelOpenAICompletionTokens`, and `CamelOpenAITotalTokens` reflect only the latest API call.

Set `autoToolExecution=false` to disable the agentic loop and receive raw tool calls in the message body instead:

[tabs]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
* 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 com.openai.models.chat.completions.ChatCompletion;
import com.openai.models.completions.CompletionUsage;

/**
* Tracks cumulative token usage across the MCP agentic loop.
*/
final class OpenAIAgenticTokenTracker {

private long promptTokens;
private long completionTokens;

void addUsage(ChatCompletion response) {
if (response == null) {
return;
}
response.usage().ifPresent(this::addUsage);
}

void addUsage(CompletionUsage usage) {
promptTokens += usage.promptTokens();
completionTokens += usage.completionTokens();
}

long getPromptTokens() {
return promptTokens;
}

long getCompletionTokens() {
return completionTokens;
}

long getTotalTokens() {
return promptTokens + completionTokens;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,14 @@ public class OpenAIConfiguration implements Cloneable {
@Metadata(description = "Maximum number of tool call loop iterations to prevent infinite loops")
private int maxToolIterations = 50;

@UriParam(defaultValue = "0")
@Metadata(description = "Maximum cumulative prompt plus completion tokens allowed across the MCP agentic loop. "
+ "When 0 or negative, no token budget is enforced. Enforcement runs after each API call "
+ "that requests further tool execution, so actual spend may exceed the configured budget "
+ "by up to one call (typically the largest, as the prompt grows each iteration). "
+ "A final text response is returned even when cumulative usage exceeds the budget.")
private long maxAgenticTokens;

@UriParam(defaultValue = "true")
@Metadata(description = "When true and MCP servers are configured, automatically execute tool calls "
+ "and loop back to the model. When false, tool calls are returned as the message body for manual handling.")
Expand Down Expand Up @@ -558,6 +566,14 @@ public void setMaxToolIterations(int maxToolIterations) {
this.maxToolIterations = maxToolIterations;
}

public long getMaxAgenticTokens() {
return maxAgenticTokens;
}

public void setMaxAgenticTokens(long maxAgenticTokens) {
this.maxAgenticTokens = maxAgenticTokens;
}

public boolean isAutoToolExecution() {
return autoToolExecution;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,12 @@ public final class OpenAIConstants {
public static final String RESPONSE_ID = "CamelOpenAIResponseId";
@Metadata(description = "The reason the completion finished (e.g., stop, length, content_filter)", javaType = "String")
public static final String FINISH_REASON = "CamelOpenAIFinishReason";
@Metadata(description = "The number of tokens used in the prompt", javaType = "Integer")
@Metadata(description = "The number of tokens used in the prompt for the latest API call", javaType = "Long")
public static final String PROMPT_TOKENS = "CamelOpenAIPromptTokens";
@Metadata(description = "The number of tokens used in the completion", javaType = "Integer")
@Metadata(description = "The number of tokens used in the completion for the latest API call", javaType = "Long")
public static final String COMPLETION_TOKENS = "CamelOpenAICompletionTokens";
@Metadata(description = "The total number of tokens used (prompt + completion)", javaType = "Integer")
@Metadata(description = "The total number of tokens used (prompt + completion) for the latest API call",
javaType = "Long")
public static final String TOTAL_TOKENS = "CamelOpenAITotalTokens";

// MCP Tool Call Headers
Expand All @@ -84,6 +85,12 @@ public final class OpenAIConstants {
+ "rather than from the LLM",
javaType = "Boolean")
public static final String MCP_RETURN_DIRECT = "CamelOpenAIMcpReturnDirect";
@Metadata(description = "Cumulative prompt tokens consumed across all agentic loop iterations", javaType = "Long")
public static final String AGENTIC_PROMPT_TOKENS = "CamelOpenAIAgenticPromptTokens";
@Metadata(description = "Cumulative completion tokens consumed across all agentic loop iterations", javaType = "Long")
public static final String AGENTIC_COMPLETION_TOKENS = "CamelOpenAIAgenticCompletionTokens";
@Metadata(description = "Cumulative total tokens consumed across all agentic loop iterations", javaType = "Long")
public static final String AGENTIC_TOTAL_TOKENS = "CamelOpenAIAgenticTotalTokens";

// Output Exchange Properties
@Metadata(description = "The complete OpenAI response object", javaType = "com.openai.models.ChatCompletion")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -486,10 +486,14 @@ private void processNonStreamingAgentic(

List<ChatCompletionMessageParam> agenticMessages = new ArrayList<>();
List<String> toolCallsLog = new ArrayList<>();
OpenAIAgenticTokenTracker tokenTracker = new OpenAIAgenticTokenTracker();
int iteration = 0;

while (iteration < maxIterations) {
ChatCompletion response = getEndpoint().getClient().chat().completions().create(paramsBuilder.build());
tokenTracker.addUsage(response);
setAgenticTokenHeaders(exchange.getMessage(), tokenTracker);

ChatCompletion.Choice choice = requireFirstChoice(exchange, response);

if (!isToolCallsFinishReason(choice)) {
Expand All @@ -512,6 +516,8 @@ private void processNonStreamingAgentic(
return;
}

enforceAgenticTokenBudget(config, tokenTracker, iteration);

iteration++;
LOG.debug("Iteration {}: model requested {} tool call(s)", iteration,
choice.message().toolCalls().map(List::size).orElse(0));
Expand Down Expand Up @@ -611,6 +617,24 @@ private void processNonStreamingAgentic(
"Max tool iterations (%d) exceeded. Tools called: %s".formatted(maxIterations, toolCallsLog));
}

private void setAgenticTokenHeaders(Message message, OpenAIAgenticTokenTracker tokenTracker) {
message.setHeader(OpenAIConstants.AGENTIC_PROMPT_TOKENS, tokenTracker.getPromptTokens());
message.setHeader(OpenAIConstants.AGENTIC_COMPLETION_TOKENS, tokenTracker.getCompletionTokens());
message.setHeader(OpenAIConstants.AGENTIC_TOTAL_TOKENS, tokenTracker.getTotalTokens());
}

private void enforceAgenticTokenBudget(
OpenAIConfiguration config, OpenAIAgenticTokenTracker tokenTracker, int iteration) {
long maxAgenticTokens = config.getMaxAgenticTokens();
if (maxAgenticTokens <= 0 || tokenTracker.getTotalTokens() <= maxAgenticTokens) {
return;
}
throw new IllegalStateException(
"Max agentic tokens (%d) exceeded at iteration %d. Cumulative usage: prompt=%d, completion=%d, total=%d"
.formatted(maxAgenticTokens, iteration, tokenTracker.getPromptTokens(),
tokenTracker.getCompletionTokens(), tokenTracker.getTotalTokens()));
}

private String extractTextContent(List<McpSchema.Content> contents) {
if (contents == null || contents.isEmpty()) {
return "";
Expand Down
Loading