From f8e2ab40b401c59fd7e99f20451a5ab40211dc03 Mon Sep 17 00:00:00 2001 From: atiaomar1978-hub Date: Sat, 18 Jul 2026 16:36:53 -0700 Subject: [PATCH 1/5] CAMEL-23394: Limit OpenAI conversation history size Add maxHistoryMessages and maxHistoryTokens options to trim exchange-scoped conversation history before it exceeds model context limits. --- .../openai/OpenAIEndpointConfigurer.java | 12 ++ .../openai/OpenAIEndpointUriFactory.java | 4 +- .../apache/camel/component/openai/openai.json | 60 +++---- .../component/openai/OpenAIConfiguration.java | 28 +++ .../OpenAIConversationHistoryTrimmer.java | 134 ++++++++++++++ .../component/openai/OpenAIProducer.java | 22 ++- .../openai/OpenAIToolExecutionProducer.java | 2 +- .../OpenAIConversationHistorySizeTest.java | 104 +++++++++++ .../OpenAIConversationHistoryTrimmerTest.java | 164 ++++++++++++++++++ 9 files changed, 492 insertions(+), 38 deletions(-) create mode 100644 components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIConversationHistoryTrimmer.java create mode 100644 components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIConversationHistorySizeTest.java create mode 100644 components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIConversationHistoryTrimmerTest.java 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 d1a7248c90d90..bceff715734b2 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 @@ -60,6 +60,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 "maxtokens": case "maxTokens": target.getConfiguration().setMaxTokens(property(camelContext, java.lang.Integer.class, value)); return true; case "maxtooliterations": @@ -157,6 +161,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 "maxtokens": case "maxTokens": return java.lang.Integer.class; case "maxtooliterations": @@ -255,6 +263,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 "maxtokens": case "maxTokens": return target.getConfiguration().getMaxTokens(); case "maxtooliterations": 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 e606b91548d88..2c2c5f6fdc669 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 ENDPOINT_IDENTITY_PROPERTY_NAMES; private static final Map MULTI_VALUE_PREFIXES; static { - Set props = new HashSet<>(48); + Set props = new HashSet<>(50); props.add("additionalBodyProperty"); props.add("additionalResponseHeader"); props.add("apiKey"); @@ -44,6 +44,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("maxTokens"); props.add("maxToolIterations"); props.add("mcpProtocolVersions"); 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 ae4a63a23c818..98753e89aa77c 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 @@ -95,34 +95,36 @@ "embeddingModel": { "index": 16, "kind": "parameter", "displayName": "Embedding Model", "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": "The model to use for embeddings" }, "encodingFormat": { "index": 17, "kind": "parameter", "displayName": "Encoding Format", "group": "producer", "label": "", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "float", "base64" ], "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "base64", "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "The format for embedding output: 'float' for list of floats, 'base64' for compressed format" }, "jsonSchema": { "index": 18, "kind": "parameter", "displayName": "Json Schema", "group": "producer", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "supportFileReference": true, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "JSON schema for structured output validation" }, - "maxTokens": { "index": 19, "kind": "parameter", "displayName": "Max Tokens", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "java.lang.Integer", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Maximum number of tokens to generate" }, - "maxToolIterations": { "index": 20, "kind": "parameter", "displayName": "Max Tool Iterations", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 50, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Maximum number of tool call loop iterations to prevent infinite loops" }, - "mcpProtocolVersions": { "index": 21, "kind": "parameter", "displayName": "Mcp Protocol Versions", "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 list of MCP protocol versions to advertise when connecting to MCP servers using Streamable HTTP transport. When not set, the SDK default is used. Example: 2024-11-05,2025-03-26,2025-06-18" }, - "mcpReconnect": { "index": 22, "kind": "parameter", "displayName": "Mcp Reconnect", "group": "producer", "label": "", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": true, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Automatically reconnect to MCP servers when a tool call fails due to a transport error, and retry the call once." }, - "mcpServer": { "index": 23, "kind": "parameter", "displayName": "Mcp Server", "group": "producer", "label": "", "required": false, "type": "object", "javaType": "java.util.Map", "prefix": "mcpServer.", "multiValue": true, "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "MCP (Model Context Protocol) server configurations. Define servers using prefix notation: mcpServer..transportType=stdiossestreamableHttp, (Note that sse is deprecated) mcpServer..command= (stdio), mcpServer..args= (stdio), mcpServer..url= (sse\/streamableHttp), mcpServer..oauthProfile= (OAuth profile for HTTP auth, requires camel-oauth). This is a multi-value option with prefix: mcpServer." }, - "mcpTimeout": { "index": 24, "kind": "parameter", "displayName": "Mcp Timeout", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 20, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Timeout in seconds for MCP tool call requests. Applies to all MCP operations including tool execution and initialization." }, - "model": { "index": 25, "kind": "parameter", "displayName": "Model", "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": "The model to use for chat completion" }, - "outputClass": { "index": 26, "kind": "parameter", "displayName": "Output Class", "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": "Fully qualified class name for structured output using response format" }, - "storeFullResponse": { "index": 27, "kind": "parameter", "displayName": "Store Full Response", "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": "Store the full response in the exchange property 'CamelOpenAIResponse' in non-streaming mode" }, - "streaming": { "index": 28, "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": 29, "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 Qwen3, DeepSeek-R1). The thinking content is stored in the CamelOpenAIThinkingContent header." }, - "systemMessage": { "index": 30, "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 conversation history is reset." }, - "temperature": { "index": 31, "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)" }, - "topP": { "index": 32, "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": 33, "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": 34, "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 producer may otherwise fail during starting and cause the route to fail being started. By deferring this startup to be lazy then the startup failure can be handled during routing messages via Camel's routing error handlers. Beware that when the first message is processed then creating and starting the producer may take a little time and prolong the total processing time of the processing." }, - "oauthProfile": { "index": 35, "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 Credentials grant. When set, the token is acquired from the configured identity provider and used instead of apiKey. Requires camel-oauth on the classpath. The profile properties are resolved from camel.oauth..client-id, camel.oauth..client-secret, and camel.oauth..token-endpoint." }, - "sslContextParameters": { "index": 36, "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 for configuring SSL\/TLS. When set, takes precedence over the individual sslTruststore, sslKeystore, and sslProtocol options." }, - "sslEndpointAlgorithm": { "index": 37, "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", "defaultValue": "https", "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "The endpoint identification algorithm to validate the server hostname using the server certificate. Set to an empty string or 'none' to disable hostname verification" }, - "sslKeymanagerAlgorithm": { "index": 38, "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 manager factory for SSL connections" }, - "sslKeyPassword": { "index": 39, "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": 40, "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 used for two-way authentication for the OpenAI API" }, - "sslKeystorePassword": { "index": 41, "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": 42, "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": 43, "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": 44, "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 trust manager factory for SSL connections" }, - "sslTruststoreLocation": { "index": 45, "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 server's certificate" }, - "sslTruststorePassword": { "index": 46, "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. If a password is not set, the configured trust store can still be used, but integrity checking is disabled" }, - "sslTruststoreType": { "index": 47, "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" } + "maxHistoryMessages": { "index": 19, "kind": "parameter", "displayName": "Max History Messages", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 0, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "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. When 0, no message limit is applied." }, + "maxHistoryTokens": { "index": 20, "kind": "parameter", "displayName": "Max History Tokens", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 0, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "When conversationMemory is enabled, trim conversation history using a token estimate (character count \/ 4). Oldest messages are dropped first until the estimated tokens are within this limit. When 0, no token limit is applied." }, + "maxTokens": { "index": 21, "kind": "parameter", "displayName": "Max Tokens", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "java.lang.Integer", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Maximum number of tokens to generate" }, + "maxToolIterations": { "index": 22, "kind": "parameter", "displayName": "Max Tool Iterations", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 50, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Maximum number of tool call loop iterations to prevent infinite loops" }, + "mcpProtocolVersions": { "index": 23, "kind": "parameter", "displayName": "Mcp Protocol Versions", "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 list of MCP protocol versions to advertise when connecting to MCP servers using Streamable HTTP transport. When not set, the SDK default is used. Example: 2024-11-05,2025-03-26,2025-06-18" }, + "mcpReconnect": { "index": 24, "kind": "parameter", "displayName": "Mcp Reconnect", "group": "producer", "label": "", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": true, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Automatically reconnect to MCP servers when a tool call fails due to a transport error, and retry the call once." }, + "mcpServer": { "index": 25, "kind": "parameter", "displayName": "Mcp Server", "group": "producer", "label": "", "required": false, "type": "object", "javaType": "java.util.Map", "prefix": "mcpServer.", "multiValue": true, "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "MCP (Model Context Protocol) server configurations. Define servers using prefix notation: mcpServer..transportType=stdiossestreamableHttp, (Note that sse is deprecated) mcpServer..command= (stdio), mcpServer..args= (stdio), mcpServer..url= (sse\/streamableHttp), mcpServer..oauthProfile= (OAuth profile for HTTP auth, requires camel-oauth). This is a multi-value option with prefix: mcpServer." }, + "mcpTimeout": { "index": 26, "kind": "parameter", "displayName": "Mcp Timeout", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 20, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Timeout in seconds for MCP tool call requests. Applies to all MCP operations including tool execution and initialization." }, + "model": { "index": 27, "kind": "parameter", "displayName": "Model", "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": "The model to use for chat completion" }, + "outputClass": { "index": 28, "kind": "parameter", "displayName": "Output Class", "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": "Fully qualified class name for structured output using response format" }, + "storeFullResponse": { "index": 29, "kind": "parameter", "displayName": "Store Full Response", "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": "Store the full response in the exchange property 'CamelOpenAIResponse' in non-streaming mode" }, + "streaming": { "index": 30, "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": 31, "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 Qwen3, DeepSeek-R1). The thinking content is stored in the CamelOpenAIThinkingContent header." }, + "systemMessage": { "index": 32, "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 conversation history is reset." }, + "temperature": { "index": 33, "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)" }, + "topP": { "index": 34, "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": 35, "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": 36, "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 producer may otherwise fail during starting and cause the route to fail being started. By deferring this startup to be lazy then the startup failure can be handled during routing messages via Camel's routing error handlers. Beware that when the first message is processed then creating and starting the producer may take a little time and prolong the total processing time of the processing." }, + "oauthProfile": { "index": 37, "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 Credentials grant. When set, the token is acquired from the configured identity provider and used instead of apiKey. Requires camel-oauth on the classpath. The profile properties are resolved from camel.oauth..client-id, camel.oauth..client-secret, and camel.oauth..token-endpoint." }, + "sslContextParameters": { "index": 38, "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 for configuring SSL\/TLS. When set, takes precedence over the individual sslTruststore, sslKeystore, and sslProtocol options." }, + "sslEndpointAlgorithm": { "index": 39, "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", "defaultValue": "https", "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "The endpoint identification algorithm to validate the server hostname using the server certificate. Set to an empty string or 'none' to disable hostname verification" }, + "sslKeymanagerAlgorithm": { "index": 40, "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 manager factory for SSL connections" }, + "sslKeyPassword": { "index": 41, "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": 42, "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 used for two-way authentication for the OpenAI API" }, + "sslKeystorePassword": { "index": 43, "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": 44, "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": 45, "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": 46, "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 trust manager factory for SSL connections" }, + "sslTruststoreLocation": { "index": 47, "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 server's certificate" }, + "sslTruststorePassword": { "index": 48, "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. If a password is not set, the configured trust store can still be used, but integrity checking is disabled" }, + "sslTruststoreType": { "index": 49, "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/OpenAIConfiguration.java b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIConfiguration.java index a5e51d1148323..c051e1e6414ac 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 @@ -84,6 +84,18 @@ 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. 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). Oldest messages are dropped first until the estimated tokens are " + + "within 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; @@ -341,6 +353,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; } diff --git a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIConversationHistoryTrimmer.java b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIConversationHistoryTrimmer.java new file mode 100644 index 0000000000000..85b69b587900a --- /dev/null +++ b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIConversationHistoryTrimmer.java @@ -0,0 +1,134 @@ +/* + * 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.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. + */ +final class OpenAIConversationHistoryTrimmer { + + private static final Logger LOG = LoggerFactory.getLogger(OpenAIConversationHistoryTrimmer.class); + private static final int CHARS_PER_TOKEN = 4; + + private OpenAIConversationHistoryTrimmer() { + } + + static List trim( + List history, OpenAIConfiguration config) { + if (history == null || history.isEmpty()) { + return history; + } + + int maxMessages = config.getMaxHistoryMessages(); + int maxTokens = config.getMaxHistoryTokens(); + if (maxMessages <= 0 && maxTokens <= 0) { + return history; + } + + List trimmed = new ArrayList<>(history); + int originalSize = trimmed.size(); + + if (maxMessages > 0 && trimmed.size() > maxMessages) { + trimmed = new ArrayList<>(trimmed.subList(trimmed.size() - maxMessages, trimmed.size())); + } + + if (maxTokens > 0) { + while (trimmed.size() > 1 && estimateTokens(trimmed) > maxTokens) { + trimmed.remove(0); + } + if (trimmed.size() == 1 && estimateTokens(trimmed) > maxTokens) { + trimmed.clear(); + } + } + + 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 messages) { + int chars = 0; + for (ChatCompletionMessageParam message : messages) { + chars += estimateMessageChars(message); + } + return (chars + CHARS_PER_TOKEN - 1) / CHARS_PER_TOKEN; + } + + 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(part -> part.text().map(text -> text.text().length()).orElse(0)) + .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; + } +} 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 37a79794a6ab3..a9493c2d50dc9 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 @@ -272,11 +272,16 @@ private void addConversationHistory( return; } - List history = in.getExchange().getProperty( + Exchange exchange = in.getExchange(); + List history = exchange.getProperty( config.getConversationHistoryProperty(), List.class); if (history != null) { - messages.addAll(history); + List trimmed = OpenAIConversationHistoryTrimmer.trim(history, config); + if (trimmed.size() != history.size()) { + exchange.setProperty(config.getConversationHistoryProperty(), trimmed); + } + messages.addAll(trimmed); } } @@ -726,7 +731,8 @@ private void updateConversationHistory( .build()); history.add(assistantMessage); - exchange.setProperty(config.getConversationHistoryProperty(), history); + exchange.setProperty(config.getConversationHistoryProperty(), + OpenAIConversationHistoryTrimmer.trim(history, config)); } private void updateConversationHistory( @@ -758,9 +764,10 @@ private void updateConversationHistory( .build()); history.add(assistantMessage); - exchange.setProperty(config.getConversationHistoryProperty(), history); + List trimmed = OpenAIConversationHistoryTrimmer.trim(history, config); + exchange.setProperty(config.getConversationHistoryProperty(), trimmed); LOG.debug("Updated conversation history with {} agentic messages + final response, total entries: {}", - agenticMessages.size(), history.size()); + agenticMessages.size(), trimmed.size()); } private void updateConversationHistory( @@ -791,9 +798,10 @@ private void updateConversationHistory( .build()); history.add(assistantMessage); - exchange.setProperty(config.getConversationHistoryProperty(), history); + List trimmed = OpenAIConversationHistoryTrimmer.trim(history, config); + exchange.setProperty(config.getConversationHistoryProperty(), trimmed); LOG.debug("Updated conversation history with {} agentic messages + returnDirect result, total entries: {}", - agenticMessages.size(), history.size()); + agenticMessages.size(), trimmed.size()); } private ResponseFormatJsonSchema.JsonSchema.Schema buildSchemaFromJson(String jsonSchemaString) throws Exception { 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 857e0e04f1f1e..e5a01352dddf1 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 @@ -173,7 +173,7 @@ public void process(Exchange exchange) throws Exception { } // Update conversation history and clear body for the next chat-completion call - exchange.setProperty(historyProperty, history); + exchange.setProperty(historyProperty, OpenAIConversationHistoryTrimmer.trim(history, config)); exchange.getMessage().setBody(null); exchange.getMessage().setHeader(OpenAIConstants.TOOL_ITERATIONS, executedCount); } diff --git a/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIConversationHistorySizeTest.java b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIConversationHistorySizeTest.java new file mode 100644 index 0000000000000..9055b6b76232b --- /dev/null +++ b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIConversationHistorySizeTest.java @@ -0,0 +1,104 @@ +/* + * 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.List; +import java.util.concurrent.atomic.AtomicReference; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.camel.Exchange; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.test.infra.openai.mock.OpenAIMock; +import org.apache.camel.test.junit6.CamelTestSupport; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +class OpenAIConversationHistorySizeTest extends CamelTestSupport { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final AtomicReference fourthRequestBody = new AtomicReference<>(); + + @RegisterExtension + public OpenAIMock openAIMock = new OpenAIMock().builder() + .when("turn-1") + .replyWith("reply-1") + .end() + .when("turn-2") + .replyWith("reply-2") + .end() + .when("turn-3") + .replyWith("reply-3") + .end() + .when("turn-4") + .assertRequest(fourthRequestBody::set) + .replyWith("reply-4") + .end() + .build(); + + @Override + protected RouteBuilder createRouteBuilder() { + String endpoint = "openai:chat-completion?model=gpt-5&apiKey=dummy&conversationMemory=true" + + "&maxHistoryMessages=2&baseUrl=" + openAIMock.getBaseUrl() + "/v1"; + + return new RouteBuilder() { + @Override + public void configure() { + from("direct:multi-turn") + .to(endpoint) + .setBody(constant("turn-2")) + .to(endpoint) + .setBody(constant("turn-3")) + .to(endpoint) + .setBody(constant("turn-4")) + .to(endpoint); + } + }; + } + + @Test + void maxHistoryMessagesShouldRetainOnlyRecentHistoryInRequestsAndExchangeProperty() throws Exception { + Exchange result = template.request("direct:multi-turn", e -> e.getIn().setBody("turn-1")); + + assertThat(result.getException()).isNull(); + assertThat(result.getMessage().getBody(String.class)).isEqualTo("reply-4"); + + @SuppressWarnings("unchecked") + List history = result.getProperty("CamelOpenAIConversationHistory", List.class); + assertThat(history).hasSize(2); + + String request = fourthRequestBody.get(); + assertThat(request).isNotNull(); + + JsonNode messages = MAPPER.readTree(request).get("messages"); + assertThat(messages).isNotNull(); + assertThat(messages) + .noneMatch(message -> "turn-1".equals(message.path("content").asText()) + || "reply-1".equals(message.path("content").asText()) + || "turn-2".equals(message.path("content").asText()) + || "reply-2".equals(message.path("content").asText())); + assertThat(messages) + .anyMatch(message -> "turn-3".equals(message.path("content").asText())); + assertThat(messages) + .anyMatch(message -> "reply-3".equals(message.path("content").asText())); + assertThat(messages) + .anyMatch(message -> "turn-4".equals(message.path("content").asText())); + } +} diff --git a/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIConversationHistoryTrimmerTest.java b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIConversationHistoryTrimmerTest.java new file mode 100644 index 0000000000000..ca5feb27ba8d5 --- /dev/null +++ b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIConversationHistoryTrimmerTest.java @@ -0,0 +1,164 @@ +/* + * 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.ChatCompletionMessageFunctionToolCall; +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.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class OpenAIConversationHistoryTrimmerTest { + + @Test + void trimShouldReturnSameListWhenNoLimitsConfigured() { + List history = List.of( + userMessage("one"), + assistantMessage("two")); + + List trimmed = OpenAIConversationHistoryTrimmer.trim(history, config(0, 0)); + + assertThat(trimmed).isSameAs(history); + } + + @Test + void trimShouldRetainMostRecentMessagesWhenMaxHistoryMessagesIsSet() { + List history = new ArrayList<>(); + history.add(userMessage("msg-1")); + history.add(assistantMessage("reply-1")); + history.add(userMessage("msg-2")); + history.add(assistantMessage("reply-2")); + history.add(userMessage("msg-3")); + history.add(assistantMessage("reply-3")); + + List trimmed = OpenAIConversationHistoryTrimmer.trim(history, config(2, 0)); + + assertThat(trimmed).hasSize(2); + assertThat(extractText(trimmed.get(0))).isEqualTo("msg-3"); + assertThat(extractText(trimmed.get(1))).isEqualTo("reply-3"); + } + + @Test + void trimShouldDropOldestMessagesWhenMaxHistoryTokensIsExceeded() { + List history = List.of( + userMessage(repeat('a', 40)), + assistantMessage(repeat('b', 40)), + userMessage(repeat('c', 40))); + + List trimmed = OpenAIConversationHistoryTrimmer.trim(history, config(0, 15)); + + assertThat(OpenAIConversationHistoryTrimmer.estimateTokens(trimmed)).isLessThanOrEqualTo(15); + assertThat(trimmed).hasSize(1); + assertThat(extractText(trimmed.get(0))).isEqualTo(repeat('c', 40)); + } + + @Test + void trimShouldApplyBothMessageAndTokenLimits() { + List history = new ArrayList<>(); + for (int i = 1; i <= 6; i++) { + history.add(userMessage("user-" + i + repeat('x', 36))); + history.add(assistantMessage("assistant-" + i)); + } + + List trimmed = OpenAIConversationHistoryTrimmer.trim(history, config(4, 20)); + + assertThat(trimmed.size()).isLessThanOrEqualTo(4); + assertThat(OpenAIConversationHistoryTrimmer.estimateTokens(trimmed)).isLessThanOrEqualTo(20); + assertThat(trimmed.stream().map(OpenAIConversationHistoryTrimmerTest::extractText)) + .noneMatch(text -> text.contains("user-1") || text.contains("assistant-1")); + assertThat(trimmed.stream().map(OpenAIConversationHistoryTrimmerTest::extractText)) + .anyMatch(text -> text.contains("user-6")); + } + + @Test + void trimShouldCountToolCallArgumentsTowardTokenEstimate() { + ChatCompletionMessageParam assistantWithToolCall = ChatCompletionMessageParam.ofAssistant( + ChatCompletionAssistantMessageParam.builder() + .toolCalls(List.of( + ChatCompletionMessageToolCall.ofFunction( + ChatCompletionMessageFunctionToolCall.builder() + .id("call-1") + .function(ChatCompletionMessageFunctionToolCall.Function.builder() + .name("lookup") + .arguments("{\"query\":\"" + repeat('z', 80) + "\"}") + .build()) + .build()))) + .build()); + + List history = List.of( + userMessage("short"), + assistantWithToolCall, + toolMessage("tool-result")); + + List trimmed = OpenAIConversationHistoryTrimmer.trim(history, config(0, 10)); + + assertThat(trimmed).hasSize(1); + assertThat(extractText(trimmed.get(0))).isEqualTo("tool-result"); + } + + private static OpenAIConfiguration config(int maxHistoryMessages, int maxHistoryTokens) { + OpenAIConfiguration configuration = new OpenAIConfiguration(); + configuration.setMaxHistoryMessages(maxHistoryMessages); + configuration.setMaxHistoryTokens(maxHistoryTokens); + return configuration; + } + + private static ChatCompletionMessageParam userMessage(String text) { + return ChatCompletionMessageParam.ofUser( + ChatCompletionUserMessageParam.builder() + .content(ChatCompletionUserMessageParam.Content.ofText(text)) + .build()); + } + + private static ChatCompletionMessageParam assistantMessage(String text) { + return ChatCompletionMessageParam.ofAssistant( + ChatCompletionAssistantMessageParam.builder() + .content(ChatCompletionAssistantMessageParam.Content.ofText(text)) + .build()); + } + + private static ChatCompletionMessageParam toolMessage(String text) { + return ChatCompletionMessageParam.ofTool( + ChatCompletionToolMessageParam.builder() + .toolCallId("call-1") + .content(text) + .build()); + } + + private static String extractText(ChatCompletionMessageParam message) { + return message.user() + .map(user -> user.content().isText() ? user.content().asText() : "") + .orElseGet(() -> message.assistant() + .flatMap(assistant -> assistant.content() + .filter(ChatCompletionAssistantMessageParam.Content::isText) + .map(ChatCompletionAssistantMessageParam.Content::asText)) + .orElseGet(() -> message.tool() + .map(tool -> tool.content().isText() ? tool.content().asText() : "") + .orElse(""))); + } + + private static String repeat(char value, int count) { + return String.valueOf(value).repeat(count); + } +} From 84b52e7444269bfd5eea72ab797aaedc333f3c6d Mon Sep 17 00:00:00 2001 From: atiaomar1978-hub Date: Sat, 18 Jul 2026 16:49:44 -0700 Subject: [PATCH 2/5] CAMEL-23394: Trim history by tool-call segments and improve token estimate Drop oldest whole segments to keep assistant tool-call blocks paired with tool results, use O(n) segment-based token trimming, and count image payload size in the token heuristic. --- .../apache/camel/component/openai/openai.json | 4 +- .../component/openai/OpenAIConfiguration.java | 9 +- .../OpenAIConversationHistoryTrimmer.java | 123 ++++++++++++++++-- .../OpenAIConversationHistoryTrimmerTest.java | 71 +++++++++- 4 files changed, 188 insertions(+), 19 deletions(-) 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 98753e89aa77c..c86faf0af9f6a 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 @@ -95,8 +95,8 @@ "embeddingModel": { "index": 16, "kind": "parameter", "displayName": "Embedding Model", "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": "The model to use for embeddings" }, "encodingFormat": { "index": 17, "kind": "parameter", "displayName": "Encoding Format", "group": "producer", "label": "", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "float", "base64" ], "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "base64", "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "The format for embedding output: 'float' for list of floats, 'base64' for compressed format" }, "jsonSchema": { "index": 18, "kind": "parameter", "displayName": "Json Schema", "group": "producer", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "supportFileReference": true, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "JSON schema for structured output validation" }, - "maxHistoryMessages": { "index": 19, "kind": "parameter", "displayName": "Max History Messages", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 0, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "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. When 0, no message limit is applied." }, - "maxHistoryTokens": { "index": 20, "kind": "parameter", "displayName": "Max History Tokens", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 0, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "When conversationMemory is enabled, trim conversation history using a token estimate (character count \/ 4). Oldest messages are dropped first until the estimated tokens are within this limit. When 0, no token limit is applied." }, + "maxHistoryMessages": { "index": 19, "kind": "parameter", "displayName": "Max History Messages", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 0, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "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." }, + "maxHistoryTokens": { "index": 20, "kind": "parameter", "displayName": "Max History Tokens", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 0, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "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. When 0, no token limit is applied." }, "maxTokens": { "index": 21, "kind": "parameter", "displayName": "Max Tokens", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "java.lang.Integer", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Maximum number of tokens to generate" }, "maxToolIterations": { "index": 22, "kind": "parameter", "displayName": "Max Tool Iterations", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 50, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Maximum number of tool call loop iterations to prevent infinite loops" }, "mcpProtocolVersions": { "index": 23, "kind": "parameter", "displayName": "Mcp Protocol Versions", "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 list of MCP protocol versions to advertise when connecting to MCP servers using Streamable HTTP transport. When not set, the SDK default is used. Example: 2024-11-05,2025-03-26,2025-06-18" }, 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 c051e1e6414ac..21c4b276c9202 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 @@ -87,13 +87,16 @@ public class OpenAIConfiguration implements Cloneable { @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. When 0, no message limit is applied.") + + "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). Oldest messages are dropped first until the estimated tokens are " - + "within this limit. When 0, no token limit is applied.") + + "(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. When 0, " + + "no token limit is applied.") private int maxHistoryTokens; @UriParam diff --git a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIConversationHistoryTrimmer.java b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIConversationHistoryTrimmer.java index 85b69b587900a..fe33729224904 100644 --- a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIConversationHistoryTrimmer.java +++ b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIConversationHistoryTrimmer.java @@ -20,6 +20,7 @@ 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; @@ -28,7 +29,8 @@ import org.slf4j.LoggerFactory; /** - * Applies sliding-window limits to exchange-scoped OpenAI conversation history. + * 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 { @@ -50,19 +52,28 @@ static List trim( return history; } - List trimmed = new ArrayList<>(history); - int originalSize = trimmed.size(); + int originalSize = history.size(); + List segments = buildSegments(history); + int firstSegment = 0; - if (maxMessages > 0 && trimmed.size() > maxMessages) { - trimmed = new ArrayList<>(trimmed.subList(trimmed.size() - maxMessages, trimmed.size())); + if (maxMessages > 0) { + firstSegment = findFirstSegmentForMessageLimit(segments, maxMessages); } if (maxTokens > 0) { - while (trimmed.size() > 1 && estimateTokens(trimmed) > maxTokens) { - trimmed.remove(0); - } - if (trimmed.size() == 1 && estimateTokens(trimmed) > maxTokens) { - trimmed.clear(); + firstSegment = Math.max(firstSegment, + findFirstSegmentForTokenLimit(history, segments, maxTokens)); + } + + List trimmed; + if (firstSegment >= segments.size()) { + trimmed = List.of(); + } else { + Segment first = segments.get(firstSegment); + Segment last = segments.get(segments.size() - 1); + trimmed = new ArrayList<>(history.subList(first.start, last.end + 1)); + if (maxTokens > 0 && estimateTokens(trimmed) > maxTokens) { + trimmed = List.of(); } } @@ -75,13 +86,74 @@ static List trim( } static int estimateTokens(List messages) { + return estimateTokens(messages, 0, messages.size()); + } + + private static int estimateTokens(List messages, int from, int to) { int chars = 0; - for (ChatCompletionMessageParam message : messages) { - chars += estimateMessageChars(message); + for (int i = from; i < to; i++) { + chars += estimateMessageChars(messages.get(i)); } + return tokensFromChars(chars); + } + + private static int tokensFromChars(int chars) { return (chars + CHARS_PER_TOKEN - 1) / CHARS_PER_TOKEN; } + private static int findFirstSegmentForMessageLimit(List 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 history, List segments, int maxTokens) { + for (int firstSegment = 0; firstSegment < segments.size(); firstSegment++) { + Segment first = segments.get(firstSegment); + Segment last = segments.get(segments.size() - 1); + if (estimateTokens(history, first.start, last.end + 1) <= maxTokens) { + return firstSegment; + } + } + return segments.size(); + } + + private static List buildSegments(List history) { + List 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) @@ -99,7 +171,7 @@ private static int estimateUserMessageChars(ChatCompletionUserMessageParam user) } if (content.isArrayOfContentParts()) { return content.asArrayOfContentParts().stream() - .mapToInt(part -> part.text().map(text -> text.text().length()).orElse(0)) + .mapToInt(OpenAIConversationHistoryTrimmer::estimateContentPartChars) .sum(); } return 0; @@ -131,4 +203,29 @@ private static int estimateToolMessageChars(ChatCompletionToolMessageParam tool) } 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; + } + } } diff --git a/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIConversationHistoryTrimmerTest.java b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIConversationHistoryTrimmerTest.java index ca5feb27ba8d5..51d6f03eb493e 100644 --- a/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIConversationHistoryTrimmerTest.java +++ b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIConversationHistoryTrimmerTest.java @@ -20,6 +20,9 @@ import java.util.List; import com.openai.models.chat.completions.ChatCompletionAssistantMessageParam; +import com.openai.models.chat.completions.ChatCompletionContentPart; +import com.openai.models.chat.completions.ChatCompletionContentPartImage; +import com.openai.models.chat.completions.ChatCompletionContentPartText; import com.openai.models.chat.completions.ChatCompletionMessageFunctionToolCall; import com.openai.models.chat.completions.ChatCompletionMessageParam; import com.openai.models.chat.completions.ChatCompletionMessageToolCall; @@ -113,8 +116,59 @@ void trimShouldCountToolCallArgumentsTowardTokenEstimate() { List trimmed = OpenAIConversationHistoryTrimmer.trim(history, config(0, 10)); + assertThat(trimmed).isEmpty(); + } + + @Test + void trimShouldKeepAssistantToolCallBlockIntactWhenMessageLimitWouldSplitIt() { + List history = List.of( + userMessage("older"), + assistantMessage("older-reply"), + userMessage("latest"), + assistantWithToolCall("call-1"), + toolMessage("tool-result"), + assistantMessage("final-reply")); + + List trimmed = OpenAIConversationHistoryTrimmer.trim(history, config(2, 0)); + assertThat(trimmed).hasSize(1); - assertThat(extractText(trimmed.get(0))).isEqualTo("tool-result"); + assertThat(trimmed.get(0).assistant()).isPresent(); + assertThat(extractText(trimmed.get(0))).isEqualTo("final-reply"); + assertThat(trimmed.stream().noneMatch(message -> message.tool().isPresent())).isTrue(); + } + + @Test + void trimShouldRetainAssistantAndToolResultsAsOneSegment() { + List history = List.of( + assistantWithToolCall("call-1"), + toolMessage("tool-result")); + + List trimmed = OpenAIConversationHistoryTrimmer.trim(history, config(2, 0)); + + assertThat(trimmed).hasSize(2); + assertThat(trimmed.get(0).assistant()).isPresent(); + assertThat(trimmed.get(1).tool()).isPresent(); + } + + @Test + void estimateTokensShouldIncludeImagePayloadSize() { + String imageUrl = "data:image/png;base64," + repeat('x', 400); + ChatCompletionMessageParam imageMessage = ChatCompletionMessageParam.ofUser( + ChatCompletionUserMessageParam.builder() + .content(ChatCompletionUserMessageParam.Content.ofArrayOfContentParts(List.of( + ChatCompletionContentPart.ofText( + ChatCompletionContentPartText.builder().text("describe").build()), + ChatCompletionContentPart.ofImageUrl( + ChatCompletionContentPartImage.builder() + .imageUrl(ChatCompletionContentPartImage.ImageUrl.builder() + .url(imageUrl) + .build()) + .build())))) + .build()); + + int tokens = OpenAIConversationHistoryTrimmer.estimateTokens(List.of(imageMessage)); + + assertThat(tokens).isGreaterThan(100); } private static OpenAIConfiguration config(int maxHistoryMessages, int maxHistoryTokens) { @@ -138,6 +192,21 @@ private static ChatCompletionMessageParam assistantMessage(String text) { .build()); } + private static ChatCompletionMessageParam assistantWithToolCall(String toolCallId) { + return ChatCompletionMessageParam.ofAssistant( + ChatCompletionAssistantMessageParam.builder() + .toolCalls(List.of( + ChatCompletionMessageToolCall.ofFunction( + ChatCompletionMessageFunctionToolCall.builder() + .id(toolCallId) + .function(ChatCompletionMessageFunctionToolCall.Function.builder() + .name("lookup") + .arguments("{}") + .build()) + .build()))) + .build()); + } + private static ChatCompletionMessageParam toolMessage(String text) { return ChatCompletionMessageParam.ofTool( ChatCompletionToolMessageParam.builder() From 5110d9794c0d4d121e429807387178ea8b5e60aa Mon Sep 17 00:00:00 2001 From: atiaomar1978-hub Date: Sun, 19 Jul 2026 08:41:54 -0700 Subject: [PATCH 3/5] CAMEL-23394: Regenerate OpenAI metadata for history size options Commit generated catalog, component schema, and endpoint DSL artifacts for maxHistoryMessages and maxHistoryTokens so CI clean-tree check passes. Co-Authored-By: Cursor --- .../camel/catalog/components/openai.json | 60 +++++++------- .../apache/camel/component/openai/openai.json | 2 +- .../dsl/OpenAIEndpointBuilderFactory.java | 80 +++++++++++++++++++ 3 files changed, 112 insertions(+), 30 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/openai.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/openai.json index 6c38a7b582f49..381921d57f321 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/openai.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/openai.json @@ -95,34 +95,36 @@ "embeddingModel": { "index": 16, "kind": "parameter", "displayName": "Embedding Model", "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": "The model to use for embeddings" }, "encodingFormat": { "index": 17, "kind": "parameter", "displayName": "Encoding Format", "group": "producer", "label": "", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "float", "base64" ], "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "base64", "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "The format for embedding output: 'float' for list of floats, 'base64' for compressed format" }, "jsonSchema": { "index": 18, "kind": "parameter", "displayName": "Json Schema", "group": "producer", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "supportFileReference": true, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "JSON schema for structured output validation" }, - "maxTokens": { "index": 19, "kind": "parameter", "displayName": "Max Tokens", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "java.lang.Integer", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Maximum number of tokens to generate" }, - "maxToolIterations": { "index": 20, "kind": "parameter", "displayName": "Max Tool Iterations", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 50, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Maximum number of tool call loop iterations to prevent infinite loops" }, - "mcpProtocolVersions": { "index": 21, "kind": "parameter", "displayName": "Mcp Protocol Versions", "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 list of MCP protocol versions to advertise when connecting to MCP servers using Streamable HTTP transport. When not set, the SDK default is used. Example: 2024-11-05,2025-03-26,2025-06-18" }, - "mcpReconnect": { "index": 22, "kind": "parameter", "displayName": "Mcp Reconnect", "group": "producer", "label": "", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": true, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Automatically reconnect to MCP servers when a tool call fails due to a transport error, and retry the call once." }, - "mcpServer": { "index": 23, "kind": "parameter", "displayName": "Mcp Server", "group": "producer", "label": "", "required": false, "type": "object", "javaType": "java.util.Map", "prefix": "mcpServer.", "multiValue": true, "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "MCP (Model Context Protocol) server configurations. Define servers using prefix notation: mcpServer..transportType=stdiossestreamableHttp, (Note that sse is deprecated) mcpServer..command= (stdio), mcpServer..args= (stdio), mcpServer..url= (sse\/streamableHttp), mcpServer..oauthProfile= (OAuth profile for HTTP auth, requires camel-oauth). This is a multi-value option with prefix: mcpServer." }, - "mcpTimeout": { "index": 24, "kind": "parameter", "displayName": "Mcp Timeout", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 20, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Timeout in seconds for MCP tool call requests. Applies to all MCP operations including tool execution and initialization." }, - "model": { "index": 25, "kind": "parameter", "displayName": "Model", "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": "The model to use for chat completion" }, - "outputClass": { "index": 26, "kind": "parameter", "displayName": "Output Class", "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": "Fully qualified class name for structured output using response format" }, - "storeFullResponse": { "index": 27, "kind": "parameter", "displayName": "Store Full Response", "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": "Store the full response in the exchange property 'CamelOpenAIResponse' in non-streaming mode" }, - "streaming": { "index": 28, "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": 29, "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 Qwen3, DeepSeek-R1). The thinking content is stored in the CamelOpenAIThinkingContent header." }, - "systemMessage": { "index": 30, "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 conversation history is reset." }, - "temperature": { "index": 31, "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)" }, - "topP": { "index": 32, "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": 33, "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": 34, "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 producer may otherwise fail during starting and cause the route to fail being started. By deferring this startup to be lazy then the startup failure can be handled during routing messages via Camel's routing error handlers. Beware that when the first message is processed then creating and starting the producer may take a little time and prolong the total processing time of the processing." }, - "oauthProfile": { "index": 35, "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 Credentials grant. When set, the token is acquired from the configured identity provider and used instead of apiKey. Requires camel-oauth on the classpath. The profile properties are resolved from camel.oauth..client-id, camel.oauth..client-secret, and camel.oauth..token-endpoint." }, - "sslContextParameters": { "index": 36, "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 for configuring SSL\/TLS. When set, takes precedence over the individual sslTruststore, sslKeystore, and sslProtocol options." }, - "sslEndpointAlgorithm": { "index": 37, "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", "description": "The endpoint identification algorithm to validate the server hostname using the server certificate. Set to an empty string or 'none' to disable hostname verification" }, - "sslKeymanagerAlgorithm": { "index": 38, "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 manager factory for SSL connections" }, - "sslKeyPassword": { "index": 39, "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": 40, "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 used for two-way authentication for the OpenAI API" }, - "sslKeystorePassword": { "index": 41, "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": 42, "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": 43, "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": 44, "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 trust manager factory for SSL connections" }, - "sslTruststoreLocation": { "index": 45, "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 server's certificate" }, - "sslTruststorePassword": { "index": 46, "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. If a password is not set, the configured trust store can still be used, but integrity checking is disabled" }, - "sslTruststoreType": { "index": 47, "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" } + "maxHistoryMessages": { "index": 19, "kind": "parameter", "displayName": "Max History Messages", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 0, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "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." }, + "maxHistoryTokens": { "index": 20, "kind": "parameter", "displayName": "Max History Tokens", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 0, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "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. When 0, no token limit is applied." }, + "maxTokens": { "index": 21, "kind": "parameter", "displayName": "Max Tokens", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "java.lang.Integer", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Maximum number of tokens to generate" }, + "maxToolIterations": { "index": 22, "kind": "parameter", "displayName": "Max Tool Iterations", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 50, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Maximum number of tool call loop iterations to prevent infinite loops" }, + "mcpProtocolVersions": { "index": 23, "kind": "parameter", "displayName": "Mcp Protocol Versions", "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 list of MCP protocol versions to advertise when connecting to MCP servers using Streamable HTTP transport. When not set, the SDK default is used. Example: 2024-11-05,2025-03-26,2025-06-18" }, + "mcpReconnect": { "index": 24, "kind": "parameter", "displayName": "Mcp Reconnect", "group": "producer", "label": "", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": true, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Automatically reconnect to MCP servers when a tool call fails due to a transport error, and retry the call once." }, + "mcpServer": { "index": 25, "kind": "parameter", "displayName": "Mcp Server", "group": "producer", "label": "", "required": false, "type": "object", "javaType": "java.util.Map", "prefix": "mcpServer.", "multiValue": true, "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "MCP (Model Context Protocol) server configurations. Define servers using prefix notation: mcpServer..transportType=stdiossestreamableHttp, (Note that sse is deprecated) mcpServer..command= (stdio), mcpServer..args= (stdio), mcpServer..url= (sse\/streamableHttp), mcpServer..oauthProfile= (OAuth profile for HTTP auth, requires camel-oauth). This is a multi-value option with prefix: mcpServer." }, + "mcpTimeout": { "index": 26, "kind": "parameter", "displayName": "Mcp Timeout", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 20, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Timeout in seconds for MCP tool call requests. Applies to all MCP operations including tool execution and initialization." }, + "model": { "index": 27, "kind": "parameter", "displayName": "Model", "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": "The model to use for chat completion" }, + "outputClass": { "index": 28, "kind": "parameter", "displayName": "Output Class", "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": "Fully qualified class name for structured output using response format" }, + "storeFullResponse": { "index": 29, "kind": "parameter", "displayName": "Store Full Response", "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": "Store the full response in the exchange property 'CamelOpenAIResponse' in non-streaming mode" }, + "streaming": { "index": 30, "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": 31, "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 Qwen3, DeepSeek-R1). The thinking content is stored in the CamelOpenAIThinkingContent header." }, + "systemMessage": { "index": 32, "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 conversation history is reset." }, + "temperature": { "index": 33, "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)" }, + "topP": { "index": 34, "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": 35, "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": 36, "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 producer may otherwise fail during starting and cause the route to fail being started. By deferring this startup to be lazy then the startup failure can be handled during routing messages via Camel's routing error handlers. Beware that when the first message is processed then creating and starting the producer may take a little time and prolong the total processing time of the processing." }, + "oauthProfile": { "index": 37, "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 Credentials grant. When set, the token is acquired from the configured identity provider and used instead of apiKey. Requires camel-oauth on the classpath. The profile properties are resolved from camel.oauth..client-id, camel.oauth..client-secret, and camel.oauth..token-endpoint." }, + "sslContextParameters": { "index": 38, "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 for configuring SSL\/TLS. When set, takes precedence over the individual sslTruststore, sslKeystore, and sslProtocol options." }, + "sslEndpointAlgorithm": { "index": 39, "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", "description": "The endpoint identification algorithm to validate the server hostname using the server certificate. Set to an empty string or 'none' to disable hostname verification" }, + "sslKeymanagerAlgorithm": { "index": 40, "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 manager factory for SSL connections" }, + "sslKeyPassword": { "index": 41, "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": 42, "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 used for two-way authentication for the OpenAI API" }, + "sslKeystorePassword": { "index": 43, "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": 44, "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": 45, "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": 46, "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 trust manager factory for SSL connections" }, + "sslTruststoreLocation": { "index": 47, "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 server's certificate" }, + "sslTruststorePassword": { "index": 48, "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. If a password is not set, the configured trust store can still be used, but integrity checking is disabled" }, + "sslTruststoreType": { "index": 49, "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/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 c86faf0af9f6a..381921d57f321 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 @@ -115,7 +115,7 @@ "lazyStartProducer": { "index": 36, "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 producer may otherwise fail during starting and cause the route to fail being started. By deferring this startup to be lazy then the startup failure can be handled during routing messages via Camel's routing error handlers. Beware that when the first message is processed then creating and starting the producer may take a little time and prolong the total processing time of the processing." }, "oauthProfile": { "index": 37, "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 Credentials grant. When set, the token is acquired from the configured identity provider and used instead of apiKey. Requires camel-oauth on the classpath. The profile properties are resolved from camel.oauth..client-id, camel.oauth..client-secret, and camel.oauth..token-endpoint." }, "sslContextParameters": { "index": 38, "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 for configuring SSL\/TLS. When set, takes precedence over the individual sslTruststore, sslKeystore, and sslProtocol options." }, - "sslEndpointAlgorithm": { "index": 39, "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", "defaultValue": "https", "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "The endpoint identification algorithm to validate the server hostname using the server certificate. Set to an empty string or 'none' to disable hostname verification" }, + "sslEndpointAlgorithm": { "index": 39, "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", "description": "The endpoint identification algorithm to validate the server hostname using the server certificate. Set to an empty string or 'none' to disable hostname verification" }, "sslKeymanagerAlgorithm": { "index": 40, "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 manager factory for SSL connections" }, "sslKeyPassword": { "index": 41, "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": 42, "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 used for two-way authentication for the OpenAI API" }, diff --git a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/OpenAIEndpointBuilderFactory.java b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/OpenAIEndpointBuilderFactory.java index 5d353f4ddde8e..ccf2be8e13958 100644 --- a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/OpenAIEndpointBuilderFactory.java +++ b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/OpenAIEndpointBuilderFactory.java @@ -437,6 +437,86 @@ default OpenAIEndpointBuilder jsonSchema(String jsonSchema) { doSetProperty("jsonSchema", jsonSchema); return this; } + /** + * 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. + * + * The option is a: int type. + * + * Default: 0 + * Group: producer + * + * @param maxHistoryMessages the value to set + * @return the dsl builder + */ + default OpenAIEndpointBuilder maxHistoryMessages(int maxHistoryMessages) { + doSetProperty("maxHistoryMessages", maxHistoryMessages); + return this; + } + /** + * 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. + * + * The option will be converted to a int type. + * + * Default: 0 + * Group: producer + * + * @param maxHistoryMessages the value to set + * @return the dsl builder + */ + default OpenAIEndpointBuilder maxHistoryMessages(String maxHistoryMessages) { + doSetProperty("maxHistoryMessages", maxHistoryMessages); + return this; + } + /** + * 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. When 0, no + * token limit is applied. + * + * The option is a: int type. + * + * Default: 0 + * Group: producer + * + * @param maxHistoryTokens the value to set + * @return the dsl builder + */ + default OpenAIEndpointBuilder maxHistoryTokens(int maxHistoryTokens) { + doSetProperty("maxHistoryTokens", maxHistoryTokens); + return this; + } + /** + * 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. When 0, no + * token limit is applied. + * + * The option will be converted to a int type. + * + * Default: 0 + * Group: producer + * + * @param maxHistoryTokens the value to set + * @return the dsl builder + */ + default OpenAIEndpointBuilder maxHistoryTokens(String maxHistoryTokens) { + doSetProperty("maxHistoryTokens", maxHistoryTokens); + return this; + } /** * Maximum number of tokens to generate. * From 5b4f03e68964783fcc2fb24e1faa4c4d2e0e6cdd Mon Sep 17 00:00:00 2001 From: atiaomar1978-hub Date: Mon, 20 Jul 2026 08:08:56 -0700 Subject: [PATCH 4/5] CAMEL-23394: Fix mutable conversation history trim and address PR review Return mutable lists from OpenAIConversationHistoryTrimmer, always retain the last segment for token limits, add route/unit tests, and update docs. Co-Authored-By: Cursor --- .../camel/catalog/components/openai.json | 2 +- .../apache/camel/component/openai/openai.json | 2 +- .../src/main/docs/openai-component.adoc | 2 + .../component/openai/OpenAIConfiguration.java | 3 +- .../OpenAIConversationHistoryTrimmer.java | 39 +++++-- ...enAIConversationHistoryTokenLimitTest.java | 110 ++++++++++++++++++ .../OpenAIConversationHistoryTrimmerTest.java | 43 ++++++- 7 files changed, 186 insertions(+), 15 deletions(-) create mode 100644 components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIConversationHistoryTokenLimitTest.java diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/openai.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/openai.json index a96263b7d1c1e..08defe1693e56 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/openai.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/openai.json @@ -97,7 +97,7 @@ "encodingFormat": { "index": 18, "kind": "parameter", "displayName": "Encoding Format", "group": "producer", "label": "", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "float", "base64" ], "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "base64", "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "The format for embedding output: 'float' for list of floats, 'base64' for compressed format" }, "jsonSchema": { "index": 19, "kind": "parameter", "displayName": "Json Schema", "group": "producer", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "supportFileReference": true, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "JSON schema for structured output validation" }, "maxHistoryMessages": { "index": 20, "kind": "parameter", "displayName": "Max History Messages", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 0, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "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." }, - "maxHistoryTokens": { "index": 21, "kind": "parameter", "displayName": "Max History Tokens", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 0, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "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. When 0, no token limit is applied." }, + "maxHistoryTokens": { "index": 21, "kind": "parameter", "displayName": "Max History Tokens", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 0, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "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." }, "maxRetries": { "index": 22, "kind": "parameter", "displayName": "Max Retries", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 2, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Maximum number of times the OpenAI SDK client retries failed requests. The SDK retry is rate-limit aware (honors Retry-After on 429)." }, "maxTokens": { "index": 23, "kind": "parameter", "displayName": "Max Tokens", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "java.lang.Integer", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Maximum number of tokens to generate" }, "maxToolIterations": { "index": 24, "kind": "parameter", "displayName": "Max Tool Iterations", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 50, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Maximum number of tool call loop iterations to prevent infinite loops" }, 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 a96263b7d1c1e..08defe1693e56 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 @@ -97,7 +97,7 @@ "encodingFormat": { "index": 18, "kind": "parameter", "displayName": "Encoding Format", "group": "producer", "label": "", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "float", "base64" ], "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "base64", "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "The format for embedding output: 'float' for list of floats, 'base64' for compressed format" }, "jsonSchema": { "index": 19, "kind": "parameter", "displayName": "Json Schema", "group": "producer", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "supportFileReference": true, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "JSON schema for structured output validation" }, "maxHistoryMessages": { "index": 20, "kind": "parameter", "displayName": "Max History Messages", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 0, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "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." }, - "maxHistoryTokens": { "index": 21, "kind": "parameter", "displayName": "Max History Tokens", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 0, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "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. When 0, no token limit is applied." }, + "maxHistoryTokens": { "index": 21, "kind": "parameter", "displayName": "Max History Tokens", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 0, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "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." }, "maxRetries": { "index": 22, "kind": "parameter", "displayName": "Max Retries", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 2, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Maximum number of times the OpenAI SDK client retries failed requests. The SDK retry is rate-limit aware (honors Retry-After on 429)." }, "maxTokens": { "index": 23, "kind": "parameter", "displayName": "Max Tokens", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "java.lang.Integer", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Maximum number of tokens to generate" }, "maxToolIterations": { "index": 24, "kind": "parameter", "displayName": "Max Tool Iterations", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 50, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Maximum number of tool call loop iterations to prevent infinite loops" }, diff --git a/components/camel-ai/camel-openai/src/main/docs/openai-component.adoc b/components/camel-ai/camel-openai/src/main/docs/openai-component.adoc index ee3cac44cb516..9a6c87ccf5374 100644 --- a/components/camel-ai/camel-openai/src/main/docs/openai-component.adoc +++ b/components/camel-ai/camel-openai/src/main/docs/openai-component.adoc @@ -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 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 d29d3375f4883..66dc4ee206af6 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 @@ -112,7 +112,8 @@ public class OpenAIConfiguration implements Cloneable { @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. When 0, " + + "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; diff --git a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIConversationHistoryTrimmer.java b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIConversationHistoryTrimmer.java index fe33729224904..39f63d1489ab1 100644 --- a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIConversationHistoryTrimmer.java +++ b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIConversationHistoryTrimmer.java @@ -42,8 +42,11 @@ private OpenAIConversationHistoryTrimmer() { static List trim( List history, OpenAIConfiguration config) { - if (history == null || history.isEmpty()) { - return history; + if (history == null) { + return null; + } + if (history.isEmpty()) { + return new ArrayList<>(); } int maxMessages = config.getMaxHistoryMessages(); @@ -67,14 +70,11 @@ static List trim( List trimmed; if (firstSegment >= segments.size()) { - trimmed = List.of(); + 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)); - if (maxTokens > 0 && estimateTokens(trimmed) > maxTokens) { - trimmed = List.of(); - } } int dropped = originalSize - trimmed.size(); @@ -90,15 +90,22 @@ static int estimateTokens(List messages) { } private static int estimateTokens(List messages, int from, int to) { - int chars = 0; + long chars = 0; for (int i = from; i < to; i++) { chars += estimateMessageChars(messages.get(i)); } return tokensFromChars(chars); } - private static int tokensFromChars(int chars) { - return (chars + CHARS_PER_TOKEN - 1) / CHARS_PER_TOKEN; + 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 segments, int maxMessages) { @@ -118,14 +125,24 @@ private static int findFirstSegmentForMessageLimit(List segments, int m private static int findFirstSegmentForTokenLimit( List history, List 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); - Segment last = segments.get(segments.size() - 1); if (estimateTokens(history, first.start, last.end + 1) <= maxTokens) { return firstSegment; } } - return segments.size(); + return lastSegment; } private static List buildSegments(List history) { diff --git a/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIConversationHistoryTokenLimitTest.java b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIConversationHistoryTokenLimitTest.java new file mode 100644 index 0000000000000..2072271837034 --- /dev/null +++ b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIConversationHistoryTokenLimitTest.java @@ -0,0 +1,110 @@ +/* + * 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.List; +import java.util.concurrent.atomic.AtomicReference; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.openai.models.chat.completions.ChatCompletionMessageParam; +import org.apache.camel.Exchange; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.test.infra.openai.mock.OpenAIMock; +import org.apache.camel.test.junit6.CamelTestSupport; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +class OpenAIConversationHistoryTokenLimitTest extends CamelTestSupport { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String TURN_1 = "turn-1" + "a".repeat(34); + private static final String TURN_2 = "turn-2" + "b".repeat(34); + private static final String TURN_3 = "turn-3" + "c".repeat(34); + private static final String TURN_4 = "turn-4" + "d".repeat(34); + + private final AtomicReference fourthRequestBody = new AtomicReference<>(); + + @RegisterExtension + public OpenAIMock openAIMock = new OpenAIMock().builder() + .when(TURN_1) + .replyWith("reply-1") + .end() + .when(TURN_2) + .replyWith("reply-2") + .end() + .when(TURN_3) + .replyWith("reply-3") + .end() + .when(TURN_4) + .assertRequest(fourthRequestBody::set) + .replyWith("reply-4") + .end() + .build(); + + @Override + protected RouteBuilder createRouteBuilder() { + String endpoint = "openai:chat-completion?model=gpt-5&apiKey=dummy&conversationMemory=true" + + "&maxHistoryTokens=15&baseUrl=" + openAIMock.getBaseUrl() + "/v1"; + + return new RouteBuilder() { + @Override + public void configure() { + from("direct:token-limited") + .to(endpoint) + .setBody(constant(TURN_2)) + .to(endpoint) + .setBody(constant(TURN_3)) + .to(endpoint) + .setBody(constant(TURN_4)) + .to(endpoint); + } + }; + } + + @Test + void maxHistoryTokensShouldTrimHistoryAndAllowSubsequentCalls() throws Exception { + Exchange result = template.request("direct:token-limited", e -> e.getIn().setBody(TURN_1)); + + assertThat(result.getException()).isNull(); + assertThat(result.getMessage().getBody(String.class)).isEqualTo("reply-4"); + + @SuppressWarnings("unchecked") + List history + = result.getProperty("CamelOpenAIConversationHistory", List.class); + assertThat(history).isNotEmpty(); + assertThat(OpenAIConversationHistoryTrimmer.estimateTokens(history)).isLessThanOrEqualTo(15); + + String request = fourthRequestBody.get(); + assertThat(request).isNotNull(); + + JsonNode messages = MAPPER.readTree(request).get("messages"); + assertThat(messages).isNotNull(); + assertThat(messages) + .noneMatch(message -> TURN_1.equals(message.path("content").asText()) + || "reply-1".equals(message.path("content").asText()) + || TURN_2.equals(message.path("content").asText())); + assertThat(messages) + .anyMatch(message -> TURN_3.equals(message.path("content").asText())); + assertThat(messages) + .anyMatch(message -> "reply-3".equals(message.path("content").asText())); + assertThat(messages) + .anyMatch(message -> TURN_4.equals(message.path("content").asText())); + } +} diff --git a/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIConversationHistoryTrimmerTest.java b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIConversationHistoryTrimmerTest.java index 51d6f03eb493e..78f5a64ebbe48 100644 --- a/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIConversationHistoryTrimmerTest.java +++ b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIConversationHistoryTrimmerTest.java @@ -116,7 +116,48 @@ void trimShouldCountToolCallArgumentsTowardTokenEstimate() { List trimmed = OpenAIConversationHistoryTrimmer.trim(history, config(0, 10)); - assertThat(trimmed).isEmpty(); + assertThat(trimmed).hasSize(2); + assertThat(trimmed.get(0).assistant()).isPresent(); + assertThat(trimmed.get(1).tool()).isPresent(); + } + + @Test + void trimShouldReturnMutableListThatSupportsSubsequentAdds() { + List history = List.of( + userMessage(repeat('a', 40)), + assistantMessage(repeat('b', 40)), + userMessage(repeat('c', 40))); + + List trimmed = OpenAIConversationHistoryTrimmer.trim(history, config(0, 15)); + + trimmed.add(userMessage("next")); + + assertThat(trimmed).hasSize(2); + assertThat(extractText(trimmed.get(0))).isEqualTo(repeat('c', 40)); + assertThat(extractText(trimmed.get(1))).isEqualTo("next"); + } + + @Test + void trimShouldReturnMutableEmptyListForEmptyHistory() { + List trimmed + = OpenAIConversationHistoryTrimmer.trim(new ArrayList<>(), config(0, 10)); + + trimmed.add(userMessage("one")); + + assertThat(trimmed).hasSize(1); + } + + @Test + void trimShouldRetainLastSegmentWhenItAloneExceedsTokenLimit() { + List history = List.of( + userMessage("older"), + assistantMessage("older-reply"), + userMessage(repeat('x', 80))); + + List trimmed = OpenAIConversationHistoryTrimmer.trim(history, config(0, 10)); + + assertThat(trimmed).hasSize(1); + assertThat(extractText(trimmed.get(0))).isEqualTo(repeat('x', 80)); } @Test From 819613479c680ff93a6a6b81b84d83da4437ea11 Mon Sep 17 00:00:00 2001 From: Omar Atie Date: Mon, 20 Jul 2026 11:35:24 -0700 Subject: [PATCH 5/5] CAMEL-23394: Regenerate endpoint DSL for maxHistoryTokens description Sync generated Endpoint DSL javadoc with OpenAIConfiguration metadata so CI sourcecheck passes after the maxHistoryTokens description update. Co-authored-by: Cursor --- .../endpoint/dsl/OpenAIEndpointBuilderFactory.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/OpenAIEndpointBuilderFactory.java b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/OpenAIEndpointBuilderFactory.java index 11502314f267a..901192e02a690 100644 --- a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/OpenAIEndpointBuilderFactory.java +++ b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/OpenAIEndpointBuilderFactory.java @@ -525,8 +525,9 @@ default OpenAIEndpointBuilder maxHistoryMessages(String maxHistoryMessages) { * 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. When 0, no - * token limit is applied. + * 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. * * The option is a: int type. * @@ -545,8 +546,9 @@ default OpenAIEndpointBuilder maxHistoryTokens(int maxHistoryTokens) { * 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. When 0, no - * token limit is applied. + * 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. * * The option will be converted to a int type. *