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

Show recorded token and USD totals for the current design, including today and the local week, beside the existing per-run token count.
29 changes: 28 additions & 1 deletion apps/desktop/src/main/ipc/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import {
deriveResourceStateFromChatRows,
GeneratePayloadV1,
ListActiveMessagesInputV1,
summarizeUsageBudget,
type UsageTotals,
} from '@open-codesign/shared';
import { computeFingerprint } from '@open-codesign/shared/fingerprint';
import type { BrowserWindow as ElectronBrowserWindow, WebContents } from 'electron';
Expand Down Expand Up @@ -58,7 +60,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 @@ -510,6 +511,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
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
49 changes: 48 additions & 1 deletion apps/desktop/src/renderer/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { getCurrentLocale, useT, useTranslation } from '@open-codesign/i18n';
import type { LocalInputFile, OnboardingState } from '@open-codesign/shared';
import {
formatUsageCost,
formatUsageTokens,
type LocalInputFile,
type OnboardingState,
} from '@open-codesign/shared';
import { FolderOpen, Link2, Paperclip, X } from 'lucide-react';
import { useCallback, useEffect, useRef } from 'react';
import { useCodesignStore } from '../store';
Expand All @@ -10,6 +15,7 @@ import { CommentChipBar } from './chat/CommentChipBar';
import { EmptyState } from './chat/EmptyState';
import { PromptInput, type PromptInputHandle } from './chat/PromptInput';
import { ModelSwitcher } from './ModelSwitcher';
import { useDesignUsageBudget } from './usage-budget-refresh';

export interface SidebarProps {
prefillPrompt: { id: number; text: string } | null;
Expand Down Expand Up @@ -90,6 +96,19 @@ export function Sidebar({ prefillPrompt }: SidebarProps) {
const pickDesignSystemDirectory = useCodesignStore((s) => s.pickDesignSystemDirectory);
const clearDesignSystem = useCodesignStore((s) => s.clearDesignSystem);
const lastUsage = useCodesignStore((s) => s.lastUsage);
const pushToast = useCodesignStore((s) => s.pushToast);
const reportUsageError = useCallback(
(error: unknown) => {
const description = error instanceof Error ? error.message : String(error);
pushToast({
variant: 'error',
title: t('sidebar.chat.usageLoadFailed'),
description,
});
},
[pushToast, t],
);
const usageBudget = useDesignUsageBudget(reportUsageError);

const chatMessages = useCodesignStore((s) => s.chatMessages);
const chatLoaded = useCodesignStore((s) => s.chatLoaded);
Expand Down Expand Up @@ -304,6 +323,34 @@ export function Sidebar({ prefillPrompt }: SidebarProps) {
</span>
) : null}
</div>
{usageBudget ? (
<p
aria-label={t('sidebar.chat.usageLabel')}
className="break-words px-[2px] text-[var(--text-sm)] leading-5 text-[var(--color-text-muted)] tabular-nums"
style={{ fontFamily: 'var(--font-mono)' }}
>
<span className="block">
{t('sidebar.chat.usageDesign', {
tokens: formatUsageTokens(
usageBudget.design.inputTokens + usageBudget.design.outputTokens,
),
cost: formatUsageCost(usageBudget.design.costUsd),
})}
</span>
<span className="block">
{t('sidebar.chat.usageWindow', {
todayTokens: formatUsageTokens(
usageBudget.today.inputTokens + usageBudget.today.outputTokens,
),
todayCost: formatUsageCost(usageBudget.today.costUsd),
weekTokens: formatUsageTokens(
usageBudget.week.inputTokens + usageBudget.week.outputTokens,
),
weekCost: formatUsageCost(usageBudget.week.costUsd),
})}
</span>
</p>
) : null}
</div>
</aside>
);
Expand Down
Loading
Loading