Skip to content

feat: display token usage after LLM responses and in spinner#157

Merged
dobesv merged 2 commits into
mainfrom
feat/display-token-usage
Apr 4, 2026
Merged

feat: display token usage after LLM responses and in spinner#157
dobesv merged 2 commits into
mainfrom
feat/display-token-usage

Conversation

@dobesv

@dobesv dobesv commented Apr 4, 2026

Copy link
Copy Markdown
Owner

Summary

Implements #152 — show token usage numbers (input/output/cached tokens) in the CLI output.

What it does

  • After final LLM response: Displays accumulated session token usage as a dimmed line, e.g. [📥 12345 📤 678 💾 9000]
  • In spinner while waiting: Shows session token totals alongside "Generating", e.g. ⠋ Generating [📥 5000 📤 200]
  • Zero values omitted: If there's no caching, cached count is hidden. If both input and output are zero, nothing is shown.
  • Only on final response: Usage is displayed only when tool_results is empty (final turn), not on intermediate tool-call turns. Session totals accumulate across all turns.

Emoji format

Uses compact emoji labels instead of verbose text:

  • 📥 = input tokens
  • 📤 = output tokens
  • 💾 = cached tokens

Provider support

Extracts cached token counts from providers that support it:

  • OpenAI: usage.prompt_tokens_details.cached_tokens (streaming: final usage-only chunk)
  • Claude: usage.cache_read_input_tokens (streaming: cumulative message_delta)
  • Gemini/VertexAI: usageMetadata.cachedContentTokenCount
  • Bedrock: streaming metadata event
  • Cohere: no cache info available

Files changed (15)

  • src/client/common.rsCompletionTokenUsage struct, propagate usage from both streaming/non-streaming paths, spinner message with session totals
  • src/client/stream.rs — Usage tracking in SseHandler, updated take() return type
  • src/client/openai.rs — Cached tokens extraction, streaming usage from final chunk only
  • src/client/claude.rs — Cached tokens extraction, streaming usage from message_start/message_delta (cumulative)
  • src/client/vertexai.rs — Cached tokens extraction (non-stream + stream)
  • src/client/bedrock.rs — Streaming metadata usage extraction
  • src/client/cohere.rscached_tokens: None
  • src/config/session.rs — Session-level CompletionTokenUsage accumulator
  • src/config/mod.rsafter_chat_completion accumulates usage in session
  • src/render/mod.rs + src/render/stream.rs — Spinner message passthrough
  • src/main.rs + src/repl/mod.rs — Display usage after final response, pass usage to after_chat_completion
  • src/acp/server.rs — Handle updated take() 3-tuple
  • .changesets/display-token-usage.md — Changeset

Verification

  • cargo fmt --all --check
  • cargo clippy --all --all-targets -- -D warnings
  • cargo test --all ✅ (162 passed; 1 pre-existing flaky test)

Closes #152

Summary by CodeRabbit

  • New Features
    • Token usage display: Input, output, and cached token counts now appear after each LLM response and are shown in the waiting spinner using a compact emoji format (📥 📤 💾).
    • Session token tracking: Completion token usage accumulates across tool-call turns; session totals are shown after the final response.

Show token usage numbers (input/output/cached tokens) in the output:
- Display accumulated session usage as a dimmed line after the final
  LLM response (when no more tool calls are pending)
- Show session token totals in the spinner while waiting for LLM
- Use emoji format (📥 📤 💾) for compact display
- Only show non-zero values; omit caching info if unused
- Extract cached token counts from providers (OpenAI, Claude,
  Gemini/VertexAI, Bedrock)
- For OpenAI streaming, only capture usage from the final usage-only
  chunk to avoid incorrect intermediate values

Closes #152
@coderabbitai

coderabbitai Bot commented Apr 4, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9c2ba2ca-bcde-4918-8bc5-f81bbfbbf640

📥 Commits

Reviewing files that changed from the base of the PR and between 3dd378d and 4e8713b.

📒 Files selected for processing (4)
  • src/client/bedrock.rs
  • src/client/common.rs
  • src/client/openai.rs
  • src/config/session.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/config/session.rs
  • src/client/bedrock.rs

📝 Walkthrough

Walkthrough

Adds token-usage collection and display: new CompletionTokenUsage type, propagation of input/output/cached token counts from providers through streaming and non-streaming paths, session accumulation, spinner label integration, and printing of usage after final LLM responses.

Changes

