diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index f938ab42..2bb84973 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -26,6 +26,7 @@ import { handleHookInput } from './utils/hook-handler'; import { getSessionDuration, getSpeedMetricsCollection, + getSubagentCostUsd, getTokenMetrics } from './utils/jsonl'; import { advanceGlobalPowerlineThemeIndex } from './utils/powerline-theme-index'; @@ -127,6 +128,12 @@ async function renderMultipleLines(data: StatusJSON) { tokenMetrics = await getTokenMetrics(data.transcript_path); } + const hasCostTotal = lines.some(line => line.some(item => item.type === 'session-cost-total')); + let subagentCostUsd: number | null = null; + if (hasCostTotal && data.transcript_path) { + subagentCostUsd = await getSubagentCostUsd(data.transcript_path); + } + let sessionDuration: string | null = null; if (hasSessionClock && !hasSessionDurationInStatusJson(data) && data.transcript_path) { sessionDuration = await getSessionDuration(data.transcript_path); @@ -161,6 +168,7 @@ async function renderMultipleLines(data: StatusJSON) { const context: RenderContext = { data, tokenMetrics, + subagentCostUsd, speedMetrics, windowedSpeedMetrics, usageData, diff --git a/src/types/RenderContext.ts b/src/types/RenderContext.ts index 78fd6308..d699b7ed 100644 --- a/src/types/RenderContext.ts +++ b/src/types/RenderContext.ts @@ -33,6 +33,7 @@ export interface CompactionData { export interface RenderContext { data?: StatusJSON; tokenMetrics?: TokenMetrics | null; + subagentCostUsd?: number | null; speedMetrics?: SpeedMetrics | null; windowedSpeedMetrics?: Record | null; usageData?: RenderUsageData | null; diff --git a/src/types/TokenMetrics.ts b/src/types/TokenMetrics.ts index 042385d9..992fd4c4 100644 --- a/src/types/TokenMetrics.ts +++ b/src/types/TokenMetrics.ts @@ -6,7 +6,7 @@ export interface TokenUsage { } export interface TranscriptLine { - message?: { usage?: TokenUsage; stop_reason?: string | null }; + message?: { usage?: TokenUsage; stop_reason?: string | null; model?: string }; isSidechain?: boolean; timestamp?: string; isApiErrorMessage?: boolean; diff --git a/src/utils/__tests__/jsonl-metrics.test.ts b/src/utils/__tests__/jsonl-metrics.test.ts index c2c13a91..14487d9c 100644 --- a/src/utils/__tests__/jsonl-metrics.test.ts +++ b/src/utils/__tests__/jsonl-metrics.test.ts @@ -12,6 +12,7 @@ import { getSessionDuration, getSpeedMetrics, getSpeedMetricsCollection, + getSubagentCostUsd, getTokenMetrics } from '../jsonl'; @@ -1098,4 +1099,37 @@ describe('jsonl transcript metrics', () => { requestCount: 0 }); }); + + it('sums subagent cost from referenced subagent transcripts (priced by model)', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-cost-')); + tempRoots.push(root); + const transcriptPath = path.join(root, 'session.jsonl'); + fs.writeFileSync(transcriptPath, JSON.stringify({ type: 'assistant', message: { content: [{ agentId: 'aaa' }] } })); + + const subagentsDir = path.join(root, 'subagents'); + fs.mkdirSync(subagentsDir); + // 1M input + 1M output on Opus = $5 + $25 = $30 + fs.writeFileSync(path.join(subagentsDir, 'agent-aaa.jsonl'), JSON.stringify({ + type: 'assistant', + message: { + model: 'claude-opus-4-8', + stop_reason: 'end_turn', + usage: { input_tokens: 1_000_000, output_tokens: 1_000_000 } + } + })); + + expect(await getSubagentCostUsd(transcriptPath)).toBeCloseTo(30, 6); + }); + + it('returns 0 subagent cost when none are referenced', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-cost-')); + tempRoots.push(root); + const transcriptPath = path.join(root, 'session.jsonl'); + fs.writeFileSync(transcriptPath, JSON.stringify({ type: 'user' })); + expect(await getSubagentCostUsd(transcriptPath)).toBe(0); + }); + + it('returns 0 subagent cost when transcript is missing', async () => { + expect(await getSubagentCostUsd('/tmp/ccstatusline-jsonl-cost-missing.jsonl')).toBe(0); + }); }); diff --git a/src/utils/__tests__/model-pricing.test.ts b/src/utils/__tests__/model-pricing.test.ts new file mode 100644 index 00000000..58e50aa6 --- /dev/null +++ b/src/utils/__tests__/model-pricing.test.ts @@ -0,0 +1,41 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import { costForUsage } from '../model-pricing'; + +describe('costForUsage', () => { + it('prices Opus input + output at $5/$25 per MTok', () => { + const cost = costForUsage('claude-opus-4-8', { + inputTokens: 1_000_000, + outputTokens: 1_000_000, + cacheReadTokens: 0, + cacheCreationTokens: 0 + }); + expect(cost).toBeCloseTo(30, 6); // 5 + 25 + }); + + it('prices Sonnet and Haiku by family', () => { + const sonnet = costForUsage('claude-sonnet-4-6', { inputTokens: 1_000_000, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 }); + const haiku = costForUsage('claude-haiku-4-5', { inputTokens: 1_000_000, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 }); + expect(sonnet).toBeCloseTo(3, 6); + expect(haiku).toBeCloseTo(1, 6); + }); + + it('applies cache multipliers (1.25x write, 0.1x read) off input price', () => { + const cost = costForUsage('claude-opus-4-8', { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 1_000_000, + cacheReadTokens: 1_000_000 + }); + expect(cost).toBeCloseTo(5 * 1.25 + 5 * 0.1, 6); // 6.25 + 0.5 + }); + + it('falls back to Opus-tier pricing for unknown models', () => { + const cost = costForUsage('some-unknown-model', { inputTokens: 1_000_000, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 }); + expect(cost).toBeCloseTo(5, 6); + }); +}); diff --git a/src/utils/jsonl-metrics.ts b/src/utils/jsonl-metrics.ts index 52207ec8..671255cd 100644 --- a/src/utils/jsonl-metrics.ts +++ b/src/utils/jsonl-metrics.ts @@ -11,6 +11,7 @@ import { parseJsonlLine, readJsonlLines } from './jsonl-lines'; +import { costForUsage } from './model-pricing'; export interface SpeedMetricsOptions { includeSubagents?: boolean; @@ -490,6 +491,73 @@ function getSubagentTranscriptPaths(transcriptPath: string, referencedAgentIds: return matchedPaths; } +// Sum the USD cost of one subagent transcript from its per-call token usage. +// Mirrors getTokenMetrics' streaming dedup: when entries carry stop_reason, +// count finalized entries plus the latest unfinished one; otherwise count all. +function subagentCostFromLines(lines: string[]): number { + const parsedEntries: TranscriptLine[] = []; + let hasStopReasonField = false; + + for (const line of lines) { + const data = parseJsonlLine(line) as TranscriptLine | null; + if (data?.message?.usage && !data.isApiErrorMessage) { + parsedEntries.push(data); + if (Object.hasOwn(data.message, 'stop_reason')) { + hasStopReasonField = true; + } + } + } + + const entriesToCount = hasStopReasonField + ? parsedEntries.filter((data, index) => { + const stopReason = data.message?.stop_reason; + return Boolean(stopReason) || (stopReason === null && index === parsedEntries.length - 1); + }) + : parsedEntries; + + let cost = 0; + for (const data of entriesToCount) { + const usage = data.message?.usage; + if (!usage) { + continue; + } + cost += costForUsage(data.message?.model ?? '', { + inputTokens: usage.input_tokens || 0, + outputTokens: usage.output_tokens || 0, + cacheReadTokens: usage.cache_read_input_tokens ?? 0, + cacheCreationTokens: usage.cache_creation_input_tokens ?? 0 + }); + } + return cost; +} + +// Total USD cost of all subagents (Task/Agent tool) referenced this session. +// Claude Code's cost.total_cost_usd covers only the main transcript; this fills +// the gap by pricing the separate subagents/agent-*.jsonl transcripts. +export async function getSubagentCostUsd(transcriptPath: string): Promise { + try { + if (!fs.existsSync(transcriptPath)) { + return 0; + } + + const mainLines = await readJsonlLines(transcriptPath); + const referencedSubagentIds = getReferencedSubagentIds(mainLines); + const subagentPaths = getSubagentTranscriptPaths(transcriptPath, referencedSubagentIds); + + const costs = await Promise.all(subagentPaths.map(async (subagentPath) => { + try { + return subagentCostFromLines(await readJsonlLines(subagentPath)); + } catch { + return 0; + } + })); + + return costs.reduce((sum, c) => sum + c, 0); + } catch { + return 0; + } +} + export async function getSpeedMetricsCollection( transcriptPath: string, options: SpeedMetricsCollectionOptions = {} diff --git a/src/utils/jsonl.ts b/src/utils/jsonl.ts index 3c6497a7..254293aa 100644 --- a/src/utils/jsonl.ts +++ b/src/utils/jsonl.ts @@ -9,6 +9,7 @@ export { getSessionDuration, getSpeedMetrics, getSpeedMetricsCollection, + getSubagentCostUsd, getTokenMetrics } from './jsonl-metrics'; export { diff --git a/src/utils/model-pricing.ts b/src/utils/model-pricing.ts new file mode 100644 index 00000000..cb90c279 --- /dev/null +++ b/src/utils/model-pricing.ts @@ -0,0 +1,46 @@ +// Per-million-token USD prices by model family. Source: Anthropic pricing +// (input / output). Cache write is billed at 1.25x input (5m TTL), cache read +// at 0.1x input — the standard ephemeral-cache multipliers. +interface ModelPrice { + inputPerMTok: number; + outputPerMTok: number; +} + +const PRICES: { match: (id: string) => boolean; price: ModelPrice }[] = [ + { match: id => id.includes('fable') || id.includes('mythos'), price: { inputPerMTok: 10, outputPerMTok: 50 } }, + { match: id => id.includes('haiku'), price: { inputPerMTok: 1, outputPerMTok: 5 } }, + { match: id => id.includes('sonnet'), price: { inputPerMTok: 3, outputPerMTok: 15 } }, + { match: id => id.includes('opus'), price: { inputPerMTok: 5, outputPerMTok: 25 } } +]; + +// Default to Opus-tier pricing for unknown models (Claude Code's default tier). +const DEFAULT_PRICE: ModelPrice = { inputPerMTok: 5, outputPerMTok: 25 }; + +function priceFor(modelId: string): ModelPrice { + const id = modelId.toLowerCase(); + for (const entry of PRICES) { + if (entry.match(id)) { + return entry.price; + } + } + return DEFAULT_PRICE; +} + +export interface ModelUsage { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; +} + +export function costForUsage(modelId: string, usage: ModelUsage): number { + const price = priceFor(modelId); + const inPerTok = price.inputPerMTok / 1_000_000; + const outPerTok = price.outputPerMTok / 1_000_000; + return ( + usage.inputTokens * inPerTok + + usage.outputTokens * outPerTok + + usage.cacheCreationTokens * inPerTok * 1.25 + + usage.cacheReadTokens * inPerTok * 0.1 + ); +} diff --git a/src/utils/widget-manifest.ts b/src/utils/widget-manifest.ts index 86cbad71..c955b117 100644 --- a/src/utils/widget-manifest.ts +++ b/src/utils/widget-manifest.ts @@ -69,6 +69,7 @@ export const WIDGET_MANIFEST: WidgetManifestEntry[] = [ { type: 'context-percentage-usable', create: () => new widgets.ContextPercentageUsableWidget() }, { type: 'session-clock', create: () => new widgets.SessionClockWidget() }, { type: 'session-cost', create: () => new widgets.SessionCostWidget() }, + { type: 'session-cost-total', create: () => new widgets.SessionCostTotalWidget() }, { type: 'block-timer', create: () => new widgets.BlockTimerWidget() }, { type: 'terminal-width', create: () => new widgets.TerminalWidthWidget() }, { type: 'version', create: () => new widgets.VersionWidget() }, diff --git a/src/widgets/SessionCostTotal.ts b/src/widgets/SessionCostTotal.ts new file mode 100644 index 00000000..aeab696d --- /dev/null +++ b/src/widgets/SessionCostTotal.ts @@ -0,0 +1,49 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; + +// Session cost including subagents. Claude Code's cost.total_cost_usd covers +// only the main transcript; this widget adds the cost of Task/Agent subagents +// (priced from their separate transcripts) so heavy parallel-agent sessions +// show a realistic total. +export class SessionCostTotalWidget implements Widget { + getDefaultColor(): string { return 'green'; } + getDescription(): string { return 'Shows the total session cost in USD including subagents (Task/Agent tool)'; } + getDisplayName(): string { return 'Session Cost (with subagents)'; } + getCategory(): string { return 'Session'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { displayText: this.getDisplayName() }; + } + + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + void settings; + if (context.isPreview) { + return item.rawValue ? '$3.55' : 'Total Cost: $3.55'; + } + + const mainCost = context.data?.cost?.total_cost_usd; + if (mainCost === undefined) { + return null; + } + + const total = mainCost + (context.subagentCostUsd ?? 0); + const formatted = `$${total.toFixed(2)}`; + + return item.rawValue ? formatted : `Total Cost: ${formatted}`; + } + + getNumericValue(context: RenderContext): number | null { + const mainCost = context.data?.cost?.total_cost_usd; + if (mainCost === undefined) { + return null; + } + return mainCost + (context.subagentCostUsd ?? 0); + } + + supportsRawValue(): boolean { return true; } + supportsColors(item: WidgetItem): boolean { return true; } +} diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 518e3c1d..98d01464 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -38,6 +38,7 @@ export { ContextPercentageWidget } from './ContextPercentage'; export { ContextPercentageUsableWidget } from './ContextPercentageUsable'; export { SessionClockWidget } from './SessionClock'; export { SessionCostWidget } from './SessionCost'; +export { SessionCostTotalWidget } from './SessionCostTotal'; export { TerminalWidthWidget } from './TerminalWidth'; export { VersionWidget } from './Version'; export { CustomTextWidget } from './CustomText';