Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion src/main/ipc/configValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export type ConfigUpdateValidationResult =
| ValidationSuccess<'display'>
| ValidationSuccess<'httpServer'>
| ValidationSuccess<'ssh'>
| ValidationSuccess<'subscriptions'>
| ValidationFailure;

const VALID_SECTIONS = new Set<ConfigSection>([
Expand All @@ -42,6 +43,7 @@ const VALID_SECTIONS = new Set<ConfigSection>([
'display',
'httpServer',
'ssh',
'subscriptions',
]);
const MAX_SNOOZE_MINUTES = 24 * 60;

Expand Down Expand Up @@ -432,14 +434,49 @@ function validateSshSection(data: unknown): ValidationSuccess<'ssh'> | Validatio
return { valid: true, section: 'ssh', data: result };
}

function isValidSubscriptionEntry(entry: unknown): boolean {
if (!isPlainObject(entry)) return false;
if (typeof entry.id !== 'string' || entry.id.trim().length === 0) return false;
if (typeof entry.date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(entry.date)) return false;
if (typeof entry.plan !== 'string' || entry.plan.trim().length === 0) return false;
if (!isFiniteNumber(entry.amountUsd) || entry.amountUsd <= 0) return false;
if (entry.note !== undefined && typeof entry.note !== 'string') return false;
return true;
}

function validateSubscriptionsSection(
data: unknown
): ValidationSuccess<'subscriptions'> | ValidationFailure {
if (!isPlainObject(data)) {
return { valid: false, error: 'subscriptions update must be an object' };
}

if (!('entries' in data)) {
return { valid: false, error: 'subscriptions.entries is required' };
}

if (!Array.isArray(data.entries) || !data.entries.every(isValidSubscriptionEntry)) {
return {
valid: false,
error: 'subscriptions.entries must be a valid subscription entry array',
};
}

return {
valid: true,
section: 'subscriptions',
data: { entries: data.entries as AppConfig['subscriptions']['entries'] },
};
}

export function validateConfigUpdatePayload(
section: unknown,
data: unknown
): ConfigUpdateValidationResult {
if (typeof section !== 'string' || !VALID_SECTIONS.has(section as ConfigSection)) {
return {
valid: false,
error: 'Section must be one of: notifications, general, display, httpServer, ssh',
error: 'Section must be one of: notifications, general, display, httpServer, ssh, subscriptions',
};
}

Expand All @@ -454,6 +491,8 @@ export function validateConfigUpdatePayload(
return validateHttpServerSection(data);
case 'ssh':
return validateSshSection(data);
case 'subscriptions':
return validateSubscriptionsSection(data);
default:
return { valid: false, error: 'Invalid section' };
}
Expand Down
70 changes: 69 additions & 1 deletion src/main/ipc/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,12 @@ import {
type SessionsByIdsOptions,
type SessionsPaginationOptions,
} from '../types';
import { calculateMetrics, parseJsonlFile } from '../utils/jsonl';

import { coercePageLimit, validateProjectId, validateSessionId } from './guards';

import type { ServiceContextRegistry } from '../services';
import type { WaterfallData } from '@shared/types';
import type { UsageStats, WaterfallData } from '@shared/types';

const logger = createLogger('IPC:sessions');

Expand All @@ -51,6 +52,7 @@ export function registerSessionHandlers(ipcMain: IpcMain): void {
ipcMain.handle('get-session-groups', handleGetSessionGroups);
ipcMain.handle('get-session-metrics', handleGetSessionMetrics);
ipcMain.handle('get-waterfall-data', handleGetWaterfallData);
ipcMain.handle('get-usage-stats', handleGetUsageStats);

logger.info('Session handlers registered');
}
Expand All @@ -66,6 +68,7 @@ export function removeSessionHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler('get-session-groups');
ipcMain.removeHandler('get-session-metrics');
ipcMain.removeHandler('get-waterfall-data');
ipcMain.removeHandler('get-usage-stats');

logger.info('Session handlers removed');
}
Expand Down Expand Up @@ -361,3 +364,68 @@ async function handleGetWaterfallData(
return null;
}
}