Cohort / File(s) Summary
Changelog
.changesets/display-token-usage.md
New changeset documenting token-usage UI (emoji format) and minor version bump.
Core types & streaming plumbing
src/client/common.rs, src/client/stream.rs
Added CompletionTokenUsage and cached_tokens on ChatCompletionsOutput; call_chat_completions* now return usage; SseHandler gains set_usage() and take() returns usage.
Provider clients
src/client/bedrock.rs, src/client/claude.rs, src/client/cohere.rs, src/client/openai.rs, src/client/vertexai.rs
Extract and propagate provider-specific usage fields (input/output/cached) in streaming and non-stream code paths; call handler.set_usage() where applicable.
ACP server
src/acp/server.rs
Destructuring updated to accept and ignore the additional usage value returned by handler.take().
Rendering pipeline
src/render/mod.rs, src/render/stream.rs
Parameterize spinner message through render_stream → markdown/raw stream paths; spinner text can include token usage.
Session & config
src/config/session.rs, src/config/mod.rs
Session stores non-serialized completion_usage; added accessors and add_completion_usage(); Config::after_chat_completion now accepts usage and records it to session.
Application flow (REPL / main)
src/repl/mod.rs, src/main.rs
Capture usage from completion calls, pass usage to after_chat_completion, and conditionally print session-or-response usage after final LLM responses; error paths use default usage.

Sequence Diagram

sequenceDiagram
    participant Provider as LLM Provider
    participant Handler as SseHandler
    participant Client as call_chat_completions*
    participant Main as Main/REPL
    participant Config as Config
    participant Session as Session
    participant Render as Render Pipeline

    Provider->>Handler: stream chunks (including usage metadata)
    Handler->>Handler: set_usage(input, output, cached)
    Handler->>Client: take() -> (text, tool_calls, CompletionTokenUsage)
    Client->>Main: return (text, tool_calls, usage)
    Main->>Config: after_chat_completion(..., usage)
    Config->>Session: add_completion_usage(usage)
    Session->>Session: accumulate completion_usage
    Main->>Render: render_stream(..., spinner_message_with_usage)
    Render->>Render: display spinner including token counts
    Main->>Main: print final usage line after response
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 Hop, I count each hop and byte,
📥 inputs, 📤 outputs, 💾 cached delight.
In spinners they shimmer while models compose,
Then rest in the session where memory grows.
Hooray — small carrots for every token I chose!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately and concisely summarizes the main change: displaying token usage information after LLM responses and in the spinner while waiting.
Linked Issues check ✅ Passed The pull request comprehensively implements all coding requirements from issue #152: token usage display with input/output/cached counts, session-level accumulation across turns, spinner integration with usage, zero-value omission, and provider-specific cached-token extraction.
Out of Scope Changes check ✅ Passed All changes are directly related to implementing token usage display as specified in issue #152; no unrelated modifications were introduced.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/display-token-usage

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/client/common.rs (1)

583-596: ⚠️ Potential issue | 🟠 Major

Don’t lose captured usage on local post-response failures.

Once CompletionTokenUsage::new(...) has run, the provider has already returned billable counts. If a later local step fails here—especially eval_tool_calls()start_directive_inner() falls into the zero-usage error path in src/main.rs Lines 484-486 and 507-509, so session totals silently undercount real completions. Keep usage separate from these fallible local steps or surface it alongside the error.

