This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 7985fce6e4fd CAMEL-24560: Use Long for GenAiUsage token counts
7985fce6e4fd is described below
commit 7985fce6e4fdee20574b0068483f20543980e26f
Author: Omar Atie <[email protected]>
AuthorDate: Thu Sep 3 23:17:57 2026 -0700
CAMEL-24560: Use Long for GenAiUsage token counts
Changes GenAiUsage inputTokens/outputTokens fields from Integer to Long
to avoid lossy casts and Math.toIntExact failures for large token counts
returned by OpenAI and other providers. A convenience Integer factory
overload is kept for LangChain4j/Spring AI call sites. OpenAI embeddings
producer no longer narrows usage headers to int. Micrometer counters use
increment(double) for large values. Tests and upgrade guide included.
Closes #26105
Co-authored-by: Cursor Agent <[email protected]>
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
---
.../component/ai/observability/GenAiUsage.java | 17 ++++-
.../component/ai/observability/GenAiUsageTest.java | 82 ++++++++++++++++++++++
.../ai/observability/GenAiMicrometerSupport.java | 4 +-
.../observability/GenAiObservabilitySpanTest.java | 20 ++++++
.../ai/observability/GenAiObservabilityTest.java | 46 ++++++++++++
.../component/openai/OpenAIEmbeddingsProducer.java | 4 +-
.../camel/component/openai/OpenAIProducer.java | 12 ++--
.../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc | 3 +
8 files changed, 173 insertions(+), 15 deletions(-)
diff --git
a/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiUsage.java
b/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiUsage.java
index a21f66397bd8..0a40e357f1b4 100644
---
a/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiUsage.java
+++
b/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiUsage.java
@@ -20,13 +20,24 @@ package org.apache.camel.component.ai.observability;
* Token usage and completion metadata captured after a GenAI operation.
*/
public record GenAiUsage(
- Integer inputTokens,
- Integer outputTokens,
+ Long inputTokens,
+ Long outputTokens,
String finishReason,
String responseModel) {
- public static GenAiUsage of(Integer inputTokens, Integer outputTokens,
Object finishReason, String responseModel) {
+ public static GenAiUsage of(Long inputTokens, Long outputTokens, Object
finishReason, String responseModel) {
String reason = finishReason == null ? null : finishReason.toString();
return new GenAiUsage(inputTokens, outputTokens, reason,
responseModel);
}
+
+ /**
+ * Convenience factory when token counts come from APIs that expose {@code
Integer} counts (e.g. LangChain4j).
+ */
+ public static GenAiUsage of(Integer inputTokens, Integer outputTokens,
Object finishReason, String responseModel) {
+ return of(toLong(inputTokens), toLong(outputTokens), finishReason,
responseModel);
+ }
+
+ private static Long toLong(Integer value) {
+ return value == null ? null : value.longValue();
+ }
}
diff --git
a/components/camel-ai/camel-ai-observability-api/src/test/java/org/apache/camel/component/ai/observability/GenAiUsageTest.java
b/components/camel-ai/camel-ai-observability-api/src/test/java/org/apache/camel/component/ai/observability/GenAiUsageTest.java
new file mode 100644
index 000000000000..80760ef7a94b
--- /dev/null
+++
b/components/camel-ai/camel-ai-observability-api/src/test/java/org/apache/camel/component/ai/observability/GenAiUsageTest.java
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.ai.observability;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class GenAiUsageTest {
+
+ @Test
+ void shouldCreateUsageWithLongTokenCounts() {
+ GenAiUsage usage = GenAiUsage.of(100L, 50L, "stop", "gpt-4o");
+
+ assertThat(usage.inputTokens()).isEqualTo(100L);
+ assertThat(usage.outputTokens()).isEqualTo(50L);
+ assertThat(usage.finishReason()).isEqualTo("stop");
+ assertThat(usage.responseModel()).isEqualTo("gpt-4o");
+ }
+
+ @Test
+ void shouldAcceptTokenCountsBeyondIntegerMaxValue() {
+ long largeInput = Integer.MAX_VALUE + 1024L;
+ long largeOutput = Integer.MAX_VALUE + 2048L;
+
+ GenAiUsage usage = GenAiUsage.of(largeInput, largeOutput, "length",
"gpt-4.1");
+
+ assertThat(usage.inputTokens()).isEqualTo(largeInput);
+ assertThat(usage.outputTokens()).isEqualTo(largeOutput);
+ }
+
+ @Test
+ void shouldConvertIntegerFactoryArgumentsToLong() {
+ GenAiUsage usage = GenAiUsage.of(12, 8, "stop", "gpt-4o-mini");
+
+ assertThat(usage.inputTokens()).isEqualTo(12L);
+ assertThat(usage.outputTokens()).isEqualTo(8L);
+ }
+
+ @Test
+ void shouldAllowNullTokenCountsAndFinishReason() {
+ GenAiUsage usage = GenAiUsage.of((Long) null, null, null, "gpt-4o");
+
+ assertThat(usage.inputTokens()).isNull();
+ assertThat(usage.outputTokens()).isNull();
+ assertThat(usage.finishReason()).isNull();
+ assertThat(usage.responseModel()).isEqualTo("gpt-4o");
+ }
+
+ @Test
+ void shouldConvertNullIntegerFactoryArgumentsToNullLongFields() {
+ GenAiUsage usage = GenAiUsage.of((Integer) null, (Integer) null, null,
"gpt-4o");
+
+ assertThat(usage.inputTokens()).isNull();
+ assertThat(usage.outputTokens()).isNull();
+ }
+
+ @Test
+ void shouldStringifyNonStringFinishReason() {
+ GenAiUsage usage = GenAiUsage.of(1L, 2L, FinishReason.STOP, "model");
+
+ assertThat(usage.finishReason()).isEqualTo("STOP");
+ }
+
+ private enum FinishReason {
+ STOP
+ }
+}
diff --git
a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiMicrometerSupport.java
b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiMicrometerSupport.java
index 149a4cfc20c1..35bbef8bb662 100644
---
a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiMicrometerSupport.java
+++
b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiMicrometerSupport.java
@@ -71,7 +71,7 @@ final class GenAiMicrometerSupport implements
GenAiMetricsBackend {
}
private void recordTokenCounter(
- Integer tokens, String tokenType, GenAiObservationContext context,
Throwable error) {
+ Long tokens, String tokenType, GenAiObservationContext context,
Throwable error) {
if (tokens == null || tokens <= 0) {
return;
}
@@ -79,7 +79,7 @@ final class GenAiMicrometerSupport implements
GenAiMetricsBackend {
.tags(baseTags(context, error))
.tag(GenAiMetrics.TAG_TOKEN_TYPE, tokenType)
.register(meterRegistry)
- .increment(tokens);
+ .increment(tokens.doubleValue());
}
private static Iterable<Tag> baseTags(GenAiObservationContext context,
Throwable error) {
diff --git
a/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiObservabilitySpanTest.java
b/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiObservabilitySpanTest.java
index 2a99235aea3b..ccf0d3eee575 100644
---
a/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiObservabilitySpanTest.java
+++
b/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiObservabilitySpanTest.java
@@ -74,6 +74,26 @@ class GenAiObservabilitySpanTest extends ExchangeTestSupport
{
assertThat(tags.get(GenAiAttributes.CAMEL_COMPONENT)).isEqualTo("langchain4j-chat");
}
+ @Test
+ void shouldRecordLargeTokenCountsOnSpanAttributes() {
+ long largeInput = Integer.MAX_VALUE + 4096L;
+ long largeOutput = Integer.MAX_VALUE + 8192L;
+
+ Exchange exchange = new DefaultExchange(context);
+ GenAiObservation observation = GenAiObservability.start(exchange,
GenAiObservationContext.builder()
+ .operationName(GenAiOperationName.CHAT)
+ .system("openai")
+ .requestModel("gpt-4.1")
+ .componentScheme("openai")
+ .build());
+ observation.recordSuccess(GenAiUsage.of(largeInput, largeOutput,
"stop", "gpt-4.1"));
+ observation.close();
+
+ Map<String, String> tags = tracer.closedSpans().get(0).tags();
+
assertThat(tags.get(GenAiAttributes.INPUT_TOKENS)).isEqualTo(Long.toString(largeInput));
+
assertThat(tags.get(GenAiAttributes.OUTPUT_TOKENS)).isEqualTo(Long.toString(largeOutput));
+ }
+
@Test
void shouldMarkSpanAsErrorWhenFailureRecorded() {
Exchange exchange = new DefaultExchange(context);
diff --git
a/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiObservabilityTest.java
b/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiObservabilityTest.java
index f58cb29dc2ca..658c3e73b0be 100644
---
a/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiObservabilityTest.java
+++
b/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiObservabilityTest.java
@@ -70,6 +70,52 @@ class GenAiObservabilityTest extends ExchangeTestSupport {
.count()).isEqualTo(5);
}
+ @Test
+ void shouldRecordLargeTokenCountsInMicrometerMetrics() {
+ long largeInput = Integer.MAX_VALUE + 1024L;
+ long largeOutput = Integer.MAX_VALUE + 2048L;
+
+ SimpleMeterRegistry registry = new SimpleMeterRegistry();
+ context.getRegistry().bind("metricsRegistry", registry);
+
+ Exchange exchange = new DefaultExchange(context);
+ GenAiObservation observation = GenAiObservability.start(exchange,
GenAiObservationContext.builder()
+ .operationName(GenAiOperationName.CHAT)
+ .system("openai")
+ .requestModel("gpt-4.1")
+ .componentScheme("openai")
+ .build());
+ observation.recordSuccess(GenAiUsage.of(largeInput, largeOutput,
"stop", "gpt-4.1"));
+ observation.close();
+
+ assertThat(registry.find(GenAiMetrics.CLIENT_TOKEN_USAGE)
+ .tag(GenAiMetrics.TAG_TOKEN_TYPE,
GenAiMetrics.TOKEN_TYPE_INPUT)
+ .counter()
+ .count()).isEqualTo((double) largeInput);
+ assertThat(registry.find(GenAiMetrics.CLIENT_TOKEN_USAGE)
+ .tag(GenAiMetrics.TAG_TOKEN_TYPE,
GenAiMetrics.TOKEN_TYPE_OUTPUT)
+ .counter()
+ .count()).isEqualTo((double) largeOutput);
+ }
+
+ @Test
+ void shouldSkipMicrometerTokenCountersWhenUsageIsNullOrZero() {
+ SimpleMeterRegistry registry = new SimpleMeterRegistry();
+ context.getRegistry().bind("metricsRegistry", registry);
+
+ Exchange exchange = new DefaultExchange(context);
+ GenAiObservation observation = GenAiObservability.start(exchange,
GenAiObservationContext.builder()
+ .operationName(GenAiOperationName.CHAT)
+ .system("openai")
+ .requestModel("gpt-4o")
+ .componentScheme("openai")
+ .build());
+ observation.recordSuccess(GenAiUsage.of((Long) null, 0L, "stop",
"gpt-4o"));
+ observation.close();
+
+
assertThat(registry.find(GenAiMetrics.CLIENT_TOKEN_USAGE).counter()).isNull();
+ }
+
@Test
void shouldReturnNoopWhenDisabled() {
Properties properties = new Properties();
diff --git
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIEmbeddingsProducer.java
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIEmbeddingsProducer.java
index 08035cae875f..4c3377fb777c 100644
---
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIEmbeddingsProducer.java
+++
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIEmbeddingsProducer.java
@@ -156,8 +156,8 @@ public class OpenAIEmbeddingsProducer extends
DefaultAsyncProducer {
if (response != null) {
message.setHeader(OpenAIConstants.EMBEDDING_RESPONSE_MODEL,
response.model());
if (response.usage() != null) {
- message.setHeader(OpenAIConstants.PROMPT_TOKENS, (int)
response.usage().promptTokens());
- message.setHeader(OpenAIConstants.TOTAL_TOKENS, (int)
response.usage().totalTokens());
+ message.setHeader(OpenAIConstants.PROMPT_TOKENS,
response.usage().promptTokens());
+ message.setHeader(OpenAIConstants.TOTAL_TOKENS,
response.usage().totalTokens());
}
}
message.setHeader(OpenAIConstants.EMBEDDING_COUNT, embeddings.size());
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 6cb5d85c4dc3..b26b1d4fddaa 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
@@ -680,8 +680,8 @@ public class OpenAIProducer extends DefaultAsyncProducer {
CompletionUsage usage = usageRef.get();
String responseModel = responseModelRef.get() != null ?
responseModelRef.get() : requestModel;
observation.recordSuccess(GenAiUsage.of(
- usage != null ? toTokenCount(usage.promptTokens()) :
null,
- usage != null ? toTokenCount(usage.completionTokens())
: null,
+ usage != null ? usage.promptTokens() : null,
+ usage != null ? usage.completionTokens() : null,
null,
responseModel));
observation.close();
@@ -731,10 +731,6 @@ public class OpenAIProducer extends DefaultAsyncProducer {
.orElse("stop");
}
- private static Integer toTokenCount(long tokens) {
- return Math.toIntExact(tokens);
- }
-
private ChatCompletion createChatCompletion(Exchange exchange,
ChatCompletionCreateParams params) {
String requestModel = params.model().toString();
GenAiObservationContext observationContext =
GenAiObservationContext.builder()
@@ -748,8 +744,8 @@ public class OpenAIProducer extends DefaultAsyncProducer {
ChatCompletion response =
getEndpoint().getClient().chat().completions().create(params);
CompletionUsage usage = response.usage().orElse(null);
observation.recordSuccess(GenAiUsage.of(
- usage != null ? toTokenCount(usage.promptTokens()) : null,
- usage != null ? toTokenCount(usage.completionTokens()) :
null,
+ usage != null ? usage.promptTokens() : null,
+ usage != null ? usage.completionTokens() : null,
response.choices().isEmpty() ? null :
getFinishReasonString(response.choices().get(0)),
response.model()));
return response;
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index 14da60b11062..2a62a71a8cb6 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -436,6 +436,9 @@ the registry has tracing and meter handlers respectively.
Token usage counters s
`MeterRegistry`. Applications without an `ObservationRegistry` bean keep the
previous OpenTelemetry
and MeterRegistry behavior. `camel-micrometer-observability` is not required.
+`GenAiUsage` token fields (`inputTokens`, `outputTokens`) are `Long` so OpenAI
and other providers
+that report `long` token counts are recorded without lossy casts.
+
OpenAI streaming chat sets `stream_options.include_usage=true` only when GenAI
observability is enabled,
adding a final chunk with token usage for span/metric recording.