/**
* Handler for 'get-usage-stats' IPC call.
* Aggregates token usage and estimated API cost across all sessions
* that started within the specified calendar month (local time).
*
* @param year Full year, e.g. 2026
* @param month 1-based month, e.g. 3 for March
*/
async function handleGetUsageStats(
_event: IpcMainInvokeEvent,
year: number,
month: number
): Promise<UsageStats> {
const empty: UsageStats = {
totalCostUsd: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheCreationTokens: 0,
sessionCount: 0,
};

try {
const { projectScanner, fsProvider } = registry.getActive();

// Date range for the requested month (Unix ms, inclusive)
const start = new Date(year, month - 1, 1).getTime();
const end = new Date(year, month, 1).getTime(); // exclusive

// Collect all projects
const projects = await projectScanner.scan();

const stats: UsageStats = { ...empty };

for (const project of projects) {
const sessions = await projectScanner.listSessions(project.id);

for (const session of sessions) {
// Use createdAt (unix ms) for date filtering
if (session.createdAt < start || session.createdAt >= end) continue;

try {
const sessionPath = projectScanner.getSessionPath(project.id, session.id);
const messages = await parseJsonlFile(sessionPath, fsProvider);
const metrics = calculateMetrics(messages);

stats.inputTokens += metrics.inputTokens;
stats.outputTokens += metrics.outputTokens;
stats.cacheReadTokens += metrics.cacheReadTokens;
stats.cacheCreationTokens += metrics.cacheCreationTokens;
stats.totalCostUsd += metrics.costUsd ?? 0;
stats.sessionCount += 1;
} catch (sessionErr) {
logger.warn(`get-usage-stats: skipping session ${session.id}:`, sessionErr);
}
}
}

return stats;
} catch (error) {
logger.error(`Error in get-usage-stats for ${year}-${month}:`, error);
return empty;
}
}
13 changes: 13 additions & 0 deletions src/main/services/infrastructure/ConfigManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { DEFAULT_TRIGGERS, TriggerManager } from './TriggerManager';

import type { TriggerColor } from '@shared/constants/triggerColors';
import type { SshConnectionProfile } from '@shared/types/api';
import type { SubscriptionEntry, SubscriptionsConfig } from '@shared/types/notifications';

const logger = createLogger('Service:ConfigManager');

Expand Down Expand Up @@ -214,13 +215,17 @@ export interface HttpServerConfig {
port: number;
}

// Re-export from shared types so consumers can import from either location
export type { SubscriptionEntry, SubscriptionsConfig };

export interface AppConfig {
notifications: NotificationConfig;
general: GeneralConfig;
display: DisplayConfig;
sessions: SessionsConfig;
ssh: SshPersistConfig;
httpServer: HttpServerConfig;
subscriptions: SubscriptionsConfig;
}

// Config section keys for type-safe updates
Expand Down Expand Up @@ -272,6 +277,9 @@ const DEFAULT_CONFIG: AppConfig = {
enabled: false,
port: 3456,
},
subscriptions: {
entries: [],
},
};

function normalizeConfiguredClaudeRootPath(value: unknown): string | null {
Expand Down Expand Up @@ -461,6 +469,11 @@ export class ConfigManager {
...DEFAULT_CONFIG.httpServer,
...(loaded.httpServer ?? {}),
},
subscriptions: {
...DEFAULT_CONFIG.subscriptions,
...(loaded.subscriptions ?? {}),
entries: loaded.subscriptions?.entries ?? DEFAULT_CONFIG.subscriptions.entries,
},
};
}

Expand Down
19 changes: 14 additions & 5 deletions src/main/utils/jsonl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
} from '../types';

// Import from extracted modules
import { calculateTokenCost } from './pricingModel';
import { extractToolCalls, extractToolResults } from './toolExtraction';