Also applies to: 637-654

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/common.rs` around lines 583 - 596, Compute and preserve the
billable token usage immediately after calling CompletionTokenUsage::new(...)
and do not let subsequent fallible local steps (like eval_tool_calls(...))
discard it; modify the code paths in start_directive_inner and the blocks around
CompletionTokenUsage::new (including the similar block at 637-654) so that on
any local error you either return a result type that carries both the error and
the Usage or wrap/attach the Usage to the propagated error, e.g., call
CompletionTokenUsage::new before any fallible work, keep the `usage` variable in
scope, and change the error propagation from `Err(e)` to an error variant or
context that includes `usage` when eval_tool_calls(...) (or print/markdown
steps) fail so session totals always reflect the returned usage.
🧹 Nitpick comments (1)
src/client/common.rs (1)

553-565: Extract the spinner-label formatting into one helper.

These blocks are identical. Centralizing the session-usage formatting keeps the streaming and non-streaming paths from drifting the next time the display rules change.

♻️ Suggested extraction
+fn completion_spinner_message(config: &GlobalConfig) -> String {
+    let config = config.read();
+    if let Some(session) = &config.session {
+        let usage = session.completion_usage();
+        if !usage.is_empty() {
+            return format!("Generating [{}]", usage);
+        }
+    }
+    "Generating".to_string()
+}
+
 pub async fn call_chat_completions(
     input: &Input,
     print: bool,
     extract_code: bool,
     client: &dyn Client,
     abort_signal: AbortSignal,
 ) -> Result<(String, Vec<ToolResult>, CompletionTokenUsage)> {
-    let spinner_message = {
-        let config = client.global_config().read();
-        if let Some(session) = &config.session {
-            let su = session.completion_usage();
-            if !su.is_empty() {
-                format!("Generating [{}]", su)
-            } else {
-                "Generating".to_string()
-            }
-        } else {
-            "Generating".to_string()
-        }
-    };
+    let spinner_message = completion_spinner_message(client.global_config());

Also applies to: 607-619

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/common.rs` around lines 553 - 565, The spinner label construction
duplicated in the block that builds spinner_message and the identical block
later should be extracted into a single helper function (e.g.,
format_spinner_label or completion_usage_label) that takes a &Client or
&GlobalConfig/Option<Session> and returns the computed String using
session.completion_usage(); replace both occurrences with calls to that helper
so streaming and non‑streaming paths share the same formatting logic and avoid
drift; update callers that currently call client.global_config().read() ->
session -> completion_usage() to use the new helper.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/client/bedrock.rs`:
- Around line 286-291: The streaming metadata handler and the non-streaming
output path both ignore Bedrock's cacheReadInputTokens by passing None; update
the calls to read data["usage"]["cacheReadInputTokens"] and pass that value
through. Specifically, in the streaming branch where handler.set_usage(...) is
called, replace the third argument None with
data["usage"]["cacheReadInputTokens"].as_u64() (or an Option wrapper if the
field can be missing), and in the non-streaming path set
ChatCompletionsOutput.cached_tokens from data["usage"]["cacheReadInputTokens"]
(using as_u64()/Option) instead of leaving it None so cached token counts flow
through consistently with other providers.

In `@src/client/openai.rs`:
- Around line 196-209: The stream handler is capturing usage via
handler.set_usage(...) but the outgoing streamed chat completion request never
enables OpenAI's usage events; add stream_options: { "include_usage": true } to
the request body used for streamed chat completions so the final SSE chunk
contains usage data. Locate the code that builds/sends the streamed chat
completion request (the same code path that pairs with handler.set_usage) and
inject stream_options.include_usage = true into the JSON request payload for
streaming requests so usage information is emitted and parsed.

In `@src/config/session.rs`:
- Around line 69-70: The completion_usage field is being skipped by serde and
therefore resets on load and is not cleared by clear_messages() or
empty_session(); remove the #[serde(skip)] attribute from the
Session::completion_usage field so it's persisted with the session, and
additionally update clear_messages() and empty_session() methods to explicitly
reset self.completion_usage (e.g., to CompletionTokenUsage::default()) when
clearing to avoid carrying stale totals when clearing in-memory sessions.

In `@src/repl/mod.rs`:
- Around line 1158-1167: When config.read().session is None the code currently
falls back to the last-round `usage`, which misses earlier tool-call rounds;
thread a per-request `CompletionTokenUsage` accumulator through `ask_inner`
(pass it into and update it inside `ask_inner` where tool calls occur) and,
where `display_usage` is computed in the `tool_results.is_empty()` branch, use
the per-request accumulator (if present/initialized) as the fallback instead of
the stale `usage` variable; update references to `CompletionTokenUsage`,
`ask_inner`, `tool_results`, `display_usage`, and `usage` so the accumulator is
created at request start, passed into `ask_inner`, incremented on each
tool-driven round, and used when `config.session` is None.

---

Outside diff comments:
In `@src/client/common.rs`:
- Around line 583-596: Compute and preserve the billable token usage immediately
after calling CompletionTokenUsage::new(...) and do not let subsequent fallible
local steps (like eval_tool_calls(...)) discard it; modify the code paths in
start_directive_inner and the blocks around CompletionTokenUsage::new (including
the similar block at 637-654) so that on any local error you either return a
result type that carries both the error and the Usage or wrap/attach the Usage
to the propagated error, e.g., call CompletionTokenUsage::new before any
fallible work, keep the `usage` variable in scope, and change the error
propagation from `Err(e)` to an error variant or context that includes `usage`
when eval_tool_calls(...) (or print/markdown steps) fail so session totals
always reflect the returned usage.

---

Nitpick comments:
In `@src/client/common.rs`:
- Around line 553-565: The spinner label construction duplicated in the block
that builds spinner_message and the identical block later should be extracted
into a single helper function (e.g., format_spinner_label or
completion_usage_label) that takes a &Client or &GlobalConfig/Option<Session>
and returns the computed String using session.completion_usage(); replace both
occurrences with calls to that helper so streaming and non‑streaming paths share
the same formatting logic and avoid drift; update callers that currently call
client.global_config().read() -> session -> completion_usage() to use the new
helper.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1b8011f4-ffa3-4c36-af72-5c5b9354abd3

📥 Commits

Reviewing files that changed from the base of the PR and between cf05789 and 3dd378d.

📒 Files selected for processing (15)
  • .changesets/display-token-usage.md
  • src/acp/server.rs
  • src/client/bedrock.rs
  • src/client/claude.rs
  • src/client/cohere.rs
  • src/client/common.rs
  • src/client/openai.rs
  • src/client/stream.rs
  • src/client/vertexai.rs
  • src/config/mod.rs
  • src/config/session.rs
  • src/main.rs
  • src/render/mod.rs
  • src/render/stream.rs
  • src/repl/mod.rs

Comment thread src/client/bedrock.rs
Comment thread src/client/openai.rs
Comment thread src/config/session.rs
Comment on lines +69 to +70
#[serde(skip)]
completion_usage: CompletionTokenUsage,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf "\ncompletion_usage field + methods:\n"
sed -n '60,170p' src/config/session.rs

printf "\nclear_messages implementation:\n"
sed -n '532,540p' src/config/session.rs

printf "\nempty_session call sites / clear paths:\n"
rg -n -C3 --type rust '\bempty_session\s*\(|\bclear_messages\s*\(' src

printf "\nsave/load references:\n"
rg -n -C3 --type rust '\b(save|load)\s*\(' src/config/session.rs src/config/mod.rs

printf "\ncompletion_usage references:\n"
rg -n -C2 --type rust 'completion_usage|add_completion_usage' src

Repository: dobesv/harnx

Length of output: 12324


completion_usage is lost on reload and not cleared on clear_messages().

The field uses #[serde(skip)], so sessions loaded from disk reset completion_usage to its default (empty) state. Meanwhile, clear_messages() and empty_session() do not reset it, leaving stale totals after clearing the session in place. Either persist completion_usage as normal session state (remove #[serde(skip)]) or ensure it resets alongside message history.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config/session.rs` around lines 69 - 70, The completion_usage field is
being skipped by serde and therefore resets on load and is not cleared by
clear_messages() or empty_session(); remove the #[serde(skip)] attribute from
the Session::completion_usage field so it's persisted with the session, and
additionally update clear_messages() and empty_session() methods to explicitly
reset self.completion_usage (e.g., to CompletionTokenUsage::default()) when
clearing to avoid carrying stale totals when clearing in-memory sessions.

