Skip to content

feat(claude): surface per-turn duration, tokens, and cost from ResultMessage#47

Closed
herikwebb wants to merge 7 commits into
mainfrom
feat/claude-turn-usage
Closed

feat(claude): surface per-turn duration, tokens, and cost from ResultMessage#47
herikwebb wants to merge 7 commits into
mainfrom
feat/claude-turn-usage

Conversation

@herikwebb

Copy link
Copy Markdown
Owner

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*.
  • Error results produce no footer (the error path already reports).
  • Zero cost is omitted — subscription (non-API-key) sessions report 0.0 and showing $0.0000 would read as a billing claim.
  • Malformed usage values degrade to whatever segments remain rather than failing the turn.
  • 8 unit tests against real ResultMessage instances.

Testing

Full Python suite passes (1300 tests).

…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.
@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown

Automated PR Review

Model: gpt-5.5
Verdict: CHANGES_REQUESTED

Changed files:

notebook_intelligence/claude.py
tests/test_claude_turn_usage.py

Review result:

Findings

Medium — notebook_intelligence/claude.py

Usage footer is streamed without a Markdown separator from the final assistant text.

The receive loop streams TextBlock contents verbatim, then streams the ResultMessage footer as another MarkdownData chunk:

response.stream(MarkdownData(block.text))
...
response.stream(MarkdownData(usage_line))

But format_result_usage() returns strings like:

*12.3s · 45.2K in / 1.2K out · $0.0842*

with no leading newline. If the assistant’s last text block does not end with a blank line, the footer will be concatenated directly onto the response body, e.g.:

Done.*12.3s · 45.2K in / 1.2K out · $0.0842*

This can render poorly and may alter Markdown emphasis. Consider adding a paragraph separator at the stream site or in format_result_usage(), e.g. "\n\n" + usage_line, and add a test covering the streamed/chunked behavior.

@herikwebb herikwebb added the enhancement New feature or request label Jul 5, 2026
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.
@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown

Automated PR Review

Model: gpt-5.5
Verdict: APPROVE

Changed files:

notebook_intelligence/claude.py
tests/test_claude_turn_usage.py

Review result:

No automated findings were found in the changed code.

Residual risks: this depends on the exact claude_agent_sdk.ResultMessage shape and stream ordering at runtime; the added unit tests cover the formatter well, but not the async receive-loop integration with the real SDK/client.

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.
@herikwebb

Copy link
Copy Markdown
Owner Author

Reworked to opt-in (d45cad0)

Manual testing surfaced two issues with an always-on footer, addressed here:

  1. Cost accuracyResultMessage.total_cost_usd is priced from the CLI's public list rates, so the $ is wrong for subscription logins (marginal cost $0), enterprise-negotiated rates, and non-first-party endpoints.
  2. Noise — a footer on every turn is persistent clutter.

Changes:

  • New show_turn_usage claude-setting, default off, surfaced as a Settings-panel toggle ("Usage footer" section).
  • Footer only emitted when the toggle is on.
  • format_result_usage gains show_cost; the caller passes show_cost=True only when a direct API key is configured, so duration/tokens (always accurate) still show while the dollar figure is suppressed where it would be misleading.
  • Added tests for the show_cost=False path; full usage suite green, frontend builds clean.

Manually verified: default → no footer; toggle on + API key → time · tokens · $cost; toggle on, key cleared → time · tokens with no $.

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

Automated PR Review

Model: gpt-5.5
Verdict: CHANGES_REQUESTED

Changed files:

notebook_intelligence/claude.py
src/components/settings-panel.tsx
tests/test_claude_turn_usage.py

Review result:

Automated PR Review Findings

Medium — notebook_intelligence/claude.py — Cost footer is shown for custom/non-Anthropic endpoints when an API key is present

The new ResultMessage handling decides whether to display the dollar cost using only:

show_cost = _normalize_anthropic_credential(
    claude_settings.get('api_key')
) is not None

However, the PR’s own docstring/comment says the SDK cost is only trustworthy for a “bare API key” and should not be shown for enterprise/cloud/custom endpoint pricing. This repository exposes base_url as a Claude setting, but the new logic ignores it. If a user configures a custom base_url plus an API key, the UI can display Claude public-list-price costs that do not match the user’s actual billing.

Recommended fix: gate show_cost on both a valid direct API key and no custom/non-default base_url, e.g. only show cost when base_url is unset or explicitly the standard Anthropic API URL. Add a regression test for custom base_url suppressing cost.

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.
@herikwebb

Copy link
Copy Markdown
Owner Author

Addressed the base_url cost finding (f26b771)

Agreed — the gate ignored base_url while the docstring claimed cost is only trustworthy for a first-party endpoint. Fixed:

  • Extracted _should_show_turn_cost, now requiring both a direct API key and Anthropic's own endpoint (base_url unset or api.anthropic.com). A custom base_url (proxy / gateway / Bedrock-Vertex front / OpenAI-compatible) suppresses the $ while duration/tokens still render.
  • Added _is_first_party_anthropic_endpoint (host check, tolerant of "" and the explicit canonical URL).
  • 5 regression tests: default endpoint, empty base_url, explicit Anthropic URL, custom proxy URL (suppressed), no key. 16/16 usage tests pass.

