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>
14 KiB
Unify Cost Calculation Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Replace dual cost calculation systems with a single shared pricing module used by both main and renderer processes.
Architecture: Create src/shared/utils/pricing.ts that statically imports resources/pricing.json and exports all pricing functions. Both jsonl.ts (main) and sessionAnalyzer.ts (renderer) consume this module instead of maintaining their own pricing logic.
Tech Stack: TypeScript, Vitest, electron-vite (resolveJsonModule)
Task 1: Create the shared pricing module with tests
Files:
- Create:
src/shared/utils/pricing.ts - Test:
test/shared/utils/pricing.test.ts
Step 1: Write the failing tests
Create test/shared/utils/pricing.test.ts:
import { describe, it, expect } from 'vitest';
import {
getPricing,
calculateTieredCost,
calculateMessageCost,
getDisplayPricing,
} from '@shared/utils/pricing';
describe('Shared Pricing Module', () => {
describe('getPricing', () => {
it('should find pricing by exact model name', () => {
// Use a model known to exist in pricing.json
const pricing = getPricing('claude-3-5-sonnet-20241022');
expect(pricing).not.toBeNull();
expect(pricing!.input_cost_per_token).toBeGreaterThan(0);
expect(pricing!.output_cost_per_token).toBeGreaterThan(0);
});
it('should find pricing case-insensitively', () => {
const pricing = getPricing('Claude-3-5-Sonnet-20241022');
expect(pricing).not.toBeNull();
});
it('should return null for unknown models', () => {
const pricing = getPricing('totally-fake-model-xyz');
expect(pricing).toBeNull();
});
});
describe('calculateTieredCost', () => {
it('should use base rate for tokens below 200k', () => {
const cost = calculateTieredCost(100_000, 0.000003);
expect(cost).toBeCloseTo(0.3, 6);
});
it('should apply tiered rate above 200k', () => {
const cost = calculateTieredCost(250_000, 0.000003, 0.000006);
// (200000 * 0.000003) + (50000 * 0.000006) = 0.6 + 0.3 = 0.9
expect(cost).toBeCloseTo(0.9, 6);
});
it('should use base rate when no tiered rate provided', () => {
const cost = calculateTieredCost(250_000, 0.000015);
expect(cost).toBeCloseTo(3.75, 6);
});
it('should return 0 for zero or negative tokens', () => {
expect(calculateTieredCost(0, 0.000003)).toBe(0);
expect(calculateTieredCost(-100, 0.000003)).toBe(0);
});
});
describe('calculateMessageCost', () => {
it('should compute cost for a known model', () => {
// claude-3-5-sonnet-20241022: input=0.000003, output=0.000015
const cost = calculateMessageCost('claude-3-5-sonnet-20241022', 1000, 500, 0, 0);
// (1000 * 0.000003) + (500 * 0.000015) = 0.003 + 0.0075 = 0.0105
expect(cost).toBeCloseTo(0.0105, 6);
});
it('should return 0 for unknown models', () => {
const cost = calculateMessageCost('unknown-model', 1000, 500, 0, 0);
expect(cost).toBe(0);
});
it('should include cache token costs', () => {
const cost = calculateMessageCost('claude-3-5-sonnet-20241022', 1000, 500, 300, 200);
expect(cost).toBeGreaterThan(0.0105); // more than just input+output
});
});
describe('getDisplayPricing', () => {
it('should return per-million rates for a known model', () => {
const dp = getDisplayPricing('claude-3-5-sonnet-20241022');
expect(dp).not.toBeNull();
expect(dp!.input).toBeCloseTo(3.0, 1); // $3/M input
expect(dp!.output).toBeCloseTo(15.0, 1); // $15/M output
});
it('should return null for unknown models', () => {
expect(getDisplayPricing('unknown-model')).toBeNull();
});
});
});
Step 2: Run tests to verify they fail
Run: pnpm vitest run test/shared/utils/pricing.test.ts
Expected: FAIL — module does not exist
Step 3: Create the shared pricing module
Create src/shared/utils/pricing.ts:
import pricingData from '../../../resources/pricing.json';
export interface LiteLLMPricing {
input_cost_per_token: number;
output_cost_per_token: number;
cache_creation_input_token_cost?: number;
cache_read_input_token_cost?: number;
input_cost_per_token_above_200k_tokens?: number;
output_cost_per_token_above_200k_tokens?: number;
cache_creation_input_token_cost_above_200k_tokens?: number;
cache_read_input_token_cost_above_200k_tokens?: number;
[key: string]: unknown;
}
export interface DisplayPricing {
input: number;
output: number;
cache_read: number;
cache_creation: number;
}
const TIER_THRESHOLD = 200_000;
const pricingMap = pricingData as Record<string, unknown>;
function tryGetPricing(key: string): LiteLLMPricing | null {
const entry = pricingMap[key];
if (
entry &&
typeof entry === 'object' &&
'input_cost_per_token' in entry &&
'output_cost_per_token' in entry
) {
return entry as LiteLLMPricing;
}
return null;
}
export function getPricing(modelName: string): LiteLLMPricing | null {
const exact = tryGetPricing(modelName);
if (exact) return exact;
const lowerName = modelName.toLowerCase();
const lower = tryGetPricing(lowerName);
if (lower) return lower;
for (const key of Object.keys(pricingMap)) {
if (key.toLowerCase() === lowerName) {
const match = tryGetPricing(key);
if (match) return match;
}
}
return null;
}
export function calculateTieredCost(
tokens: number,
baseRate: number,
tieredRate?: number
): number {
if (tokens <= 0) return 0;
if (!tieredRate || tokens <= TIER_THRESHOLD) {
return tokens * baseRate;
}
const costBelow = TIER_THRESHOLD * baseRate;
const costAbove = (tokens - TIER_THRESHOLD) * tieredRate;
return costBelow + costAbove;
}
export function calculateMessageCost(
modelName: string,
inputTokens: number,
outputTokens: number,
cacheReadTokens: number,
cacheCreationTokens: number
): number {
const pricing = getPricing(modelName);
if (!pricing) return 0;
const inputCost = calculateTieredCost(
inputTokens,
pricing.input_cost_per_token,
pricing.input_cost_per_token_above_200k_tokens
);
const outputCost = calculateTieredCost(
outputTokens,
pricing.output_cost_per_token,
pricing.output_cost_per_token_above_200k_tokens
);
const cacheCreationCost = calculateTieredCost(
cacheCreationTokens,
pricing.cache_creation_input_token_cost ?? 0,
pricing.cache_creation_input_token_cost_above_200k_tokens
);
const cacheReadCost = calculateTieredCost(
cacheReadTokens,
pricing.cache_read_input_token_cost ?? 0,
pricing.cache_read_input_token_cost_above_200k_tokens
);
return inputCost + outputCost + cacheCreationCost + cacheReadCost;
}
export function getDisplayPricing(modelName: string): DisplayPricing | null {
const pricing = getPricing(modelName);
if (!pricing) return null;
return {
input: pricing.input_cost_per_token * 1_000_000,
output: pricing.output_cost_per_token * 1_000_000,
cache_read: (pricing.cache_read_input_token_cost ?? 0) * 1_000_000,
cache_creation: (pricing.cache_creation_input_token_cost ?? 0) * 1_000_000,
};
}
Step 4: Run tests to verify they pass
Run: pnpm vitest run test/shared/utils/pricing.test.ts
Expected: PASS
Step 5: Commit
git add src/shared/utils/pricing.ts test/shared/utils/pricing.test.ts
git commit -m "feat: add shared pricing module with LiteLLM data"
Task 2: Wire jsonl.ts to use the shared pricing module
Files:
- Modify:
src/main/utils/jsonl.ts:219-400(remove pricing functions, update calculateMetrics) - Test:
test/main/utils/costCalculation.test.ts(update to remove fs mocking)
Step 1: Update the cost calculation test to use static imports
The existing tests mock fs.readFileSync to provide pricing data. Since the shared module uses a static JSON import, the tests should instead test against real pricing data or mock the shared module.
Update test/main/utils/costCalculation.test.ts:
- Remove
import * as fs from 'fs'andvi.mock('fs') - Remove
mockPricingDataand thebeforeEachthat mocksfs.readFileSync - Update model names in tests to match models that exist in
resources/pricing.json(the existingclaude-3-5-sonnet-20241022andclaude-3-opus-20240229should already be there) - Update expected cost values to match the actual rates from
pricing.json(verify they match the existing mock data — they should be identical since the mock was based on real rates) - Remove the "pricing data load failure" test (line 409-449) — there's no runtime file loading to fail anymore
- Keep all other test cases and assertions as-is
Step 2: Run updated tests to verify they fail
Run: pnpm vitest run test/main/utils/costCalculation.test.ts
Expected: FAIL — jsonl.ts still has old imports
Step 3: Update jsonl.ts
In src/main/utils/jsonl.ts:
- Remove lines 219-320: the
fs/pathimports,ModelPricinginterface,TIER_THRESHOLD,pricingCache,loadPricingData(),calculateTieredCost(),getPricing() - Add import at top of file:
import { calculateMessageCost } from '@shared/utils/pricing'; - In
calculateMetrics()(lines 354-400), replace the inline cost calculation block (lines 374-398) with:
if (msg.model) {
costUsd += calculateMessageCost(
msg.model,
msgInputTokens,
msgOutputTokens,
msgCacheReadTokens,
msgCacheCreationTokens
);
}
- Remove the unused
modelNamevariable (line 338) and the block that sets it (lines 370-372)
Step 4: Run tests to verify they pass
Run: pnpm vitest run test/main/utils/costCalculation.test.ts
Expected: PASS
Step 5: Run full test suite to check for regressions
Run: pnpm test
Expected: All tests pass
Step 6: Commit
git add src/main/utils/jsonl.ts test/main/utils/costCalculation.test.ts
git commit -m "refactor: wire jsonl.ts to shared pricing module"
Task 3: Wire sessionAnalyzer.ts and CostSection.tsx to use the shared pricing module
Files:
- Modify:
src/renderer/utils/sessionAnalyzer.ts:32,60-130(remove local pricing) - Modify:
src/renderer/types/sessionReport.ts:23-28(update ModelPricing type) - Modify:
src/renderer/components/report/sections/CostSection.tsx:3,10,39-46,204(update imports and usage)
Step 1: Run existing session analyzer tests as baseline
Run: pnpm vitest run test/renderer/utils/sessionAnalyzer.test.ts
Expected: PASS (baseline)
Step 2: Update sessionAnalyzer.ts
In src/renderer/utils/sessionAnalyzer.ts:
- Remove the
ModelPricingimport from@renderer/types/sessionReport(line 32) - Remove lines 60-130:
MODEL_PRICINGtable,DEFAULT_PRICING,getPricing(),costUsd() - Add import:
import { calculateMessageCost, getDisplayPricing } from '@shared/utils/pricing'; - Export
getDisplayPricingasgetPricingfor backward compat with CostSection:export { getDisplayPricing as getPricing } from '@shared/utils/pricing'; - Replace
costUsd(model, inpTok, outTok, cr, cc)at line 476 withcalculateMessageCost(model, inpTok, outTok, cr, cc) - Replace
costUsd(subagentModel, ...)at line 900 withcalculateMessageCost(subagentModel, proc.metrics.inputTokens, proc.metrics.outputTokens, proc.metrics.cacheReadTokens, proc.metrics.cacheCreationTokens)
Step 3: Update sessionReport.ts ModelPricing type
In src/renderer/types/sessionReport.ts:
- Replace the existing
ModelPricinginterface (lines 23-28) with a re-export from the shared module:
export type { DisplayPricing as ModelPricing } from '@shared/utils/pricing';
This keeps backward compatibility — CostSection.tsx imports ModelPricing from here and expects { input, output, cache_read, cache_creation } which matches DisplayPricing.
Step 4: Update CostSection.tsx
In src/renderer/components/report/sections/CostSection.tsx:
- Line 3: Change
import { getPricing } from '@renderer/utils/sessionAnalyzer'toimport { getPricing } from '@renderer/utils/sessionAnalyzer'— no change needed if we re-export from sessionAnalyzer. Verify the import still resolves. - The
ModelPricingimport from@renderer/types/sessionReport(line 10) continues to work via the re-export. - The
CostBreakdownCard(lines 34-46) usespricing.input,pricing.output, etc. as per-million rates — this matchesDisplayPricingfromgetDisplayPricing().
Step 5: Run session analyzer tests
Run: pnpm vitest run test/renderer/utils/sessionAnalyzer.test.ts
Expected: PASS
Step 6: Run full test suite
Run: pnpm test
Expected: All tests pass
Step 7: Commit
git add src/renderer/utils/sessionAnalyzer.ts src/renderer/types/sessionReport.ts src/renderer/components/report/sections/CostSection.tsx
git commit -m "refactor: wire session analyzer and CostSection to shared pricing"
Task 4: Typecheck, lint, and verify the app runs
Files:
- No new files — verification only
Step 1: Run typecheck
Run: pnpm typecheck
Expected: No errors
Step 2: Run linter
Run: pnpm lint:fix
Expected: Clean or auto-fixed
Step 3: Run full test suite
Run: pnpm test
Expected: All tests pass
Step 4: Run the app and verify cost display
Run: pnpm dev
- Open a session with known token usage
- Verify
TokenUsageDisplayshows cost in the chat view - Open the Session Report tab and verify cost-by-model breakdown renders
- Verify CostBreakdownCard expands with per-token-type rates
- Confirm chat view cost and report cost show the same total
Step 5: Commit any fixes from verification
If any fixes were needed, commit them:
git add -A
git commit -m "fix: address typecheck/lint issues from cost unification"
Task 5: Clean up dead code from package.json extraResources
Files:
- Modify:
package.json(optional — evaluate ifextraResourcesfor pricing.json is still needed)
Step 1: Check if pricing.json is still loaded at runtime anywhere
Search for any remaining fs.readFileSync or runtime references to pricing.json:
Run: grep -r "pricing.json" src/
Expected: Only the static import in src/shared/utils/pricing.ts
Step 2: Evaluate extraResources
If no runtime file loading remains, the extraResources entry for pricing.json in package.json is dead config. However, removing it is low-risk and low-priority — it just means the file gets copied to the app bundle uselessly. Leave it for now unless it causes issues. Document the decision.
Step 3: Final commit
git add docs/plans/2026-02-23-unify-cost-calculation.md
git commit -m "docs: finalize implementation plan for cost unification"