This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch tui-ollama-tab in repository https://gitbox.apache.org/repos/asf/camel.git
commit a3ed5ea24f16f8d6d2d2fe0bbbd047022ff03351 Author: Claus Ibsen <[email protected]> AuthorDate: Thu Sep 17 12:20:03 2026 +0200 CAMEL-24794: camel-jbang - TUI Ollama tab shows context fill per turn and compactions Each request now carries the context window of the runner that served it (the slot size, else the loaded model's allocated context, fetched on the spot when the tab has not polled yet). The request log gains a CTX column with the share of the window the prompt filled (evaluated plus cached tokens), yellow from 50% and red from 80%. The Context panel gains a "turns" line: one bar per AI panel turn scaled to the whole window, so a growing conversation and the drop of a compaction are visible at a glance, followed by the latest fill, the session peak and the number of compactions seen. A prompt a fifth smaller than the previous turn counts as a compaction. The same figures are in tui_get_ollama (promptTokens, contextSize, contextPercent per request; peakContextPercent and compactions in the session block), the F1 help and the user manual. Co-Authored-By: Claude Fable 5.1 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../modules/ROOT/pages/camel-jbang-tui.adoc | 8 +- .../dsl/jbang/core/commands/tui/OllamaMonitor.java | 100 +++++++++++++++++++-- .../dsl/jbang/core/commands/tui/OllamaTab.java | 60 ++++++++++++- .../src/main/resources/tui/help/ollama.md | 8 ++ .../jbang/core/commands/tui/OllamaMonitorTest.java | 31 +++++++ .../core/commands/tui/OllamaTabRenderTest.java | 26 +++++- 6 files changed, 218 insertions(+), 15 deletions(-) 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 f8c97d5ced6f..5851b3104896 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc @@ -427,11 +427,13 @@ or at the endpoint the AI panel (*F8*) is using. otherwise from the last request; time to first token and load time (a load of a second or more is a cold start); session averages and a sparkline of the decode rate. * *Context* -- how full the context window is, the share of the prompt served from Ollama's cache, - whether the model is working or idle, and the speculative decoding method in use. + whether the model is working or idle, the speculative decoding method in use, and a per-turn trend + of how much of the window each AI panel prompt filled, with the session peak and the compactions seen. * *Host* -- GPU utilization and memory (Apple silicon through `ioreg`, NVIDIA through `nvidia-smi`), and CPU and memory of the Ollama server and its model runner. -* *Requests* -- one line per request with input, output and cached tokens, prefill and decode - tokens per second, time to first token, total time and the stop reason. +* *Requests* -- one line per request with input, output and cached tokens, the share of the context + window the prompt filled, prefill and decode tokens per second, time to first token, total time and + the stop reason. Two kinds of requests appear in the log. Questions asked in the AI panel with Ollama as the provider come with the timings Ollama reports for each request (prompt evaluation, generation, model load, diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitor.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitor.java index 0b6cca44a239..ceaf4c63dd42 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitor.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitor.java @@ -140,10 +140,26 @@ final class OllamaMonitor { ROUTE } - /** One request as Ollama reported it (TUI) or as the GenAI span of a route recorded it (no phase timings). */ + /** + * One request as Ollama reported it (TUI) or as the GenAI span of a route recorded it (no phase timings). + * {@code contextSize} is the context window of the runner that served it, 0 when unknown. + */ record RequestEntry(Instant timestamp, RequestSource source, String routeId, String model, int inputTokens, int outputTokens, int cachedTokens, long prefillMs, long decodeMs, long loadMs, long totalMs, - String doneReason) { + String doneReason, long contextSize) { + + /** Everything the model had in front of it: prompt tokens evaluated plus those served from cache. */ + long promptTokens() { + return (long) inputTokens + cachedTokens; + } + + /** Share of the context window the prompt filled, or -1 when the window is unknown. */ + int contextPercent() { + if (contextSize <= 0) { + return -1; + } + return (int) Math.min(100, promptTokens() * 100 / contextSize); + } double prefillTokensPerSecond() { return prefillMs > 0 ? inputTokens * 1000.0 / prefillMs : 0; @@ -169,15 +185,16 @@ final class OllamaMonitor { } record SessionTotals(int requests, long inputTokens, long outputTokens, long cachedTokens, long prefillMs, - long decodeMs, long loadMs, int coldStarts) { + long decodeMs, long loadMs, int coldStarts, int peakContextPercent, int compactions) { - static final SessionTotals EMPTY = new SessionTotals(0, 0, 0, 0, 0, 0, 0, 0); + static final SessionTotals EMPTY = new SessionTotals(0, 0, 0, 0, 0, 0, 0, 0, 0, 0); - SessionTotals plus(RequestEntry e) { + SessionTotals plus(RequestEntry e, boolean compaction) { return new SessionTotals( requests + 1, inputTokens + e.inputTokens(), outputTokens + e.outputTokens(), cachedTokens + e.cachedTokens(), prefillMs + e.prefillMs(), decodeMs + e.decodeMs(), - loadMs + e.loadMs(), coldStarts + (e.coldStart() ? 1 : 0)); + loadMs + e.loadMs(), coldStarts + (e.coldStart() ? 1 : 0), + Math.max(peakContextPercent, e.contextPercent()), compactions + (compaction ? 1 : 0)); } double avgDecodeTokensPerSecond() { @@ -227,6 +244,7 @@ final class OllamaMonitor { private final Deque<RequestEntry> requests = new ArrayDeque<>(); private final LinkedHashSet<String> seenSpanIds = new LinkedHashSet<>(); private SessionTotals totals = SessionTotals.EMPTY; + private long lastTuiPromptTokens; private final TokenRateWindow decodeWindow = new TokenRateWindow(RATE_WINDOW_MS); private final TokenRateWindow prefillWindow = new TokenRateWindow(RATE_WINDOW_MS); private final long[] decodeHistory = new long[HISTORY_POINTS]; @@ -272,12 +290,53 @@ final class OllamaMonitor { RequestEntry entry = new RequestEntry( Instant.now(), RequestSource.TUI, null, model != null ? model : "unknown", usage.inputTokens(), usage.outputTokens(), usage.cachedTokens(), - usage.prefillMillis(), usage.generationMillis(), usage.loadMillis(), total, doneReason); + usage.prefillMillis(), usage.generationMillis(), usage.loadMillis(), total, doneReason, + contextSizeForNewRequest()); synchronized (lock) { addRequest(entry); } } + /** + * The context window that served a request that just finished: the runner's slot size when known, else the + * allocated context of the loaded model, fetched on the spot when the tab has not polled yet. A request with a + * different {@code num_ctx} makes Ollama reload before answering, so what is loaded after the reply is what served + * it. + */ + private long contextSizeForNewRequest() { + long known = knownContextSize(); + if (known > 0) { + return known; + } + String base = baseUrl; + if (base != null) { + JsonObject ps = getJsonObject(base + "/api/ps"); + if (ps != null) { + List<LoadedModel> loaded = OllamaParsers.parsePs(ps); + if (!loaded.isEmpty()) { + synchronized (lock) { + List<LoadedModel> withShapes = new ArrayList<>(); + for (LoadedModel m : loaded) { + withShapes.add(m.withShape(shapes.get(m.name()))); + } + models = List.copyOf(withShapes); + } + return loaded.get(0).contextLength(); + } + } + } + return 0; + } + + private long knownContextSize() { + synchronized (lock) { + if (slot != null && slot.contextSize() > 0) { + return slot.contextSize(); + } + return models.isEmpty() ? 0 : models.get(0).contextLength(); + } + } + /** * Adds the Ollama calls Camel routes made, from the GenAI observability spans of the selected integration. Spans * are deduplicated by id, so the same list can be handed over on every refresh. @@ -313,7 +372,7 @@ final class OllamaMonitor { (int) OllamaParsers.num(attrs, GenAiAttributes.INPUT_TOKENS), (int) OllamaParsers.num(attrs, GenAiAttributes.OUTPUT_TOKENS), 0, 0, 0, 0, Math.max(0, span.durationMs()), - OllamaParsers.str(attrs, GenAiAttributes.FINISH_REASONS))); + OllamaParsers.str(attrs, GenAiAttributes.FINISH_REASONS), knownContextSizeLocked())); } // spans arrive oldest first; keep the log newest first fresh.sort((a, b) -> a.timestamp().compareTo(b.timestamp())); @@ -341,18 +400,36 @@ final class OllamaMonitor { synchronized (lock) { requests.clear(); totals = SessionTotals.EMPTY; + lastTuiPromptTokens = 0; Arrays.fill(decodeHistory, 0); decodeWindow.clear(); prefillWindow.clear(); } } + /** As {@link #knownContextSize()} for callers already holding the lock. */ + private long knownContextSizeLocked() { + if (slot != null && slot.contextSize() > 0) { + return slot.contextSize(); + } + return models.isEmpty() ? 0 : models.get(0).contextLength(); + } + private void addRequest(RequestEntry entry) { + // an AI panel prompt that shrinks by a fifth or more against the previous turn means the history was + // compacted (or a new conversation started); either way the context was freed + boolean compaction = false; + if (entry.source() == RequestSource.TUI) { + if (lastTuiPromptTokens > 0 && entry.promptTokens() < lastTuiPromptTokens * 0.8) { + compaction = true; + } + lastTuiPromptTokens = entry.promptTokens(); + } requests.addFirst(entry); while (requests.size() > MAX_REQUESTS) { requests.removeLast(); } - totals = totals.plus(entry); + totals = totals.plus(entry, compaction); } // ---- state updates (also the test seam) ---- @@ -870,6 +947,8 @@ final class OllamaMonitor { session.put("avgDecodeTokensPerSecond", round1(s.totals().avgDecodeTokensPerSecond())); session.put("avgPrefillTokensPerSecond", round1(s.totals().avgPrefillTokensPerSecond())); session.put("coldStarts", s.totals().coldStarts()); + session.put("peakContextPercent", s.totals().peakContextPercent()); + session.put("compactions", s.totals().compactions()); root.put("session", session); JsonArray reqs = new JsonArray(); @@ -906,6 +985,9 @@ final class OllamaMonitor { r.put("decodeTokensPerSecond", round1(e.decodeTokensPerSecond())); r.put("ttftMs", e.ttftMs()); r.put("coldStart", e.coldStart()); + r.put("promptTokens", e.promptTokens()); + r.put("contextSize", e.contextSize()); + r.put("contextPercent", e.contextPercent()); if (e.doneReason() != null) { r.put("doneReason", e.doneReason()); } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTab.java index df6e7d259e61..e532e60b0e79 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTab.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTab.java @@ -337,6 +337,10 @@ class OllamaTab extends AbstractTab { state.add(Span.styled(" · speculative " + slot.speculative(), Theme.muted())); } lines.add(Line.from(state)); + Line trend = contextTrendLine(s, block.inner(area).width()); + if (trend != null) { + lines.add(trend); + } } else { RequestEntry last = s.lastRequest(); long ctx = model != null ? model.contextLength() : 0; @@ -357,10 +361,47 @@ class OllamaTab extends AbstractTab { lines.add(Line.from(Span.styled(s.local() ? " live state appears once the runner is found" : " live state needs the runner on this machine", Theme.muted().dim()))); + Line trend = contextTrendLine(s, block.inner(area).width()); + if (trend != null) { + lines.add(trend); + } } frame.renderWidget(Paragraph.builder().text(Text.from(lines)).block(block).build(), area); } + /** + * How full the context was on each of the last AI panel turns, oldest first, scaled to the whole window so the bars + * are comparable across turns; a drop between bars is a compaction. Null when no turn has a known window. + */ + static Line contextTrendLine(Snapshot s, int width) { + List<RequestEntry> turns = new ArrayList<>(); + for (RequestEntry e : s.requests()) { + if (e.source() == RequestSource.TUI && e.contextPercent() >= 0) { + turns.add(e); + } + } + if (turns.isEmpty()) { + return null; + } + int last = turns.get(0).contextPercent(); + SessionTotals t = s.totals(); + String suffix = " " + last + "%" + (t.peakContextPercent() > last ? " (peak " + t.peakContextPercent() + "%)" : "") + + (t.compactions() > 0 + ? " · " + t.compactions() + (t.compactions() == 1 ? " compaction" : " compactions") + : ""); + int barWidth = Math.max(4, Math.min(24, width - 8 - suffix.length())); + int n = Math.min(barWidth, turns.size()); + long[] data = new long[n]; + for (int i = 0; i < n; i++) { + data[n - 1 - i] = turns.get(i).contextPercent(); + } + Style level = last >= 80 ? Theme.error() : last >= 50 ? Theme.warning() : Theme.info(); + return Line.from( + Span.styled(" turns ", Theme.muted()), + Span.styled(sparkline(data, barWidth, 100), level), + Span.styled(suffix, level)); + } + private void renderHost(Frame frame, Rect area, Snapshot s) { List<Line> lines = new ArrayList<>(); HostStats host = s.host(); @@ -419,6 +460,9 @@ class OllamaTab extends AbstractTab { rightCell(formatTokens(e.inputTokens()), 6), rightCell(formatTokens(e.outputTokens()), 6), rightCell(e.cachedTokens() > 0 ? formatTokens(e.cachedTokens()) : "-", 6, Theme.muted()), + rightCell(e.contextPercent() >= 0 ? e.contextPercent() + "%" : "-", 5, + e.contextPercent() >= 80 ? Theme.error() : e.contextPercent() >= 50 ? Theme.warning() + : Style.EMPTY), rightCell(e.prefillMs() > 0 ? formatRate(e.prefillTokensPerSecond()) : "-", 8, Theme.info()), rightCell(e.decodeMs() > 0 ? formatRate(e.decodeTokensPerSecond()) : "-", 8, Theme.success()), rightCell(e.hasTimings() ? formatSeconds(e.ttftMs()) : "-", 7, @@ -448,6 +492,7 @@ class OllamaTab extends AbstractTab { rightCell("IN", 6, Style.EMPTY.bold()), rightCell("OUT", 6, Style.EMPTY.bold()), rightCell("CACHED", 6, Style.EMPTY.bold()), + rightCell("CTX", 5, Style.EMPTY.bold()), rightCell("PREFILL", 8, Style.EMPTY.bold()), rightCell("DECODE", 8, Style.EMPTY.bold()), rightCell("TTFT", 7, Style.EMPTY.bold()), @@ -460,6 +505,7 @@ class OllamaTab extends AbstractTab { Constraint.length(6), Constraint.length(6), Constraint.length(6), + Constraint.length(5), Constraint.length(8), Constraint.length(8), Constraint.length(7), @@ -468,7 +514,9 @@ class OllamaTab extends AbstractTab { .highlightStyle(Theme.selectionBg()) .highlightSpacing(Table.HighlightSpacing.ALWAYS) .block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL) - .title(" Requests (" + requests.size() + ") tok/s for prefill and decode ").build()) + .title(" Requests (" + requests.size() + + ") tok/s for prefill and decode · CTX = prompt share of the context window ") + .build()) .build(); lastTableArea = area; frame.renderStatefulWidget(table, area, tableState); @@ -635,13 +683,21 @@ class OllamaTab extends AbstractTab { /** Right-aligned one-row sparkline scaled to the largest value; an all-zero series renders as spaces. */ static String sparkline(long[] data, int width) { - if (data == null || width <= 0) { + if (data == null) { return ""; } long max = 0; for (long v : data) { max = Math.max(max, v); } + return sparkline(data, width, max); + } + + /** As {@link #sparkline(long[], int)} with a fixed scale, so bars stay comparable across renders. */ + static String sparkline(long[] data, int width, long max) { + if (data == null || width <= 0) { + return ""; + } StringBuilder sb = new StringBuilder(); int start = Math.max(0, data.length - width); for (int i = 0; i < width - Math.min(width, data.length); i++) { diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/ollama.md b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/ollama.md index 8e75cc50ca53..63af33fc2a0f 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/ollama.md +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/ollama.md @@ -79,6 +79,13 @@ loads on the first request. - **speculative** — the speculative decoding method the runner uses (`draft-mtp` is multi-token prediction), which is why decode can exceed one token per step. +- **turns** — one bar per AI panel turn, oldest first, scaled to the + whole context window: how much of the window each turn's prompt + filled (system prompt, tool definitions, history, question). The + bars grow as a conversation continues and drop when the history is + compacted; the line ends with the latest fill, the session peak and + the number of compactions seen. Yellow from 50%, red from 80%: that is + when `/compact` in the AI panel, or a smaller toolset, pays off. Without the local runner the panel falls back to the last request's tokens against the model's context length. @@ -106,6 +113,7 @@ One line per request, newest first: | IN | Prompt tokens evaluated | | OUT | Tokens generated | | CACHED | Prompt tokens served from Ollama's cache (`-` when none) | +| CTX | Share of the context window the prompt filled (IN plus CACHED against the window that served it) | | PREFILL | Prompt tokens per second | | DECODE | Generated tokens per second | | TTFT | Time to first token (load plus prefill), shown in yellow after a cold start | diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitorTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitorTest.java index cb2b325e32d9..bd8d7c1ecf3e 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitorTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitorTest.java @@ -136,6 +136,37 @@ class OllamaMonitorTest { assertEquals(4100, e.totalMs()); assertFalse(e.hasTimings()); assertEquals(0, e.ttftMs()); + // no runner slot and no model polled yet: the window is unknown + assertEquals(0, e.contextSize()); + assertEquals(-1, e.contextPercent()); + } + + @Test + void contextFillComesFromTheSlotOrTheLoadedModelAndCompactionsAreCounted() { + OllamaMonitor monitor = new OllamaMonitor(); + monitor.updateServer(new ServerInfo("http://localhost:11434", "0.33.3", true)); + monitor.updateModels(List.of(new OllamaMonitor.LoadedModel( + "m", "f", "35.5B", "Q4_K_M", 1, 1, 32_768, null, null))); + monitor.recordRequest("m", new LlmClient.TokenUsage(4_000, 60, 4_060, 500, 900, 800, 0, 1800), 0, "stop"); + RequestEntry first = monitor.snapshot().lastRequest(); + assertEquals(32_768, first.contextSize()); + assertEquals(4_500, first.promptTokens()); + assertEquals(13, first.contextPercent()); + + // the runner's slot wins over the model list when present + monitor.updateSlot(slot(false, 0, System.currentTimeMillis())); + monitor.recordRequest("m", new LlmClient.TokenUsage(20_000, 60, 20_060, 0, 900, 800, 0, 1800), 0, "stop"); + assertEquals(262144, monitor.snapshot().lastRequest().contextSize()); + assertEquals(7, monitor.snapshot().lastRequest().contextPercent()); + assertEquals(13, monitor.snapshot().totals().peakContextPercent()); + assertEquals(0, monitor.snapshot().totals().compactions()); + + // a prompt a fifth smaller than the previous turn counts as a compaction + monitor.recordRequest("m", new LlmClient.TokenUsage(9_000, 60, 9_060, 0, 900, 800, 0, 1800), 0, "stop"); + assertEquals(1, monitor.snapshot().totals().compactions()); + JsonObject last = (JsonObject) ((JsonArray) monitor.toJson(1).get("requests")).get(0); + assertEquals(9_000L, last.get("promptTokens")); + assertEquals(3, last.get("contextPercent")); } @Test diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTabRenderTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTabRenderTest.java index 46d7d515b5d0..668c73bae5ed 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTabRenderTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTabRenderTest.java @@ -110,6 +110,7 @@ class OllamaTabRenderTest { assertTrue(rendered.contains("ollama serve"), rendered); // request log assertTrue(rendered.contains("Requests (1)"), rendered); + assertTrue(rendered.contains("CTX"), rendered); assertTrue(rendered.contains("PREFILL"), rendered); assertTrue(rendered.contains("DECODE"), rendered); assertTrue(rendered.contains("tui"), rendered); @@ -169,6 +170,24 @@ class OllamaTabRenderTest { assertEquals(1, ((JsonArray) summary.get("loadedModels")).size()); } + @Test + void contextTrendShowsTurnsPeakAndCompactions() { + localServerWithModel(); // ctx 262,144 + // four turns: the prompt grows, then a compaction frees most of it + monitor.recordRequest("m", usage(26_000), 0, "stop"); + monitor.recordRequest("m", usage(52_000), 0, "stop"); + monitor.recordRequest("m", usage(131_000), 0, "stop"); + monitor.recordRequest("m", usage(39_000), 0, "stop"); + + String rendered = TuiTestHelper.renderToString(new OllamaTab(ctx, monitor), 200, 40); + assertTrue(rendered.contains("turns"), rendered); + assertTrue(rendered.contains("14% (peak 49%) · 1 compaction"), rendered); + // the CTX column of the biggest turn + assertTrue(rendered.contains("49%"), rendered); + assertEquals(49, monitor.snapshot().totals().peakContextPercent()); + assertEquals(1, monitor.snapshot().totals().compactions()); + } + @Test void helpTextExplainsThePhasesAndColumns() { String help = new OllamaTab(ctx, monitor).getHelpText(); @@ -197,6 +216,7 @@ class OllamaTabRenderTest { assertEquals(" ▁▄█", OllamaTab.sparkline(new long[] { 10, 40, 80 }, 5)); assertEquals(" ", OllamaTab.sparkline(new long[] { 0, 0 }, 4)); assertEquals("▁█", OllamaTab.sparkline(new long[] { 1, 2, 3, 20 }, 2)); + assertEquals("▁▄█", OllamaTab.sparkline(new long[] { 10, 50, 100 }, 3, 100)); Instant now = Instant.parse("2026-09-17T09:18:12Z"); assertEquals("unloads in 4m32s", OllamaTab.formatCountdown(Instant.parse("2026-09-17T09:22:44Z"), now)); assertEquals("unloads in 2h5m", OllamaTab.formatCountdown(now.plusSeconds(2 * 3600 + 300), now)); @@ -219,6 +239,10 @@ class OllamaTabRenderTest { monitor.updateModels(List.of(model())); } + private static LlmClient.TokenUsage usage(int promptTokens) { + return new LlmClient.TokenUsage(promptTokens, 50, promptTokens + 50, 0, 900, 800, 10, 1800); + } + private static LoadedModel model() { return new LoadedModel( "qwen3.6:35b-a3b", "qwen35moe", "35.5B", "Q4_K_M", 23567972432L, 23567972432L, 262144, @@ -231,7 +255,7 @@ class OllamaTabRenderTest { private static RequestEntry request(int decodeTokensPerSecond) { return new RequestEntry( Instant.now(), RequestSource.TUI, null, "m", 10, decodeTokensPerSecond, 0, 100, 1000, - 0, 1100, "stop"); + 0, 1100, "stop", 0); } private static SpanEntry routeSpan() {
