Skip to content
Merged
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.

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 @@ -179,6 +181,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 @@ -297,6 +301,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<>(58);
Set<String> props = new HashSet<>(59);
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 @@ -622,6 +630,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