The earlier paragraph-break note is already covered on this branch (footer is prefixed with \\n\\n, pinned by test_footer_starts_with_a_paragraph_break).

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

Automated PR Review

Model: gpt-5.5
Verdict: CHANGES_REQUESTED

Changed files:

notebook_intelligence/claude.py
src/components/settings-panel.tsx
tests/test_claude_turn_usage.py

Review result:

Findings

Medium — notebook_intelligence/claude.py

_should_show_turn_cost() bases cost visibility only on claude_settings["api_key"] and claude_settings["base_url"], but the Anthropic/Claude SDK path can also use environment-resolved credentials and endpoints when settings are blank/locked.

This can make the new billing footer inaccurate in common configurations:

  • ANTHROPIC_API_KEY set via environment: direct API-key usage may incorrectly suppress cost because claude_settings["api_key"] is empty.
  • Custom ANTHROPIC_BASE_URL set via environment while an API key is configured elsewhere: the code may treat the endpoint as first-party and show Anthropic list-price cost for a proxy/gateway/cloud endpoint.

Because this PR explicitly gates cost display on billing accuracy, the decision should use the resolved credentials/endpoint actually used by Claude Code, including locked/env-provided values, or conservatively suppress cost whenever that cannot be determined.

Low — src/components/settings-panel.tsx

The new showTurnUsage React state is initialized from nbiConfig.claudeSettings.show_turn_usage, but the existing configChanged handler only updates claudeSettingsRef, models, and render count. If config is refreshed externally or changed by policy/server state after the panel mounts, showTurnUsage can remain stale and later be posted back to the server during an unrelated settings change, overwriting the current value.

Consider updating showTurnUsage in the configChanged handler, or otherwise deriving/synchronizing it from the refreshed config consistently with server state.

…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.
@herikwebb

Copy link
Copy Markdown
Owner Author

Round 2 findings (f1e28d9)

Medium — env-resolved credentials/endpoint — fixed

Good catch. NBI overlays ANTHROPIC_API_KEY/ANTHROPIC_BASE_URL onto the CLI subprocess env, and since that env is merged over the server's os.environ, a blank setting falls through to an ambient value the CLI actually uses — so an env-provided custom base_url could show list-price cost for a proxy.

  • Added _resolve_effective_credential, mirroring the CLI's precedence (non-empty setting wins, else the ambient env var), and routed _should_show_turn_cost through it for both key and base_url.
  • Regression tests: env-provided key on default endpoint (shows), env custom base_url with a settings key (suppressed), settings base_url overriding env, and no-key-anywhere. Plus an autouse fixture keeping the suite hermetic. 20/20 usage tests pass.

Low — stale showTurnUsage React state — declining in this PR (pre-existing, panel-wide)

This is real but not introduced or worsened here: the configChanged handler re-syncs none of the panel's local field states — not showTurnUsage, and not continueConversation, apiKey, or tools. showTurnUsage follows the exact established pattern of every sibling field.

Patching only showTurnUsage would make it the lone re-syncing field — inconsistent, and it wouldn't fix the underlying class of bug (all the other fields would still go stale and clobber on an unrelated save). The correct fix is a holistic re-sync of all panel fields in the configChanged handler, which is broader than a usage-footer change and better as its own focused PR. Keeping this one scoped to the footer.

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

Automated PR Review

Model: gpt-5.5
Verdict: APPROVE

Changed files:

notebook_intelligence/claude.py
src/components/settings-panel.tsx
tests/test_claude_turn_usage.py

Review result:

No automated findings were found.

Residual risks: the new usage footer is covered at the formatter/cost-gating helper level, but the actual Claude SDK receive-loop/UI rendering path is not exercised by the added tests, so regressions there would require integration or manual validation.

Drop the billing caveats from the settings toggle title — those belong
in the docs, not the settings UI. Keep a one-line description.
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

Automated PR Review

Model: gpt-5.5
Verdict: APPROVE

Changed files:

notebook_intelligence/claude.py
src/components/settings-panel.tsx
tests/test_claude_turn_usage.py

Review result:

No automated findings were found in the changed code.

Residual risks: the usage footer behavior depends on the exact claude_agent_sdk.ResultMessage shape and on how persisted claude_settings handles newly introduced keys, but the added unit coverage exercises the main formatting and cost-gating paths.

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.
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

Automated PR Review

Model: gpt-5.5
Verdict: APPROVE

Changed files:

README.md
notebook_intelligence/claude.py
src/components/settings-panel.tsx
tests/test_claude_turn_usage.py

Review result:

No automated findings were found. Residual risks include compatibility with the exact claude_agent_sdk version in downstream environments and end-to-end UI behavior for the new persisted setting, but the changed code is covered by focused unit tests and no blocking correctness issues were identified from the diff.

@herikwebb

Copy link
Copy Markdown
Owner Author

Raised upstream as plmbr#391; closing this fork PR. Review history retained here.

@herikwebb herikwebb closed this Jul 12, 2026
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.

1 participant