Unify cost calculation with shared pricing module#73
Unify cost calculation with shared pricing module#73matt1398 merged 11 commits intomatt1398:mainfrom
Conversation
Shared pricing module replacing dual cost calculation systems (jsonl.ts hardcoded + sessionAnalyzer.ts LiteLLM) with a single src/shared/utils/pricing.ts used by both main and renderer processes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
5 tasks: shared pricing module, wire jsonl.ts, wire sessionAnalyzer, verify, and clean up. TDD approach with tests first. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Create a unified pricing module in src/shared/utils/ that wraps resources/pricing.json with typed lookup, tiered cost calculation, and display pricing helpers. This is the foundation for consolidating the dual cost calculation systems (main jsonl.ts + renderer sessionAnalyzer). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace the hardcoded MODEL_PRICING table and costUsd() function in sessionAnalyzer.ts with calculateMessageCost() and getDisplayPricing() from the shared pricing module. Re-export getDisplayPricing as getPricing for backward compat with CostSection. Replace the ModelPricing interface with a type re-export of DisplayPricing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
resources/ lives outside src/ with no path alias available. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The Visible Context panel's Session Cost only reflects the parent session. Add "(parent only · view full cost)" label that links to the Session Report where parent + subagent costs are shown. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add console.warn when calculateMessageCost encounters an unknown model, so pricing.json gaps are visible rather than silently returning $0. Update tests to expect the warning. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary of ChangesHello @holstein13, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request significantly refactors the application's cost calculation logic to ensure consistency and accuracy across all user interfaces. By centralizing pricing data and calculation functions into a single shared module, it eliminates discrepancies that previously existed between different parts of the application. The update also enhances transparency regarding pricing for unknown models and provides a clearer user experience for understanding session costs, especially in scenarios involving subagents. Highlights
Changelog
Activity
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
📝 WalkthroughWalkthroughAdds a shared pricing module (src/shared/utils/pricing.ts) with tiered-cost logic and display helpers, updates main and renderer code to use it for message cost calculation, introduces tests for the shared pricing, updates existing cost tests, and threads a session-report callback and subagent cost through renderer UI components. Changes
Suggested labels
Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
test/main/utils/costCalculation.test.ts (1)
87-107:⚠️ Potential issue | 🟡 MinorAvoid empty mock implementation to satisfy linting.
Line 87 uses an empty arrow function, which triggers
@typescript-eslint/no-empty-function. Returnundefinedexplicitly (or usevi.fn()), so lint passes.✅ Suggested fix
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@test/main/utils/costCalculation.test.ts` around lines 87 - 107, The console.warn mock uses an empty arrow function which triggers the no-empty-function lint rule; update the mock in the test to return undefined (or use vi.fn()) instead of an empty body, e.g. change the spy creation for console.warn (warnSpy) to use mockImplementation(() => undefined) or vi.fn(), keep the rest of the test using calculateMetrics and restore the spy with warnSpy.mockRestore().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@docs/plans/2026-02-23-unify-cost-calculation-design.md`:
- Around line 61-65: The "No UI changes — same components, same layout" bullet
in the "What Changes for Users" section is now incorrect because the PR adds a
parent-only disclaimer and a "view full cost" action; update that bullet to
reflect the small UI change (e.g., "Minor UI additions: parent-only disclaimer
and a 'view full cost' action for expanded cost details") so users know a new
cost link/disclaimer appears in the UI and costs are still consistent between
views.
In `@docs/plans/2026-02-23-unify-cost-calculation.md`:
- Around line 13-15: The markdown has a heading level jump at the line
containing "### Task 1: Create the shared pricing module with tests" (a
third-level heading immediately after a first-level heading), so update that
heading to "## Task 1: Create the shared pricing module with tests" or add an
intermediate second-level section above it to maintain proper heading increments
and satisfy MD001.
In `@src/main/utils/jsonl.ts`:
- Around line 10-13: The import order is wrong: move the external package import
"readline" to appear before the path-alias imports; reorder so readline is
first, followed by the path-alias imports that include isCommandOutputContent
and sanitizeDisplayContent from '@shared/utils/contentSanitizer', createLogger
from '@shared/utils/logger', and calculateMessageCost from
'@shared/utils/pricing' to satisfy the project import-order guideline.
In `@src/shared/utils/pricing.ts`:
- Around line 23-38: Extract the inline runtime checks into a proper TypeScript
type guard named isLiteLLMPricing(entry: unknown): entry is LiteLLMPricing and
use it inside tryGetPricing (replace the object/field checks with a single call
to isLiteLLMPricing), ensure the constant uses UPPER_SNAKE_CASE (TIER_THRESHOLD
is fine or rename to match repo convention), and keep pricingMap and
LiteLLMPricing references as-is so callers of tryGetPricing/pricingMap continue
to work.
In `@test/shared/utils/pricing.test.ts`:
- Around line 51-71: The ESLint no-empty-function error is caused by using a
block-body empty arrow in vi.spyOn(console, 'warn').mockImplementation(() =>
{}); change the mockImplementation to use an expression-bodied arrow so the body
is not empty, e.g. mockImplementation(() => undefined) or mockImplementation(()
=> void 0), keeping the same warnSpy variable and restoring it with
warnSpy.mockRestore().
---
Outside diff comments:
In `@test/main/utils/costCalculation.test.ts`:
- Around line 87-107: The console.warn mock uses an empty arrow function which
triggers the no-empty-function lint rule; update the mock in the test to return
undefined (or use vi.fn()) instead of an empty body, e.g. change the spy
creation for console.warn (warnSpy) to use mockImplementation(() => undefined)
or vi.fn(), keep the rest of the test using calculateMetrics and restore the spy
with warnSpy.mockRestore().
ℹ️ Review info
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (12)
docs/plans/2026-02-23-unify-cost-calculation-design.mddocs/plans/2026-02-23-unify-cost-calculation.mdsrc/main/utils/jsonl.tssrc/renderer/components/chat/ChatHistory.tsxsrc/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsxsrc/renderer/components/chat/SessionContextPanel/index.tsxsrc/renderer/components/chat/SessionContextPanel/types.tssrc/renderer/types/sessionReport.tssrc/renderer/utils/sessionAnalyzer.tssrc/shared/utils/pricing.tstest/main/utils/costCalculation.test.tstest/shared/utils/pricing.test.ts
There was a problem hiding this comment.
Code Review
This pull request does an excellent job of unifying the cost calculation logic into a single shared module, which significantly improves consistency and maintainability across the main and renderer processes. The changes are well-structured, clearly documented in the PR description, and supported by new and updated tests. I've identified a performance optimization opportunity in the new pricing module and a minor bug in a UI component that could also be simplified. Overall, this is a very solid and beneficial refactoring.
src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx
Outdated
Show resolved
Hide resolved
- Design doc: update "No UI changes" to reflect cost disclaimer
- Plan doc: fix MD001 heading level jump (### after #)
- pricing.ts: rename to PRICING_MAP, add isLiteLLMPricing type guard,
pre-compute LOWERCASE_KEY_MAP for O(1) case-insensitive lookups
- SessionContextHeader: simplify cost disclaimer JSX into single span
- Tests: fix no-empty-function lint (() => {} → () => undefined)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 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/shared/utils/pricing.ts`:
- Around line 62-70: The calculateTieredCost function incorrectly treats a
tieredRate of 0 as "missing" due to the falsy check; update the conditional that
currently reads "!tieredRate || tokens <= TIER_THRESHOLD" to explicitly check
for null/undefined (e.g., "tieredRate == null || tokens <= TIER_THRESHOLD") so a
valid 0 tieredRate is used when provided while preserving behavior when
tieredRate is absent; reference calculateTieredCost and TIER_THRESHOLD to locate
the change.
- Around line 72-109: In calculateMessageCost, the no-pricing warning currently
only checks inputTokens/outputTokens; update the condition inside the if
(!pricing) block to also include cacheReadTokens and cacheCreationTokens so any
cache-only usage logs a warning (e.g., if inputTokens > 0 || outputTokens > 0 ||
cacheReadTokens > 0 || cacheCreationTokens > 0) before returning 0; modify the
warning message to mention cache tokens and reference calculateMessageCost,
cacheReadTokens, and cacheCreationTokens so the log covers all token types for
unknown models.
ℹ️ Review info
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (6)
docs/plans/2026-02-23-unify-cost-calculation-design.mddocs/plans/2026-02-23-unify-cost-calculation.mdsrc/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsxsrc/shared/utils/pricing.tstest/main/utils/costCalculation.test.tstest/shared/utils/pricing.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
- test/shared/utils/pricing.test.ts
- docs/plans/2026-02-23-unify-cost-calculation-design.md
… models - calculateTieredCost: use `== null` instead of `!` so a 0 tiered rate is not treated as missing - calculateMessageCost: include cacheReadTokens and cacheCreationTokens in the unknown-model warning condition Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Well I though subagents session are stored in a separate jsonl files and are shown separately in the sidebar / tab. My initial implementation was based on this assumption, but I could be wrong though. Edit: You are right, in that case the cost usage shown will be incorrect (we show sub agent trace in same session - which makes sense). I think a new PR with this fix would be great. |
Awesome. The data is already available, and no new IPC is needed. I'll go ahead and make the change to this PR so that our numbers match exactly. On Edit: Done. @matt1398, the new commits address this issue, and everything should align now. |
…panel Compute subagentCostUsd from sessionDetail.processes[].metrics and display total cost (parent + subagents) with breakdown in the header. When subagents exist, shows e.g. "$24.70 ($16.85 parent + $7.84 subagents)" with a link to the Session Report for full details. Replaces the "(parent only)" disclaimer since cost is now accurate. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Great catch — you were right that subagent sessions are stored in separate JSONL files ( But as you noted, since subagent traces are displayed in the same session view (as Good news: the fix was straightforward since the data was already available. Pushed in
For sessions without subagents, it just shows the cost with no annotation — same as before. |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx (1)
133-160:⚠️ Potential issue | 🟠 MajorShow subagent-only costs even when parent cost is $0.
The current gate (sessionMetrics.costUsd > 0) hides the cost line when the parent cost is zero but subagentCostUsd is positive. This prevents the combined total from showing in those sessions.✅ Proposed fix
}: Readonly<SessionContextHeaderProps>): React.ReactElement => { + const parentCostUsd = sessionMetrics?.costUsd ?? 0; + const subagentCost = subagentCostUsd ?? 0; + const totalCostUsd = parentCostUsd + subagentCost; + return ( <div className="shrink-0 px-4 py-3" style={{ borderBottom: `1px solid ${COLOR_BORDER}` }}> @@ - {sessionMetrics.costUsd !== undefined && sessionMetrics.costUsd > 0 && ( + {totalCostUsd > 0 && ( <div className="col-span-2"> <span style={{ color: COLOR_TEXT_MUTED }}>Session Cost: </span> <span className="font-medium tabular-nums" style={{ color: COLOR_TEXT_SECONDARY }}> - {formatCostUsd(sessionMetrics.costUsd + (subagentCostUsd ?? 0))} + {formatCostUsd(totalCostUsd)} </span> - {subagentCostUsd !== undefined && subagentCostUsd > 0 && ( + {subagentCost > 0 && ( <span style={{ color: COLOR_TEXT_MUTED }}> {' ('} - {formatCostUsd(sessionMetrics.costUsd)} + {formatCostUsd(parentCostUsd)} {' parent + '} - {formatCostUsd(subagentCostUsd)} + {formatCostUsd(subagentCost)} {' subagents'}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx` around lines 133 - 160, The cost row is currently gated by sessionMetrics.costUsd > 0 which hides the line when the parent cost is zero but subagentCostUsd is positive; update the outer conditional to render when either sessionMetrics.costUsd or subagentCostUsd is present and > 0 (e.g., check (sessionMetrics.costUsd ?? 0) > 0 || (subagentCostUsd ?? 0) > 0), keep the inner breakdown logic that prints parent + subagent using formatCostUsd(sessionMetrics.costUsd) and formatCostUsd(subagentCostUsd), and ensure the details button (onViewReport) behavior remains unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Outside diff comments:
In
`@src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx`:
- Around line 133-160: The cost row is currently gated by sessionMetrics.costUsd
> 0 which hides the line when the parent cost is zero but subagentCostUsd is
positive; update the outer conditional to render when either
sessionMetrics.costUsd or subagentCostUsd is present and > 0 (e.g., check
(sessionMetrics.costUsd ?? 0) > 0 || (subagentCostUsd ?? 0) > 0), keep the inner
breakdown logic that prints parent + subagent using
formatCostUsd(sessionMetrics.costUsd) and formatCostUsd(subagentCostUsd), and
ensure the details button (onViewReport) behavior remains unchanged.
ℹ️ Review info
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (4)
src/renderer/components/chat/ChatHistory.tsxsrc/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsxsrc/renderer/components/chat/SessionContextPanel/index.tsxsrc/renderer/components/chat/SessionContextPanel/types.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- src/renderer/components/chat/ChatHistory.tsx
|
Great finding @KaustubhPatange — I dug into this and here's what I found: There's no server-reported cost in the JSONL filesThe JSONL The ~2x discrepancy is most likely cache creation double-countingWhen a parent session creates a prompt cache and a subagent reuses it, both JSONL files report Cache creation is expensive — typically 25% more than regular input tokens (e.g., $3.75/MTok vs $3.00/MTok for Sonnet). Double-counting it across parent + 2 subagents could easily account for the $2.40 difference ($6 vs $3.6). Other findings from the raw JSONL dataThe
Our What Claude Code's
|
|
@holstein13 I think once your PR is merged, it would be better to open a issue and track this. Relevant issue with ccusage for tracking sub agents (sidechains): ryoppippi/ccusage#313 (comment) Edit: We need to account for duplicate token tracking. There is a similar PR opened in ccusage ryoppippi/ccusage#835 (comment) but looks like this is not merged. We can use it to fix the double accounting problem. |
|
Merged. Thanks @holstein13 for the refactor and @KaustubhPatange for catching the subagent cost bug. Let's track the cache issue in a separate ticket. |



Summary
The app had two independent cost calculation systems that produced different numbers for the same session:
jsonl.ts) — loadedresources/pricing.jsonat runtime viafs.readFileSync, supported 206 models with tiered pricing above 200k tokenssessionAnalyzer.ts) — hardcoded a 6-model lookup table with flat rates, used a Sonnet-default fallback for unknown modelsThis meant the "Session Cost" in the Visible Context panel and the cost in the Session Report could disagree. For example, a session using
claude-4-sonnet-20250514(which has tiered rates above 200k tokens) would get correct tiered pricing in the main process but flat-rate pricing in the renderer.What we changed
We replaced both systems with a single shared pricing module at
src/shared/utils/pricing.tsthat:resources/pricing.json(works in both Electron main and renderer viaresolveJsonModule)calculateMessageCost()used by bothjsonl.tsandsessionAnalyzer.ts$0with aconsole.warnfor unknown models (instead of silently guessing Sonnet rates)The parent session costs now match exactly — both the Visible Context panel and the Session Report call the same
calculateMessageCost()function with the same pricing data.Why this approach
pricing.jsonis pure data with no Node.js dependency, a static import viaresolveJsonModuleis simpler and faster than adding an IPC channel. Both processes get the data at bundle time.$0with a warning is more honest and makes pricing gaps visible.sessionAnalyzer.tsre-exportsgetDisplayPricing as getPricingandsessionReport.tsaliasesDisplayPricing as ModelPricing, soCostSection.tsxrequired zero changes.UX note: parent-only cost disclaimer
The "Session Cost" shown in the Visible Context panel header only reflects the parent session cost — it does not include subagent costs. This is because
calculateMetrics()injsonl.tsonly processes the main session's JSONL file; subagent costs are computed separately fromdetail.processesin the Session Report.This could be misleading for sessions that spawn subagents, where the total cost may be significantly higher. We added a "(parent only · view full cost)" disclaimer to the cost display, where "view full cost" is a clickable link that opens the Session Report showing the complete cost breakdown including all subagents.
@KaustubhPatange — Was showing only the parent cost here intentional, or would you be interested in including subagent costs in the Visible Context panel total? We'd be happy to help implement that if it's something you'd want. The subagent cost data is already available via the session report analyzer; it would just need to be surfaced in the panel header.
Changes
src/shared/utils/pricing.ts— shared pricing module (5 exports)test/shared/utils/pricing.test.ts— 12 tests for the shared modulesrc/main/utils/jsonl.ts— removed ~145 lines of local pricing code, now importscalculateMessageCostsrc/renderer/utils/sessionAnalyzer.ts— removed ~83 lines (hardcoded table + fallback), now importscalculateMessageCostsrc/renderer/types/sessionReport.ts— type alias for backward compatSessionContextHeader.tsx— added "(parent only · view full cost)" with linkSessionContextPanel/index.tsx+types.ts— threadedonViewReportpropChatHistory.tsx— passedonViewReportcallbacktest/main/utils/costCalculation.test.ts— updated for real pricing dataNet: +796 / -316 lines (includes design doc and implementation plan)
Test plan
pnpm typecheck— cleanpnpm test— 827/827 passing🤖 Generated with Claude Code
Summary by CodeRabbit