import type { FileSystemProvider } from '../services/infrastructure/FileSystemProvider';
Expand Down Expand Up @@ -271,7 +272,7 @@ export function calculateMetrics(messages: ParsedMessage[]): SessionMetrics {
let outputTokens = 0;
let cacheReadTokens = 0;
let cacheCreationTokens = 0;
const costUsd = 0;
let costUsd = 0;

// Get timestamps for duration (loop instead of Math.min/max spread to avoid stack overflow on large sessions)
const timestamps = messages.map((m) => m.timestamp.getTime()).filter((t) => !isNaN(t));
Expand All @@ -289,10 +290,18 @@ export function calculateMetrics(messages: ParsedMessage[]): SessionMetrics {

for (const msg of dedupedMessages) {
if (msg.usage) {
inputTokens += msg.usage.input_tokens ?? 0;
outputTokens += msg.usage.output_tokens ?? 0;
cacheReadTokens += msg.usage.cache_read_input_tokens ?? 0;
cacheCreationTokens += msg.usage.cache_creation_input_tokens ?? 0;
const msgInput = msg.usage.input_tokens ?? 0;
const msgOutput = msg.usage.output_tokens ?? 0;
const msgCacheRead = msg.usage.cache_read_input_tokens ?? 0;
const msgCacheCreate = msg.usage.cache_creation_input_tokens ?? 0;

inputTokens += msgInput;
outputTokens += msgOutput;
cacheReadTokens += msgCacheRead;
cacheCreationTokens += msgCacheCreate;

// Accumulate per-message cost using the model reported by that message
costUsd += calculateTokenCost(msg.model, msgInput, msgOutput, msgCacheRead, msgCacheCreate);
}
}

Expand Down
135 changes: 135 additions & 0 deletions src/main/utils/pricingModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* Claude API pricing model.
*
* Provides token-to-USD cost calculation for all Claude model families.
* Prices are in USD per 1,000,000 tokens.
*
* Source: https://www.anthropic.com/api
*/

export interface ModelPricing {
/** USD per 1M input tokens */
inputPerMillion: number;
/** USD per 1M output tokens */
outputPerMillion: number;
/** USD per 1M cache creation tokens (prompt caching write) */
cacheWritePerMillion: number;
/** USD per 1M cache read tokens (prompt caching read) */
cacheReadPerMillion: number;
}

/**
* Pricing table: [model-string-prefix, pricing].
* Entries are ordered from most-specific to least-specific.
* Matching is done via startsWith or includes on the lowercased model string.
*/
const PRICING_TABLE: [string, ModelPricing][] = [
// ── Claude 4 ──────────────────────────────────────────────────────────
[
'claude-opus-4',
{ inputPerMillion: 15.0, outputPerMillion: 75.0, cacheWritePerMillion: 18.75, cacheReadPerMillion: 1.5 },
],
[
'claude-sonnet-4',
{ inputPerMillion: 3.0, outputPerMillion: 15.0, cacheWritePerMillion: 3.75, cacheReadPerMillion: 0.3 },
],
[
'claude-haiku-4',
{ inputPerMillion: 0.8, outputPerMillion: 4.0, cacheWritePerMillion: 1.0, cacheReadPerMillion: 0.08 },
],

// ── Claude 3.7 ────────────────────────────────────────────────────────
[
'claude-3-7-sonnet',
{ inputPerMillion: 3.0, outputPerMillion: 15.0, cacheWritePerMillion: 3.75, cacheReadPerMillion: 0.3 },
],

// ── Claude 3.5 ────────────────────────────────────────────────────────
[
'claude-3-5-sonnet',
{ inputPerMillion: 3.0, outputPerMillion: 15.0, cacheWritePerMillion: 3.75, cacheReadPerMillion: 0.3 },
],
[
'claude-3-5-haiku',
{ inputPerMillion: 0.8, outputPerMillion: 4.0, cacheWritePerMillion: 1.0, cacheReadPerMillion: 0.08 },
],

// ── Claude 3 ──────────────────────────────────────────────────────────
[
'claude-3-opus',
{ inputPerMillion: 15.0, outputPerMillion: 75.0, cacheWritePerMillion: 18.75, cacheReadPerMillion: 1.5 },
],
[
'claude-3-sonnet',
{ inputPerMillion: 3.0, outputPerMillion: 15.0, cacheWritePerMillion: 3.75, cacheReadPerMillion: 0.3 },
],
[
'claude-3-haiku',
{ inputPerMillion: 0.25, outputPerMillion: 1.25, cacheWritePerMillion: 0.3, cacheReadPerMillion: 0.03 },
],

// ── Claude 2 ──────────────────────────────────────────────────────────
[
'claude-2',
{ inputPerMillion: 8.0, outputPerMillion: 24.0, cacheWritePerMillion: 8.0, cacheReadPerMillion: 8.0 },
],
[
'claude-instant',
{ inputPerMillion: 1.63, outputPerMillion: 5.51, cacheWritePerMillion: 1.63, cacheReadPerMillion: 1.63 },
],
];
Comment on lines +26 to +80
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

What are the current token pricing rates for Claude API models including Claude 4, Claude 3.5, and Claude 3?

💡 Result:

Current Claude API token pricing rates (per million tokens, USD, as of March 2026) from official Anthropic documentation: Claude 4 models (latest flagship family): - Claude Opus 4.6: Input $5, Output $25 - Claude Sonnet 4.6: Input $3, Output $15 - Claude Opus 4.5: Input $5, Output $25 - Claude Sonnet 4.5: Input $3, Output $15 - Claude Haiku 4.5: Input $1, Output $5 Claude 3.5 models: - Claude Haiku 3.5: Input $0.80, Output $4 Claude 3 models (legacy/deprecated): - Claude Opus 3: Input $15, Output $75 - Claude Sonnet 3.7: Input $3, Output $15 (deprecated) - Claude Haiku 3: Input $0.25, Output $1.25 Notes: Prices are for base input/output tokens. Prompt caching, batch processing (50% discount), and long-context may modify rates. Claude 4 represents the current production models; Claude 3/3.5 are older generations still available but some deprecated. No specific "Claude 3.5 Sonnet" or "Claude 3 Opus/Sonnet" listed separately in current docs—likely rolled into 4.x or legacy 3.7/3.

Citations:


Update PRICING_TABLE to reflect current Anthropic API pricing (as of March 2026).

The hardcoded pricing values are outdated and will cause inaccurate cost calculations:

  • claude-opus-4: Input/Output should be $5/$25 (not $15/$75)
  • claude-haiku-4: Input/Output should be $1/$5 (not $0.8/$4)
  • Missing newer model variants: Claude Opus/Sonnet 4.6 and 4.5 entries should be added
  • Claude 2 and Instant are deprecated and no longer in current production pricing; consider removing or clearly marking as legacy

Reference current pricing from Anthropic documentation.

Regarding Claude 2/Instant cache rates matching input rates—if intentionally supporting legacy models without caching, add an inline comment explaining this is by design.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/utils/pricingModel.ts` around lines 26 - 88, Update the
PRICING_TABLE entries to match current Anthropic pricing: change the
'claude-opus-4' input/output to 5.0/25.0 and change 'claude-haiku-4'
input/output to 1.0/5.0; add new entries for the 4.6 and 4.5 variants (e.g.,
'claude-opus-4-6'/'claude-sonnet-4-6' and 'claude-opus-4-5'/'claude-sonnet-4-5')
with their correct input/output/cache rates per the Anthropic docs, and either
remove or mark 'claude-2' and 'claude-instant' as legacy (add an inline "legacy"
comment and keep or remove the entries accordingly); ensure
cacheWritePerMillion/cacheReadPerMillion values are updated to match current
rates and, if keeping legacy models with cache rates equal to input by design,
add a comment in PRICING_TABLE explaining that choice.


/**
* Fallback pricing used for unknown or future models.
* Uses Claude Sonnet 4 pricing as a reasonable mid-tier estimate.
*/
export const FALLBACK_PRICING: ModelPricing = {
inputPerMillion: 3.0,
outputPerMillion: 15.0,
cacheWritePerMillion: 3.75,
cacheReadPerMillion: 0.3,
};

/**
* Returns pricing for a given model string.
* Matching is prefix-based and case-insensitive.
* Falls back to Sonnet-tier pricing for unrecognized models.
*/
export function getPricingForModel(model: string | null | undefined): ModelPricing {
if (!model) return FALLBACK_PRICING;

const lower = model.toLowerCase();
for (const [prefix, pricing] of PRICING_TABLE) {
if (lower.startsWith(prefix) || lower.includes(prefix)) {
return pricing;
}
}

return FALLBACK_PRICING;
}

/**
* Calculates the USD cost for a single API response given token counts and model.
*
* @param model Model string (e.g. "claude-sonnet-4-5-20251022")
* @param inputTokens Prompt / input tokens
* @param outputTokens Completion / output tokens
* @param cacheReadTokens Tokens retrieved from prompt cache
* @param cacheCreationTokens Tokens written to prompt cache
* @returns Cost in USD
*/
export function calculateTokenCost(
model: string | null | undefined,
inputTokens: number,
outputTokens: number,
cacheReadTokens = 0,
cacheCreationTokens = 0
): number {
const p = getPricingForModel(model);
return (
(inputTokens / 1_000_000) * p.inputPerMillion +
(outputTokens / 1_000_000) * p.outputPerMillion +
(cacheReadTokens / 1_000_000) * p.cacheReadPerMillion +
(cacheCreationTokens / 1_000_000) * p.cacheWritePerMillion
);
}
Loading