Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
6 changes: 6 additions & 0 deletions .changeset/usage-budget.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@open-codesign/desktop": minor
"@open-codesign/i18n": patch
---

Show recorded token and USD totals for the current design, including today and the local week, beside the existing per-run token count.
28 changes: 27 additions & 1 deletion apps/desktop/src/main/ipc/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ import {
workspaceNameFromPath,
} from '../memory-ipc';
import { getApiKeyForProvider, getCachedConfig, hasApiKeyForProvider } from '../onboarding-ipc';

import { readPersisted as readPreferences } from '../preferences-ipc';
import { runPreview } from '../preview-runtime';
import { preparePromptContext } from '../prompt-context';
Expand Down Expand Up @@ -89,6 +88,7 @@ import {
} from '../snapshots-db';
import { registerSourceEditBusyCheck } from '../source-edits-ipc';
import { withTlsBypass } from '../tls-override';
import { summarizeUsageBudget, type UsageTotals } from '../usage-budget';
import { createResearchHost, createWebResearchAuthorization } from '../web-research';
import { createWebResearchRun } from '../web-research-run';
import { withStableWorkspacePath } from '../workspace-path-lock';
Expand Down Expand Up @@ -510,6 +510,32 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe
return recovered;
});

const emptyUsage = (): UsageTotals => ({ inputTokens: 0, outputTokens: 0, costUsd: 0 });
ipcMain.handle('codesign:v1:usage-budget', async (_event, raw: unknown) => {
const input = raw as { schemaVersion?: unknown; designId?: unknown } | null;
if (
!input ||
input.schemaVersion !== 1 ||
typeof input.designId !== 'string' ||
input.designId.length === 0 ||
input.designId.length > 512
) {
throw new CodesignError('Invalid usage budget request', 'IPC_BAD_INPUT');
}
const empty = {
schemaVersion: 1 as const,
design: emptyUsage(),
today: emptyUsage(),
week: emptyUsage(),
};
if (!journal) return empty;
const records = await journal.usageRecords();
return {
schemaVersion: 1 as const,
...summarizeUsageBudget(records, input.designId, Date.now()),
};
});

const recordFinalError = (scope: string, runId: string, err: unknown): void => {
if (db === null) return;
const code = err instanceof CodesignError ? (err.code as string) : 'PROVIDER_UPSTREAM_ERROR';
Expand Down
22 changes: 22 additions & 0 deletions apps/desktop/src/main/run-journal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,4 +325,26 @@ describe('RunJournal', () => {
expect.objectContaining({ type: 'run_settled', seq: 1, outcome: 'interrupted' }),
]);
});

