Skip to content

feat(claude): surface per-turn usage as an opt-in footer#391

Open
herikwebb wants to merge 7 commits into
plmbr:mainfrom
herikwebb:feat/claude-turn-usage
Open

feat(claude): surface per-turn usage as an opt-in footer#391
herikwebb wants to merge 7 commits into
plmbr:mainfrom
herikwebb:feat/claude-turn-usage

Conversation

@herikwebb

Copy link
Copy Markdown
Contributor

Problem

The Claude Agent SDK ends every turn with a ResultMessage carrying duration_ms, token usage, and total_cost_usd. The receive loop dropped it in its silent else branch, so users had no signal of what a turn cost short of typing /cost.

Change

  • New format_result_usage helper renders a small italic footer streamed after the turn's content, e.g. *12.3s · 45.2K in (38.1K cached) / 1.2K out · $0.0842*. Prefixed with a paragraph break so it can't concatenate onto prose that lacks a trailing blank line.
  • Opt-in via a new show_turn_usage Claude setting (default off), surfaced as a Settings-panel toggle — a footer on every turn is persistent noise, so it's off unless the user asks for it.
  • Cost is only shown when it can be trusted. total_cost_usd is priced from the CLI's public list rates, which are wrong for subscription logins (marginal cost $0), enterprise-negotiated rates, and non-first-party endpoints. _should_show_turn_cost gates the $ segment on both a direct API key and Anthropic's own endpoint, resolving each from the environment the CLI actually inherits (ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL), not just the settings panel. Duration and token counts — always accurate — still render when cost is suppressed.
  • Error results produce no footer; malformed usage values degrade to whatever segments remain rather than failing the turn.
  • 20 unit tests: the formatter (real ResultMessage instances), the cost gate (API key / base_url / env-resolution matrix), and the paragraph-break guard.

Testing

Full Python suite passes. Manually verified in JupyterLab:

  • Default (toggle off): no footer.
  • Toggle on + direct API key: time · tokens · $cost.
  • Toggle on, key cleared (subscription): time · tokens, no $.
  • Toggle on, custom base_url + key: time · tokens, no $ (proxy/gateway billing can't be trusted).

Herik Webb added 7 commits July 5, 2026 09:10
…Message

The Claude Agent SDK ends every turn with a ResultMessage carrying
duration_ms, token usage, and total_cost_usd. The receive loop dropped
it in its silent else branch, so users had no signal of what a turn
cost short of typing /cost.

Format it as a small italic footer (e.g. '12.3s · 45.2K in (38.1K
cached) / 1.2K out · $0.0842') streamed after the turn's content.
Error results produce no footer (the error path already reports), zero
cost is omitted (subscription sessions report 0.0 and showing $0.0000
would read as a billing claim), and malformed usage values degrade to
whatever segments remain rather than failing the turn.
Review follow-up (PR #47): the footer is streamed as another
MarkdownData chunk right after the assistant's last text block; without
a leading paragraph break it concatenated onto prose that doesn't end
in a blank line ('Done.*12.3s ...*'), breaking the emphasis markup.
Prefix the footer with a blank line and pin it with a test.
The footer streamed after every turn is persistent visual noise, and its
cost figure comes from the SDK's total_cost_usd, which the CLI prices
from public list rates — wrong for subscription logins (marginal cost is
$0), enterprise-negotiated rates, and non-first-party endpoints.

Add a 'show_turn_usage' claude_setting (default off) surfaced as a
Settings-panel toggle, and only emit the footer when it is on. Gate the
$ segment on a direct API key being configured so the duration/token
counts (always accurate) still show while a dollar amount we can't stand
behind is omitted.
The cost gate only checked for a direct API key, but a user with a
custom base_url (proxy, gateway, Bedrock/Vertex front, OpenAI-compatible
endpoint) plus a key would still see Claude public-list-price costs that
don't match their actual billing — the very case the footer's cost
suppression is meant to avoid.

Extract _should_show_turn_cost, requiring both a direct API key and
Anthropic's own endpoint (unset or api.anthropic.com base_url); a custom
base_url now suppresses the dollar figure while duration/tokens still
render. Add regression tests covering the base_url cases.
…st settings

The cost gate read api_key/base_url only from claude_settings, but NBI
overlays ANTHROPIC_API_KEY/ANTHROPIC_BASE_URL onto the CLI subprocess env
merged over the server's os.environ — so a blank setting falls through to
an ambient value the CLI actually uses. An env-provided custom base_url
would then show Anthropic list-price cost for a proxy/gateway endpoint.

Add _resolve_effective_credential mirroring that precedence (setting wins,
else env) and route _should_show_turn_cost through it, so the gate honors
env-provided keys and endpoints. Add env-resolution regression tests and
an autouse fixture keeping the suite hermetic.
Drop the billing caveats from the settings toggle title — those belong
in the docs, not the settings UI. Keep a one-line description.
Explain the Show usage after each turn toggle and when the cost figure is
shown vs omitted (direct API key on the default endpoint only), matching
the setting's trimmed UI description.
@pjdoland pjdoland added the enhancement New feature or request label Jul 9, 2026

@pjdoland pjdoland left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, approving. This is a well-built feature, and I really like that the cost figure is gated to only show when it can be trusted, opt-in and default off. Two non-blocking things worth a look, plus a couple of small optional fixes.

  1. Per-turn vs cumulative cost. NBI drives a persistent ClaudeSDKClient (client.query() per turn), and the footer is labeled per-turn, but the SDK's ResultMessage bundles total_cost_usd, duration_ms, and num_turns together, which reads like a session-level summary, and the docstring does not disambiguate. If those values accumulate across turns in one session, each turn's footer would inflate (a cheap cached follow-up showing a growing dollar figure and elapsed time). Might be worth a quick check across a 2 to 3 turn session to confirm they reset per turn. Not blocking.

  2. Enterprise README claim. The new README text says cost is "omitted ... on an enterprise-negotiated contract," but _should_show_turn_cost only sees direct-key + first-party endpoint, which an enterprise direct-key account also satisfies, so it would show list-price cost that overstates their negotiated billing. Since there is no signal to detect enterprise pricing, it might be cleaner to soften the wording (call it a list-price figure that will not match negotiated rates) rather than promise a suppression the gate cannot deliver. Non-blocking.

Two small optional fixes:

  • A genuine sub-cent turn passes cost > 0 but formats to $0.0000 via the .4f, which reads as "free," the same impression the zero-cost branch avoids. A <$0.0001 rendering would sidestep it.
  • urlparse("api.anthropic.com").hostname is None, so a first-party base_url entered without a scheme is treated as custom and the cost is suppressed. It fails safe, just a minor edge.

Nice work overall.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants