feat(claude): surface per-turn usage as an opt-in footer#391
Conversation
…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
left a comment
There was a problem hiding this comment.
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.
-
Per-turn vs cumulative cost. NBI drives a persistent
ClaudeSDKClient(client.query()per turn), and the footer is labeled per-turn, but the SDK'sResultMessagebundlestotal_cost_usd,duration_ms, andnum_turnstogether, 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. -
Enterprise README claim. The new README text says cost is "omitted ... on an enterprise-negotiated contract," but
_should_show_turn_costonly 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 > 0but formats to$0.0000via the.4f, which reads as "free," the same impression the zero-cost branch avoids. A<$0.0001rendering would sidestep it. urlparse("api.anthropic.com").hostnameisNone, so a first-partybase_urlentered without a scheme is treated as custom and the cost is suppressed. It fails safe, just a minor edge.
Nice work overall.
Problem
The Claude Agent SDK ends every turn with a
ResultMessagecarryingduration_ms, token usage, andtotal_cost_usd. The receive loop dropped it in its silentelsebranch, so users had no signal of what a turn cost short of typing/cost.Change
format_result_usagehelper 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.show_turn_usageClaude 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.total_cost_usdis 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_costgates 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.ResultMessageinstances), the cost gate (API key /base_url/ env-resolution matrix), and the paragraph-break guard.Testing
Full Python suite passes. Manually verified in JupyterLab:
time · tokens · $cost.time · tokens, no$.base_url+ key:time · tokens, no$(proxy/gateway billing can't be trusted).