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 dccac5e0a82c CAMEL-23861: Phase 2 - TUI AI Usage view integrates GenAI
OTel spans
dccac5e0a82c is described below
commit dccac5e0a82cd0033de5859f44ca5fe40f44d40c
Author: Omar Atie <[email protected]>
AuthorDate: Fri Aug 14 04:49:09 2026 -0700
CAMEL-23861: Phase 2 - TUI AI Usage view integrates GenAI OTel spans
Extend the Camel JBang TUI AI Usage panel (Ctrl+U) to combine TUI ask
token metrics with route-level GenAI usage extracted from OTel spans
using gen_ai.* semantic convention attributes from camel-ai-observability.
Adds GenAiSpanUsageExtractor, extends AiPanel with source labels
([tui]/[route:<routeId>]), wires OTel span data from CamelMonitor,
and includes unit tests and documentation updates.
Closes #25491
Co-authored-by: Composer <[email protected]>
Co-authored-by: Guillaume Nodet <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
---
.../camel/catalog/docs/ai-observability.adoc | 10 ++
.../src/main/docs/ai-observability.adoc | 10 ++
.../modules/ROOT/pages/camel-jbang-tui.adoc | 4 +
dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml | 4 +
.../camel/dsl/jbang/core/commands/tui/AiPanel.java | 93 ++++++++++++--
.../dsl/jbang/core/commands/tui/CamelMonitor.java | 6 +
.../core/commands/tui/GenAiSpanUsageExtractor.java | 122 +++++++++++++++++++
.../core/commands/tui/AiPanelGenAiUsageTest.java | 133 +++++++++++++++++++++
.../commands/tui/GenAiSpanUsageExtractorTest.java | 125 +++++++++++++++++++
9 files changed, 498 insertions(+), 9 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-observability.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-observability.adoc
index ae8a89b194a0..04e3893b1427 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-observability.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-observability.adoc
@@ -36,3 +36,13 @@ Metrics recorded (when Micrometer is available):
* `gen_ai.client.operation` — operation duration timer
* `gen_ai.client.token.usage` — token usage counter (tags:
`gen_ai.token.type=input|output`)
+
+== Camel TUI integration (Phase 2)
+
+When monitoring a running integration with `camel tui` and observability
enabled, the AI panel usage view
+(*Ctrl+U* while the AI panel is open) combines:
+
+* *TUI ask* token usage from `camel ask` / the embedded AI prompt
+* *Route* GenAI usage extracted from exported OpenTelemetry spans with
`gen_ai.*` attributes
+
+This gives a single dashboard for developer CLI usage and production route LLM
calls.
diff --git
a/components/camel-ai/camel-ai-observability/src/main/docs/ai-observability.adoc
b/components/camel-ai/camel-ai-observability/src/main/docs/ai-observability.adoc
index ae8a89b194a0..04e3893b1427 100644
---
a/components/camel-ai/camel-ai-observability/src/main/docs/ai-observability.adoc
+++
b/components/camel-ai/camel-ai-observability/src/main/docs/ai-observability.adoc
@@ -36,3 +36,13 @@ Metrics recorded (when Micrometer is available):
* `gen_ai.client.operation` — operation duration timer
* `gen_ai.client.token.usage` — token usage counter (tags:
`gen_ai.token.type=input|output`)
+
+== Camel TUI integration (Phase 2)
+
+When monitoring a running integration with `camel tui` and observability
enabled, the AI panel usage view
+(*Ctrl+U* while the AI panel is open) combines:
+
+* *TUI ask* token usage from `camel ask` / the embedded AI prompt
+* *Route* GenAI usage extracted from exported OpenTelemetry spans with
`gen_ai.*` attributes
+
+This gives a single dashboard for developer CLI usage and production route LLM
calls.
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
index 9df667818106..9c7192eab078 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
@@ -807,6 +807,10 @@ When the AI panel is open, input that starts with `/` runs
a local panel command
Submitted prompts (including slash commands) participate in AI prompt history
when
`camel.tui.ai.promptHistory` is not `0`. Use *↑*/*↓* on the prompt line to
recall them.
+Press *Ctrl+U* while the AI panel is open to toggle the AI Usage view. It
shows token consumption from
+the embedded AI prompt (*TUI ask*) and from monitored route LLM calls exported
as OpenTelemetry GenAI spans
+when observability is enabled on the integration.
+
=== Connecting an AI Agent
To connect Claude Code to the TUI, add the MCP server to your project
configuration
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml
b/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml
index 260223b5cdc3..241adb18dbad 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml
@@ -41,6 +41,10 @@
</properties>
<dependencies>
+ <dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-ai-observability-api</artifactId>
+ </dependency>
<dependency>
<groupId>org.apache.camel</groupId>
<artifactId>camel-jbang-core</artifactId>
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
index 665c3d7f3df4..ba7dbb3a91e8 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
@@ -32,6 +32,7 @@ import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
import dev.tamboui.layout.Constraint;
import dev.tamboui.layout.Layout;
@@ -162,9 +163,16 @@ class AiPanel {
// AI usage stats
private final List<AiUsageEntry> usageHistory = new
CopyOnWriteArrayList<>();
+ private AtomicReference<List<SpanEntry>> otelSpans = new
AtomicReference<>(List.of());
private final TableState statsTableState = new TableState();
private boolean statsView;
private int statsScrollOffset;
+ boolean spanRefreshRequested;
+
+ enum AiUsageSource {
+ TUI,
+ ROUTE
+ }
record ConversationEntry(AiRole role, String text, long elapsedSeconds,
int totalTokens) {
ConversationEntry(AiRole role, String text) {
@@ -173,7 +181,14 @@ class AiPanel {
}
record AiUsageEntry(String model, String provider, int inputTokens, int
outputTokens,
- int totalTokens, long latencyMs, String stopReason, Instant
timestamp) {
+ int totalTokens, long latencyMs, String stopReason, Instant
timestamp,
+ AiUsageSource source, String routeId) {
+
+ AiUsageEntry(String model, String provider, int inputTokens, int
outputTokens,
+ int totalTokens, long latencyMs, String stopReason,
Instant timestamp) {
+ this(model, provider, inputTokens, outputTokens, totalTokens,
latencyMs, stopReason, timestamp,
+ AiUsageSource.TUI, null);
+ }
}
void setContext(MonitorContext ctx) {
@@ -202,6 +217,10 @@ class AiPanel {
this.mcpServerPort = port;
}
+ void setOtelSpans(AtomicReference<List<SpanEntry>> otelSpans) {
+ this.otelSpans = otelSpans != null ? otelSpans : new
AtomicReference<>(List.of());
+ }
+
synchronized List<LogEntry> getActivityLog() {
return new ArrayList<>(activityLog);
}
@@ -403,6 +422,9 @@ class AiPanel {
if (ke.hasCtrl() && ke.isCharIgnoreCase('u')) {
statsView = !statsView;
statsScrollOffset = 0;
+ if (statsView) {
+ spanRefreshRequested = true;
+ }
return true;
}
if (ke.hasCtrl() && ke.isCharIgnoreCase('y')) {
@@ -1323,9 +1345,12 @@ class AiPanel {
return;
}
- if (usageHistory.isEmpty()) {
+ List<AiUsageEntry> entries = combinedUsageEntries();
+ if (entries.isEmpty()) {
frame.renderWidget(
- Paragraph.from(Line.from(Span.styled("No AI usage data
yet. Ask a question first.", Style.EMPTY.dim()))),
+ Paragraph.from(Line.from(Span.styled(
+ "No AI usage data yet. Ask a question in the AI
panel, or run routes with GenAI observability and OTel span export
(--observe).",
+ Style.EMPTY.dim()))),
area);
return;
}
@@ -1335,18 +1360,25 @@ class AiPanel {
int totalOutput = 0;
int totalTokens = 0;
long totalLatency = 0;
- for (AiUsageEntry e : usageHistory) {
+ int tuiRequests = 0;
+ int routeRequests = 0;
+ for (AiUsageEntry e : entries) {
totalInput += e.inputTokens();
totalOutput += e.outputTokens();
totalTokens += e.totalTokens();
totalLatency += e.latencyMs();
+ if (e.source() == AiUsageSource.ROUTE) {
+ routeRequests++;
+ } else {
+ tuiRequests++;
+ }
}
- int requestCount = usageHistory.size();
+ int requestCount = entries.size();
// Per-model aggregation
Map<String, int[]> perModel = new LinkedHashMap<>();
- for (AiUsageEntry e : usageHistory) {
- String key = e.model() + " (" + e.provider() + ")";
+ for (AiUsageEntry e : entries) {
+ String key = modelTableKey(e);
int[] stats = perModel.computeIfAbsent(key, k -> new int[4]);
stats[0]++; // requests
stats[1] += e.inputTokens();
@@ -1354,7 +1386,7 @@ class AiPanel {
stats[3] += e.totalTokens();
}
- // Per-conversation token totals (group by consecutive runs between
user questions)
+ // Per-conversation token totals (TUI ask sessions only)
List<Integer> turnTokens = new ArrayList<>();
int currentTurn = 0;
int turnIndex = 0;
@@ -1398,7 +1430,11 @@ class AiPanel {
summaryLines.add(Line.from(
Span.styled("Requests: ", dimStyle),
Span.styled(String.valueOf(requestCount), cyanStyle),
- Span.styled(" Total tokens: ", dimStyle),
+ Span.styled(" (TUI: ", dimStyle),
+ Span.styled(String.valueOf(tuiRequests), cyanStyle),
+ Span.styled(" / routes: ", dimStyle),
+ Span.styled(String.valueOf(routeRequests), cyanStyle),
+ Span.styled(") Total tokens: ", dimStyle),
Span.styled(LlmClient.formatTokens(totalTokens), cyanStyle),
Span.styled(" (in: ", dimStyle),
Span.styled(LlmClient.formatTokens(totalInput),
Theme.success()),
@@ -1482,6 +1518,45 @@ class AiPanel {
}
}
+ private List<AiUsageEntry> combinedUsageEntries() {
+ List<AiUsageEntry> combined = new ArrayList<>(usageHistory.size() + 8);
+ combined.addAll(usageHistory);
+ List<SpanEntry> spans = otelSpans.get();
+ if (spans != null && !spans.isEmpty()) {
+ combined.addAll(GenAiSpanUsageExtractor.extract(spans));
+ }
+ return combined;
+ }
+
+ private static String modelTableKey(AiUsageEntry entry) {
+ String modelProvider = entry.model() + " (" + entry.provider() + ")";
+ if (entry.source() == AiUsageSource.ROUTE) {
+ String route = entry.routeId() != null &&
!entry.routeId().isBlank() ? entry.routeId() : "route";
+ return "[route:" + route + "] " + modelProvider;
+ }
+ return "[tui] " + modelProvider;
+ }
+
+ boolean isStatsView() {
+ return statsView;
+ }
+
+ void toggleStatsViewForTesting() {
+ statsView = !statsView;
+ statsScrollOffset = 0;
+ if (statsView) {
+ spanRefreshRequested = true;
+ }
+ }
+
+ List<AiUsageEntry> combinedUsageEntriesForTesting() {
+ return combinedUsageEntries();
+ }
+
+ void recordUsageForTesting(AiUsageEntry entry) {
+ usageHistory.add(entry);
+ }
+
private void renderScrollbar(Frame frame, Rect area, int totalLines, int
visibleHeight, int scroll) {
int thumbSize = Math.max(1, visibleHeight * visibleHeight /
Math.max(1, totalLines));
int maxScroll = Math.max(1, totalLines - visibleHeight);
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
index 2aa880d1208b..92fdfaebe6c9 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
@@ -575,6 +575,7 @@ public class CamelMonitor extends CamelCommand {
}
});
aiPanel.setMcpFacade(mcpFacade);
+ aiPanel.setOtelSpans(dataService.otelSpans());
mcpFacade.setAiActivityLog(aiPanel::getActivityLog);
Path mcpJsonFile = null;
actionsPopup.setAiActivityLog(aiPanel::getActivityLog);
@@ -2391,6 +2392,11 @@ public class CamelMonitor extends CamelCommand {
tabRegistry.spansTab().spanRefreshRequested = false;
dataService.refreshSpanData();
}
+ if (aiPanel.isOpen() && aiPanel.isStatsView()
+ && ctx.selectedPid != null && aiPanel.spanRefreshRequested) {
+ aiPanel.spanRefreshRequested = false;
+ dataService.refreshSpanData();
+ }
}
// ---- Helpers ----
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GenAiSpanUsageExtractor.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GenAiSpanUsageExtractor.java
new file mode 100644
index 000000000000..ad78b4972c25
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GenAiSpanUsageExtractor.java
@@ -0,0 +1,122 @@
+/*
+ * 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.dsl.jbang.core.commands.tui;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.component.ai.observability.GenAiAttributes;
+
+/**
+ * Converts OTel spans exported from monitored Camel integrations into AI
usage entries for the TUI stats view.
+ */
+final class GenAiSpanUsageExtractor {
+
+ private GenAiSpanUsageExtractor() {
+ }
+
+ static boolean isGenAiSpan(SpanEntry span) {
+ if (span == null || span.attributes() == null) {
+ return false;
+ }
+ return span.attributes().containsKey(GenAiAttributes.OPERATION_NAME);
+ }
+
+ static List<AiPanel.AiUsageEntry> extract(List<SpanEntry> spans) {
+ if (spans == null || spans.isEmpty()) {
+ return List.of();
+ }
+ List<AiPanel.AiUsageEntry> entries = new ArrayList<>();
+ for (SpanEntry span : spans) {
+ if (!isGenAiSpan(span)) {
+ continue;
+ }
+ AiPanel.AiUsageEntry entry = toUsageEntry(span);
+ if (entry != null) {
+ entries.add(entry);
+ }
+ }
+ return List.copyOf(entries);
+ }
+
+ private static AiPanel.AiUsageEntry toUsageEntry(SpanEntry span) {
+ Map<String, Object> attrs = span.attributes();
+ String requestModel = stringAttr(attrs, GenAiAttributes.REQUEST_MODEL);
+ String responseModel = stringAttr(attrs,
GenAiAttributes.RESPONSE_MODEL);
+ String model = !isBlank(responseModel) ? responseModel : requestModel;
+ if (isBlank(model) || "unknown".equalsIgnoreCase(model)) {
+ model = span.name() != null ? span.name() : "unknown";
+ }
+
+ String provider = stringAttr(attrs, GenAiAttributes.SYSTEM);
+ if (isBlank(provider)) {
+ provider = "unknown";
+ }
+
+ int inputTokens = intAttr(attrs, GenAiAttributes.INPUT_TOKENS);
+ int outputTokens = intAttr(attrs, GenAiAttributes.OUTPUT_TOKENS);
+ int totalTokens = inputTokens + outputTokens;
+
+ long latencyMs = span.durationMs() > 0 ? span.durationMs() : 0;
+ String stopReason = stringAttr(attrs, GenAiAttributes.FINISH_REASONS);
+ Instant timestamp = span.startEpochNanos() > 0
+ ? Instant.ofEpochSecond(0, span.startEpochNanos())
+ : Instant.EPOCH;
+
+ return new AiPanel.AiUsageEntry(
+ model,
+ provider,
+ inputTokens,
+ outputTokens,
+ totalTokens,
+ latencyMs,
+ stopReason,
+ timestamp,
+ AiPanel.AiUsageSource.ROUTE,
+ span.routeId());
+ }
+
+ private static String stringAttr(Map<String, Object> attrs, String key) {
+ Object value = attrs.get(key);
+ if (value == null) {
+ return null;
+ }
+ String text = value.toString();
+ return text.isBlank() ? null : text;
+ }
+
+ private static int intAttr(Map<String, Object> attrs, String key) {
+ Object value = attrs.get(key);
+ if (value == null) {
+ return 0;
+ }
+ if (value instanceof Number number) {
+ return number.intValue();
+ }
+ try {
+ return Integer.parseInt(value.toString());
+ } catch (NumberFormatException e) {
+ return 0;
+ }
+ }
+
+ private static boolean isBlank(String value) {
+ return value == null || value.isBlank();
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelGenAiUsageTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelGenAiUsageTest.java
new file mode 100644
index 000000000000..4fa6581b419b
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelGenAiUsageTest.java
@@ -0,0 +1,133 @@
+/*
+ * 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.dsl.jbang.core.commands.tui;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.terminal.Frame;
+import org.apache.camel.component.ai.observability.GenAiAttributes;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class AiPanelGenAiUsageTest {
+
+ private AtomicReference<List<SpanEntry>> spans;
+
+ @BeforeEach
+ void setUp() {
+ Theme.resetForTesting();
+ spans = new AtomicReference<>(List.of());
+ }
+
+ @Test
+ void combinedUsageIncludesRouteGenAiSpans() {
+ AiPanel panel = new AiPanel();
+ panel.setOtelSpans(spans);
+ panel.recordUsageForTesting(new AiPanel.AiUsageEntry(
+ "gpt-4o-mini", "openai", 10, 5, 15, 1000, "stop",
Instant.now()));
+
+ spans.set(List.of(routeSpan("route-chat", "gpt-4o", 100, 20)));
+
+ assertThat(panel.combinedUsageEntriesForTesting()).hasSize(2);
+ assertThat(panel.combinedUsageEntriesForTesting())
+ .anyMatch(entry -> entry.source() ==
AiPanel.AiUsageSource.ROUTE
+ && "route-chat".equals(entry.routeId()));
+ }
+
+ @Test
+ void statsViewRendersRouteAndTuiBreakdown() {
+ AiPanel panel = new AiPanel();
+ panel.setOtelSpans(spans);
+ panel.open();
+ panel.recordUsageForTesting(new AiPanel.AiUsageEntry(
+ "gpt-4o-mini", "openai", 10, 5, 15, 1000, "stop",
Instant.now()));
+ spans.set(List.of(routeSpan("orders", "gpt-4o", 50, 10)));
+ panel.toggleStatsViewForTesting();
+
+ Rect area = new Rect(0, 0, 120, 24);
+ Buffer buffer = Buffer.empty(area);
+ panel.render(Frame.forTesting(buffer), area);
+
+ String rendered = TuiTestHelper.bufferToString(buffer);
+ assertThat(rendered).contains("AI Usage");
+ assertThat(rendered).contains("TUI:");
+ assertThat(rendered).contains("routes:");
+ assertThat(rendered).contains("[tui]");
+ assertThat(rendered).contains("[route:orders]");
+ }
+
+ @Test
+ void togglingStatsViewRequestsSpanRefresh() {
+ AiPanel panel = new AiPanel();
+ assertThat(panel.spanRefreshRequested).isFalse();
+
+ panel.toggleStatsViewForTesting();
+ assertThat(panel.isStatsView()).isTrue();
+ assertThat(panel.spanRefreshRequested).isTrue();
+
+ panel.toggleStatsViewForTesting();
+ assertThat(panel.isStatsView()).isFalse();
+ assertThat(panel.spanRefreshRequested).isTrue();
+ }
+
+ @Test
+ void statsViewShowsGuidanceWhenNoDataAvailable() {
+ AiPanel panel = new AiPanel();
+ panel.setOtelSpans(spans);
+ panel.open();
+ panel.toggleStatsViewForTesting();
+
+ Rect area = new Rect(0, 0, 100, 12);
+ Buffer buffer = Buffer.empty(area);
+ panel.render(Frame.forTesting(buffer), area);
+
+ assertThat(TuiTestHelper.bufferToString(buffer))
+ .contains("No AI usage data yet")
+ .contains("GenAI observability");
+ }
+
+ private static SpanEntry routeSpan(String routeId, String model, int
input, int output) {
+ return new SpanEntry(
+ "trace-1",
+ "span-1",
+ "",
+ "chat " + model,
+ "CLIENT",
+ "OK",
+ Instant.now().getEpochSecond() * 1_000_000_000L,
+ Instant.now().getEpochSecond() * 1_000_000_000L + 500_000_000L,
+ 500,
+ routeId,
+ null,
+ "camel",
+ Map.of(
+ GenAiAttributes.OPERATION_NAME, "chat",
+ GenAiAttributes.SYSTEM, "openai",
+ GenAiAttributes.REQUEST_MODEL, model,
+ GenAiAttributes.RESPONSE_MODEL, model,
+ GenAiAttributes.INPUT_TOKENS, input,
+ GenAiAttributes.OUTPUT_TOKENS, output,
+ GenAiAttributes.FINISH_REASONS, "stop"));
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/GenAiSpanUsageExtractorTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/GenAiSpanUsageExtractorTest.java
new file mode 100644
index 000000000000..67cb3204ec7c
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/GenAiSpanUsageExtractorTest.java
@@ -0,0 +1,125 @@
+/*
+ * 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.dsl.jbang.core.commands.tui;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.component.ai.observability.GenAiAttributes;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class GenAiSpanUsageExtractorTest {
+
+ @Test
+ void isGenAiSpanRequiresOperationNameAttribute() {
+ SpanEntry genAi = span(Map.of(GenAiAttributes.OPERATION_NAME, "chat"));
+ SpanEntry other = span(Map.of("http.method", "GET"));
+
+ assertThat(GenAiSpanUsageExtractor.isGenAiSpan(genAi)).isTrue();
+ assertThat(GenAiSpanUsageExtractor.isGenAiSpan(other)).isFalse();
+ assertThat(GenAiSpanUsageExtractor.isGenAiSpan(null)).isFalse();
+ }
+
+ @Test
+ void extractMapsGenAiSemanticConventionAttributes() {
+ SpanEntry span = span(Map.of(
+ GenAiAttributes.OPERATION_NAME, "chat",
+ GenAiAttributes.SYSTEM, "openai",
+ GenAiAttributes.REQUEST_MODEL, "gpt-4o-mini",
+ GenAiAttributes.RESPONSE_MODEL, "gpt-4o-mini",
+ GenAiAttributes.INPUT_TOKENS, 120,
+ GenAiAttributes.OUTPUT_TOKENS, 45,
+ GenAiAttributes.FINISH_REASONS, "stop"));
+
+ List<AiPanel.AiUsageEntry> entries =
GenAiSpanUsageExtractor.extract(List.of(span));
+
+ assertThat(entries).hasSize(1);
+ AiPanel.AiUsageEntry entry = entries.get(0);
+ assertThat(entry.source()).isEqualTo(AiPanel.AiUsageSource.ROUTE);
+ assertThat(entry.model()).isEqualTo("gpt-4o-mini");
+ assertThat(entry.provider()).isEqualTo("openai");
+ assertThat(entry.inputTokens()).isEqualTo(120);
+ assertThat(entry.outputTokens()).isEqualTo(45);
+ assertThat(entry.totalTokens()).isEqualTo(165);
+ assertThat(entry.latencyMs()).isEqualTo(250);
+ assertThat(entry.stopReason()).isEqualTo("stop");
+ assertThat(entry.routeId()).isEqualTo("route-a");
+ }
+
+ @Test
+ void extractIgnoresNonGenAiSpans() {
+ SpanEntry camelSpan = span(Map.of("camel.routeId", "r1"));
+ SpanEntry genAi = span(Map.of(
+ GenAiAttributes.OPERATION_NAME, "embeddings",
+ GenAiAttributes.SYSTEM, "openai",
+ GenAiAttributes.REQUEST_MODEL, "text-embedding-3-small",
+ GenAiAttributes.INPUT_TOKENS, "10",
+ GenAiAttributes.OUTPUT_TOKENS, "0"));
+
+ List<AiPanel.AiUsageEntry> entries =
GenAiSpanUsageExtractor.extract(List.of(camelSpan, genAi));
+
+ assertThat(entries).hasSize(1);
+ assertThat(entries.get(0).model()).isEqualTo("text-embedding-3-small");
+ }
+
+ @Test
+ void extractFallsBackToRequestModelAndSpanName() {
+ SpanEntry requestOnly = span("chat gpt-4", Map.of(
+ GenAiAttributes.OPERATION_NAME, "chat",
+ GenAiAttributes.REQUEST_MODEL, "gpt-4",
+ GenAiAttributes.SYSTEM, "anthropic"));
+
+ SpanEntry nameFallback = span("embeddings unknown-model", Map.of(
+ GenAiAttributes.OPERATION_NAME, "embeddings",
+ GenAiAttributes.REQUEST_MODEL, "unknown",
+ GenAiAttributes.SYSTEM, "openai"));
+
+
assertThat(GenAiSpanUsageExtractor.extract(List.of(requestOnly)).get(0).model()).isEqualTo("gpt-4");
+
assertThat(GenAiSpanUsageExtractor.extract(List.of(nameFallback)).get(0).model())
+ .isEqualTo("embeddings unknown-model");
+ }
+
+ @Test
+ void extractReturnsEmptyListForNullOrEmptyInput() {
+ assertThat(GenAiSpanUsageExtractor.extract(null)).isEmpty();
+ assertThat(GenAiSpanUsageExtractor.extract(List.of())).isEmpty();
+ }
+
+ private static SpanEntry span(Map<String, Object> attributes) {
+ return span("chat gpt-4o-mini", attributes);
+ }
+
+ private static SpanEntry span(String name, Map<String, Object> attributes)
{
+ return new SpanEntry(
+ "trace-1",
+ "span-1",
+ "",
+ name,
+ "CLIENT",
+ "OK",
+ Instant.parse("2026-08-13T12:00:00Z").getEpochSecond() *
1_000_000_000L,
+ Instant.parse("2026-08-13T12:00:00.250Z").getEpochSecond() *
1_000_000_000L,
+ 250,
+ "route-a",
+ null,
+ "camel",
+ attributes);
+ }
+}