Comment thread src/repl/mod.rs
Comment on lines +1158 to +1167
if tool_results.is_empty() {
let session_usage = config
.read()
.session
.as_ref()
.map(|s| s.completion_usage().clone());
let display_usage = session_usage.as_ref().unwrap_or(&usage);
if !display_usage.is_empty() {
eprintln!("{}", dimmed_text(&format!("[{}]", display_usage)));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Accumulate tool-call rounds when no session is active.

When config.session is None, display_usage falls back to the last round’s usage. A tool-driven turn outside an explicit session will therefore print incomplete totals on the final line, because earlier rounds were never accumulated anywhere. Please thread a per-request CompletionTokenUsage accumulator through ask_inner and use that as the no-session fallback instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/repl/mod.rs` around lines 1158 - 1167, When config.read().session is None
the code currently falls back to the last-round `usage`, which misses earlier
tool-call rounds; thread a per-request `CompletionTokenUsage` accumulator
through `ask_inner` (pass it into and update it inside `ask_inner` where tool
calls occur) and, where `display_usage` is computed in the
`tool_results.is_empty()` branch, use the per-request accumulator (if
present/initialized) as the fallback instead of the stale `usage` variable;
update references to `CompletionTokenUsage`, `ask_inner`, `tool_results`,
`display_usage`, and `usage` so the accumulator is created at request start,
passed into `ask_inner`, incremented on each tool-driven round, and used when
`config.session` is None.

- Extract Bedrock cacheReadInputTokens in both streaming and non-streaming paths
- Add stream_options.include_usage to OpenAI streaming requests so usage data is actually sent
- Reset completion_usage in clear_messages() to avoid stale totals
- Extract duplicated spinner label construction into spinner_label() helper
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Display token usage

1 participant