docs: add implementation plan for unified cost calculation
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>
This commit is contained in:
parent
ec42e92bb3
commit
da81aaa26a
1 changed files with 433 additions and 0 deletions
433
docs/plans/2026-02-23-unify-cost-calculation.md
Normal file
433
docs/plans/2026-02-23-unify-cost-calculation.md
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
# 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`:
|
||||
|
||||
```typescript
|
||||
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`:
|
||||
|
||||
```typescript
|
||||
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**
|
||||
|
||||
```bash
|
||||
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'` and `vi.mock('fs')`
|
||||
- Remove `mockPricingData` and the `beforeEach` that mocks `fs.readFileSync`
|
||||
- Update model names in tests to match models that exist in `resources/pricing.json` (the existing `claude-3-5-sonnet-20241022` and `claude-3-opus-20240229` should 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`/`path` imports, `ModelPricing` interface, `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:
|
||||
|
||||
```typescript
|
||||
if (msg.model) {
|
||||
costUsd += calculateMessageCost(
|
||||
msg.model,
|
||||
msgInputTokens,
|
||||
msgOutputTokens,
|
||||
msgCacheReadTokens,
|
||||
msgCacheCreationTokens
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- Remove the unused `modelName` variable (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**
|
||||
|
||||
```bash
|
||||
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 `ModelPricing` import from `@renderer/types/sessionReport` (line 32)
|
||||
- Remove lines 60-130: `MODEL_PRICING` table, `DEFAULT_PRICING`, `getPricing()`, `costUsd()`
|
||||
- Add import: `import { calculateMessageCost, getDisplayPricing } from '@shared/utils/pricing';`
|
||||
- Export `getDisplayPricing` as `getPricing` for backward compat with CostSection: `export { getDisplayPricing as getPricing } from '@shared/utils/pricing';`
|
||||
- Replace `costUsd(model, inpTok, outTok, cr, cc)` at line 476 with `calculateMessageCost(model, inpTok, outTok, cr, cc)`
|
||||
- Replace `costUsd(subagentModel, ...)` at line 900 with `calculateMessageCost(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 `ModelPricing` interface (lines 23-28) with a re-export from the shared module:
|
||||
|
||||
```typescript
|
||||
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'` to `import { getPricing } from '@renderer/utils/sessionAnalyzer'` — no change needed if we re-export from sessionAnalyzer. Verify the import still resolves.
|
||||
- The `ModelPricing` import from `@renderer/types/sessionReport` (line 10) continues to work via the re-export.
|
||||
- The `CostBreakdownCard` (lines 34-46) uses `pricing.input`, `pricing.output`, etc. as per-million rates — this matches `DisplayPricing` from `getDisplayPricing()`.
|
||||
|
||||
**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**
|
||||
|
||||
```bash
|
||||
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 `TokenUsageDisplay` shows 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:
|
||||
```bash
|
||||
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 if `extraResources` for 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**
|
||||
|
||||
```bash
|
||||
git add docs/plans/2026-02-23-unify-cost-calculation.md
|
||||
git commit -m "docs: finalize implementation plan for cost unification"
|
||||
```
|
||||
Loading…
Reference in a new issue