it('sums settled provider usage after the journal is reopened', async () => {
const journal = new RunJournal(directory);
await start(journal, 'run-a', 'design-a', 10);
const response: GenerateResponse = {
message: 'done',
artifacts: [],
inputTokens: 12,
outputTokens: 8,
costUsd: 0.03,
};
await journal.append(
event('run-a', 'design-a', { type: 'run_settled', outcome: 'completed', response }),
);
await start(journal, 'run-b', 'design-b', 11);
await journal.append(event('run-b', 'design-b', { type: 'run_settled', outcome: 'failed' }));

const reopened = new RunJournal(directory);
await expect(reopened.usageRecords()).resolves.toEqual([
{ designId: 'design-a', startedAt: 10, inputTokens: 12, outputTokens: 8, costUsd: 0.03 },
]);
});
});
48 changes: 48 additions & 0 deletions apps/desktop/src/main/run-journal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,18 @@ export interface RunJournalRecovery {

type JournalEvent = AgentStreamEvent & { schemaVersion: 1; runId: string; seq: number };

interface RunUsage {
inputTokens: number;
outputTokens: number;
costUsd: number;
}

interface RunState extends RunJournalStart {
seq: number;
bytes: number;
settled: boolean;
writeError?: Error;
usage?: RunUsage;
}

const EVENT_TYPES = new Set<AgentStreamEvent['type']>([
Expand Down Expand Up @@ -89,6 +96,20 @@ function validateEvent(value: unknown, run: RunState, seq: number): JournalEvent
return record as unknown as JournalEvent;
}

function finiteUsage(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0;
}

function usageFromResponse(response: unknown): RunUsage | undefined {
if (response === null || typeof response !== 'object') return undefined;
const record = response as Record<string, unknown>;
return {
inputTokens: finiteUsage(record['inputTokens']),
outputTokens: finiteUsage(record['outputTokens']),
costUsd: finiteUsage(record['costUsd']),
};
}

function encode(value: unknown): Buffer {
const data = Buffer.from(`${JSON.stringify(value)}\n`, 'utf8');
if (data.length > MAX_RECORD_BYTES) throw invalid('record exceeds the 64 MiB size limit');
Expand Down Expand Up @@ -173,6 +194,25 @@ export class RunJournal {
});
}

/** Settled runs that recorded a provider response. Totals are derived from these rows. */
usageRecords(): Promise<Array<{ designId: string; startedAt: number } & RunUsage>> {
return this.serialize(async () => {
await this.initialize();
const records: Array<{ designId: string; startedAt: number } & RunUsage> = [];
for (const run of this.sortedRuns()) {
if (!run.usage) continue;
records.push({
designId: run.designId,
startedAt: run.startedAt,
inputTokens: run.usage.inputTokens,
outputTokens: run.usage.outputTokens,
costUsd: run.usage.costUsd,
});
}
return records;
});
}

/** Startup projection repair; history is streamed from disk and never retained by this instance. */
allEvents(): Promise<AgentStreamEvent[]> {
return this.serialize(async () => {
Expand Down Expand Up @@ -278,6 +318,10 @@ export class RunJournal {
run.seq = event.seq;
run.bytes += bytes.length;
run.settled = event.type === 'run_settled';
if (event.type === 'run_settled') {
const usage = usageFromResponse(event.response);
if (usage) run.usage = usage;
}
return committed;
}

Expand Down Expand Up @@ -319,6 +363,10 @@ export class RunJournal {
const event = validateEvent(record, run, run.seq + 1);
run.seq = event.seq;
run.settled = event.type === 'run_settled';
if (event.type === 'run_settled') {
const usage = usageFromResponse(event.response);
if (usage) run.usage = usage;
}
if (events && event.seq > after) events.push(event);
}
run.bytes += newline + 1;
Expand Down
99 changes: 99 additions & 0 deletions apps/desktop/src/main/usage-budget.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest';
import {
formatUsageCost,
formatUsageTokens,
nextLocalMidnight,
summarizeUsageBudget,
} from './usage-budget';

const now = new Date(2024, 0, 7, 12).getTime();

describe('summarizeUsageBudget', () => {
it('keeps another design out of the current design totals', () => {
const budget = summarizeUsageBudget(
[
{
designId: 'a',
startedAt: new Date(2024, 0, 7, 9).getTime(),
inputTokens: 10,
outputTokens: 5,
costUsd: 0.25,
},
{
designId: 'b',
startedAt: new Date(2024, 0, 7, 9).getTime(),
inputTokens: 100,
outputTokens: 50,
costUsd: 9,
},
{
designId: 'a',
startedAt: new Date(2024, 0, 2, 9).getTime(),
inputTokens: 4,
outputTokens: 1,
costUsd: 0.5,
},
{
designId: 'a',
startedAt: new Date(2023, 11, 31, 23).getTime(),
inputTokens: 7,
outputTokens: 3,
costUsd: 0.125,
},
{
designId: 'a',
startedAt: new Date(2024, 0, 8, 1).getTime(),
inputTokens: 1,
outputTokens: 1,
costUsd: 0.25,
},
],
'a',
now,
);

expect(budget.design).toEqual({ inputTokens: 22, outputTokens: 10, costUsd: 1.125 });
expect(budget.today).toEqual({ inputTokens: 10, outputTokens: 5, costUsd: 0.25 });
expect(budget.week).toEqual({ inputTokens: 14, outputTokens: 6, costUsd: 0.75 });
});

it('counts a record that starts at the local week boundary', () => {
const budget = summarizeUsageBudget(
[
{
designId: 'a',
startedAt: new Date(2024, 0, 1).getTime(),
inputTokens: 2,
outputTokens: 3,
costUsd: 0.5,
},
],
'a',
now,
);
expect(budget.week).toEqual({ inputTokens: 2, outputTokens: 3, costUsd: 0.5 });
expect(budget.today).toEqual({ inputTokens: 0, outputTokens: 0, costUsd: 0 });
});
});

describe('usage formatting', () => {
it('formats token counts and small USD amounts', () => {
expect(formatUsageTokens(1234)).toBe('1,234');
expect(formatUsageCost(0)).toBe('$0');
expect(formatUsageCost(1.2)).toBe('$1.2');
expect(formatUsageCost(0.0125)).toBe('$0.0125');
expect(formatUsageCost(0.000012)).toBe('$0.000012');
});
});

describe('nextLocalMidnight', () => {
it('lands on the next local midnight and on Monday when the week turns', () => {
expect(nextLocalMidnight(new Date(2024, 0, 6, 23).getTime())).toBe(
new Date(2024, 0, 7).getTime(),
);
expect(nextLocalMidnight(new Date(2024, 0, 7, 12).getTime())).toBe(
new Date(2024, 0, 8).getTime(),
);
expect(nextLocalMidnight(new Date(2024, 0, 8).getTime())).toBe(new Date(2024, 0, 9).getTime());
});
});
77 changes: 77 additions & 0 deletions apps/desktop/src/main/usage-budget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
export interface UsageRecord {
designId: string;
startedAt: number;
inputTokens: number;
outputTokens: number;
costUsd: number;
}

export interface UsageTotals {
inputTokens: number;
outputTokens: number;
costUsd: number;
}

export interface UsageBudget {
design: UsageTotals;
today: UsageTotals;
week: UsageTotals;
}

function startOfLocalDay(now: number): number {
const date = new Date(now);
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
}

/** The next local midnight after now. Monday 00:00 is the week boundary too. */
export function nextLocalMidnight(now: number): number {
const date = new Date(now);
return new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1).getTime();
}

/** Local weeks start on Monday. */
function startOfLocalWeek(now: number): number {
const date = new Date(now);
const daysSinceMonday = (date.getDay() + 6) % 7;
return new Date(date.getFullYear(), date.getMonth(), date.getDate() - daysSinceMonday).getTime();
}

function add(total: UsageTotals, record: UsageRecord): void {
total.inputTokens += record.inputTokens;
total.outputTokens += record.outputTokens;
total.costUsd += record.costUsd;
}

export function summarizeUsageBudget(
records: readonly UsageRecord[],
designId: string,
now: number,
): UsageBudget {
const design: UsageTotals = { inputTokens: 0, outputTokens: 0, costUsd: 0 };
const today: UsageTotals = { inputTokens: 0, outputTokens: 0, costUsd: 0 };
const week: UsageTotals = { inputTokens: 0, outputTokens: 0, costUsd: 0 };
const dayStart = startOfLocalDay(now);
const weekStart = startOfLocalWeek(now);
for (const record of records) {
if (record.designId !== designId || !Number.isFinite(record.startedAt)) continue;
add(design, record);
if (record.startedAt > now) continue;
if (record.startedAt >= weekStart) add(week, record);
if (record.startedAt >= dayStart) add(today, record);
}
return { design, today, week };
}

export function formatUsageTokens(value: number): string {
return new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value);
}

export function formatUsageCost(value: number): string {
if (!Number.isFinite(value) || value <= 0) return '$0';
const digits = value >= 1 ? 2 : value >= 0.01 ? 4 : 6;
const text = value
.toFixed(digits)
.replace(/(\.\d*?)0+$/, '$1')
.replace(/\.$/, '');
return `$${text}`;
}
18 changes: 18 additions & 0 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,19 @@ export interface RunRecoveryResult {
events: AgentStreamEvent[];
}

export interface UsageBudgetTotals {
inputTokens: number;
outputTokens: number;
costUsd: number;
}

export interface UsageBudgetResult {
schemaVersion: 1;
design: UsageBudgetTotals;
today: UsageBudgetTotals;
week: UsageBudgetTotals;
}

const api = {
detectProvider: (key: string) =>
ipcRenderer.invoke('codesign:detect-provider', key) as Promise<string | null>,
Expand Down Expand Up @@ -471,6 +484,11 @@ const api = {
schemaVersion: 1,
cursors,
}) as Promise<RunRecoveryResult>,
usageBudget: (designId: string) =>
ipcRenderer.invoke('codesign:v1:usage-budget', {
schemaVersion: 1,
designId,
}) as Promise<UsageBudgetResult>,
generationStatus: () =>
ipcRenderer.invoke('codesign:v1:generation-status') as Promise<GenerationStatusResult>,
sendActiveMessage: (payload: ActiveRunMessageInputV1) =>
Expand Down
Loading
Loading