Skip to content

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ 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 "maxhistorymessages":
case "maxHistoryMessages": target.getConfiguration().setMaxHistoryMessages(property(camelContext, int.class, value)); return true;
case "maxhistorytokens":
case "maxHistoryTokens": target.getConfiguration().setMaxHistoryTokens(property(camelContext, int.class, value)); return true;
case "maxretries":
case "maxRetries": target.getConfiguration().setMaxRetries(property(camelContext, int.class, value)); return true;
case "maxtokens":
Expand Down Expand Up @@ -165,6 +169,10 @@ public Class<?> getOptionType(String name, boolean ignoreCase) {
case "jsonSchema": return java.lang.String.class;
case "lazystartproducer":
case "lazyStartProducer": return boolean.class;
case "maxhistorymessages":
case "maxHistoryMessages": return int.class;
case "maxhistorytokens":
case "maxHistoryTokens": return int.class;
case "maxretries":
case "maxRetries": return int.class;
case "maxtokens":
Expand Down Expand Up @@ -269,6 +277,10 @@ public Object getOptionValue(Object obj, String name, boolean ignoreCase) {
case "jsonSchema": return target.getConfiguration().getJsonSchema();
case "lazystartproducer":
case "lazyStartProducer": return target.isLazyStartProducer();
case "maxhistorymessages":
case "maxHistoryMessages": return target.getConfiguration().getMaxHistoryMessages();
case "maxhistorytokens":
case "maxHistoryTokens": return target.getConfiguration().getMaxHistoryTokens();
case "maxretries":
case "maxRetries": return target.getConfiguration().getMaxRetries();
case "maxtokens":
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<>(51);
Set<String> props = new HashSet<>(53);
props.add("additionalBodyProperty");
props.add("additionalHeader");
props.add("additionalResponseHeader");
Expand All @@ -45,6 +45,8 @@ public class OpenAIEndpointUriFactory extends org.apache.camel.support.component
props.add("encodingFormat");
props.add("jsonSchema");
props.add("lazyStartProducer");
props.add("maxHistoryMessages");
props.add("maxHistoryTokens");
props.add("maxRetries");
props.add("maxTokens");
props.add("maxToolIterations");
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,8 @@ The JSON schema must be a valid JSON object. Invalid schema strings will result

When `conversationMemory=true`, the component maintains conversation history in the `CamelOpenAIConversationHistory` exchange property (configurable via `conversationHistoryProperty` option). This history is scoped to a single Exchange and allows multi-turn conversations within a route.

Use `maxHistoryMessages` and `maxHistoryTokens` to bound how much history is retained in that property. Oldest conversation segments are dropped first; assistant tool-call blocks are always removed together with their tool results. The most recent segment is always kept, even when it alone exceeds `maxHistoryMessages` or `maxHistoryTokens`. Token limits use a character-count / 4 estimate (including image payload size for multi-modal user messages).

IMPORTANT:

* Conversation history is automatically updated with each assistant response for **non-streaming** responses only
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,22 @@ public class OpenAIConfiguration implements Cloneable {
@Metadata(description = "Exchange property name for storing conversation history")
private String conversationHistoryProperty = "CamelOpenAIConversationHistory";

@UriParam(defaultValue = "0")
@Metadata(description = "When conversationMemory is enabled, retain at most this many messages in the exchange "
+ "conversation history. System and developer messages are prepended separately and are not "
+ "stored in history. Assistant tool-call blocks are kept intact and may retain slightly "
+ "more than this limit to preserve tool result pairing. When 0, no message limit is applied.")
private int maxHistoryMessages;

@UriParam(defaultValue = "0")
@Metadata(description = "When conversationMemory is enabled, trim conversation history using a token estimate "
+ "(character count / 4, including image payload size for multi-modal user messages). "
+ "Oldest segments are dropped first until the estimated tokens are within this limit. "
+ "Assistant tool-call blocks are removed as a unit with their tool results. The most recent "
+ "segment is always retained, even when it alone exceeds this limit. When 0, "
+ "no token limit is applied.")
private int maxHistoryTokens;

@UriParam
@Metadata(description = "Default user message text to use when no prompt is provided", largeInput = true)
private String userMessage;
Expand Down Expand Up @@ -382,6 +398,22 @@ public void setConversationHistoryProperty(String conversationHistoryProperty) {
this.conversationHistoryProperty = conversationHistoryProperty;
}

public int getMaxHistoryMessages() {
return maxHistoryMessages;
}

public void setMaxHistoryMessages(int maxHistoryMessages) {
this.maxHistoryMessages = maxHistoryMessages;
}

public int getMaxHistoryTokens() {
return maxHistoryTokens;
}

public void setMaxHistoryTokens(int maxHistoryTokens) {
this.maxHistoryTokens = maxHistoryTokens;
}

public String getUserMessage() {
return userMessage;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
/*
* 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.ArrayList;
import java.util.List;

import com.openai.models.chat.completions.ChatCompletionAssistantMessageParam;
import com.openai.models.chat.completions.ChatCompletionContentPart;
import com.openai.models.chat.completions.ChatCompletionMessageParam;
import com.openai.models.chat.completions.ChatCompletionMessageToolCall;
import com.openai.models.chat.completions.ChatCompletionToolMessageParam;
import com.openai.models.chat.completions.ChatCompletionUserMessageParam;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Applies sliding-window limits to exchange-scoped OpenAI conversation history. Trimming removes whole segments from
* the oldest side so assistant tool-call blocks stay paired with their tool result messages.
*/
final class OpenAIConversationHistoryTrimmer {

private static final Logger LOG = LoggerFactory.getLogger(OpenAIConversationHistoryTrimmer.class);
private static final int CHARS_PER_TOKEN = 4;

private OpenAIConversationHistoryTrimmer() {
}

static List<ChatCompletionMessageParam> trim(
List<ChatCompletionMessageParam> history, OpenAIConfiguration config) {
if (history == null) {
return null;
}
if (history.isEmpty()) {
return new ArrayList<>();
}

int maxMessages = config.getMaxHistoryMessages();
int maxTokens = config.getMaxHistoryTokens();
if (maxMessages <= 0 && maxTokens <= 0) {
return history;
}

int originalSize = history.size();
List<Segment> segments = buildSegments(history);
int firstSegment = 0;

if (maxMessages > 0) {
firstSegment = findFirstSegmentForMessageLimit(segments, maxMessages);
}

if (maxTokens > 0) {
firstSegment = Math.max(firstSegment,
findFirstSegmentForTokenLimit(history, segments, maxTokens));
}

List<ChatCompletionMessageParam> trimmed;
if (firstSegment >= segments.size()) {
trimmed = new ArrayList<>();
} else {
Segment first = segments.get(firstSegment);
Segment last = segments.get(segments.size() - 1);
trimmed = new ArrayList<>(history.subList(first.start, last.end + 1));
}

int dropped = originalSize - trimmed.size();
if (dropped > 0) {
LOG.debug("Trimmed conversation history: dropped {} message(s), retained {}", dropped, trimmed.size());
}

return trimmed;
}

static int estimateTokens(List<ChatCompletionMessageParam> messages) {
return estimateTokens(messages, 0, messages.size());
}

private static int estimateTokens(List<ChatCompletionMessageParam> messages, int from, int to) {
long chars = 0;
for (int i = from; i < to; i++) {
chars += estimateMessageChars(messages.get(i));
}
return tokensFromChars(chars);
}

private static int tokensFromChars(long chars) {
if (chars <= 0) {
return 0;
}
long maxChars = (long) Integer.MAX_VALUE * CHARS_PER_TOKEN;
if (chars > maxChars) {
return Integer.MAX_VALUE;
}
return (int) ((chars + CHARS_PER_TOKEN - 1) / CHARS_PER_TOKEN);
}

private static int findFirstSegmentForMessageLimit(List<Segment> segments, int maxMessages) {
int firstSegment = segments.size() - 1;
int messageCount = segments.get(firstSegment).messageCount();
while (firstSegment > 0) {
Segment previous = segments.get(firstSegment - 1);
int nextCount = messageCount + previous.messageCount();
if (nextCount > maxMessages) {
break;
}
firstSegment--;
messageCount = nextCount;
}
return firstSegment;
}

private static int findFirstSegmentForTokenLimit(
List<ChatCompletionMessageParam> history, List<Segment> segments, int maxTokens) {
int lastSegment = segments.size() - 1;
Segment last = segments.get(lastSegment);
int lastSegmentTokens = estimateTokens(history, last.start, last.end + 1);
if (lastSegmentTokens > maxTokens) {
LOG.warn(
"Last conversation segment alone exceeds maxHistoryTokens (estimated {} > {}); "
+ "retaining it anyway",
lastSegmentTokens, maxTokens);
return lastSegment;
}

for (int firstSegment = 0; firstSegment < segments.size(); firstSegment++) {
Segment first = segments.get(firstSegment);
if (estimateTokens(history, first.start, last.end + 1) <= maxTokens) {
return firstSegment;
}
}
return lastSegment;
}

private static List<Segment> buildSegments(List<ChatCompletionMessageParam> history) {
List<Segment> segments = new ArrayList<>();
int index = 0;
while (index < history.size()) {
ChatCompletionMessageParam message = history.get(index);
if (isAssistantWithToolCalls(message)) {
int end = index;
while (end + 1 < history.size() && history.get(end + 1).tool().isPresent()) {
end++;
}
segments.add(new Segment(index, end));
index = end + 1;
} else {
segments.add(new Segment(index, index));
index++;
}
}
return segments;
}

private static boolean isAssistantWithToolCalls(ChatCompletionMessageParam message) {
return message.assistant()
.map(assistant -> !assistant.toolCalls().orElse(List.of()).isEmpty())
.orElse(false);
}

private static int estimateMessageChars(ChatCompletionMessageParam message) {
return message.user()
.map(OpenAIConversationHistoryTrimmer::estimateUserMessageChars)
.orElseGet(() -> message.assistant()
.map(OpenAIConversationHistoryTrimmer::estimateAssistantMessageChars)
.orElseGet(() -> message.tool()
.map(OpenAIConversationHistoryTrimmer::estimateToolMessageChars)
.orElse(0)));
}

private static int estimateUserMessageChars(ChatCompletionUserMessageParam user) {
ChatCompletionUserMessageParam.Content content = user.content();
if (content.isText()) {
return content.asText().length();
}
if (content.isArrayOfContentParts()) {
return content.asArrayOfContentParts().stream()
.mapToInt(OpenAIConversationHistoryTrimmer::estimateContentPartChars)
.sum();
}
return 0;
}

private static int estimateAssistantMessageChars(ChatCompletionAssistantMessageParam assistant) {
int chars = assistant.content()
.filter(ChatCompletionAssistantMessageParam.Content::isText)
.map(content -> content.asText().length())
.orElse(0);

for (ChatCompletionMessageToolCall toolCall : assistant.toolCalls().orElse(List.of())) {
chars += toolCall.asFunction().function().name().length();
chars += toolCall.asFunction().function().arguments().length();
}

return chars;
}

private static int estimateToolMessageChars(ChatCompletionToolMessageParam tool) {
ChatCompletionToolMessageParam.Content content = tool.content();
if (content.isText()) {
return content.asText().length();
}
if (content.isArrayOfContentParts()) {
return content.asArrayOfContentParts().stream()
.mapToInt(part -> part.text().length())
.sum();
}
return 0;
}

private static int estimateContentPartChars(ChatCompletionContentPart part) {
int chars = 0;
if (part.text().isPresent()) {
chars += part.asText().text().length();
}
if (part.imageUrl().isPresent()) {
chars += part.asImageUrl().imageUrl().url().length();
}
return chars;
}

private static final class Segment {
private final int start;
private final int end;

private Segment(int start, int end) {
this.start = start;
this.end = end;
}

private int messageCount() {
return end - start + 1;
}
}
}
Loading