diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash
index 5b4e50d81..fd8f7984d 100644
--- a/nix/npm-deps.hash
+++ b/nix/npm-deps.hash
@@ -1 +1 @@
-sha256-H18zIrryRN+tfQA68vfbWHNMN1mOxhO4PAWcZ4yuNSc=
+sha256-s1HwJgifRYWfnIVit2JVrJmFSyu8lL8TpX67I/33dQQ=
diff --git a/packages/app/src/components/composer.tsx b/packages/app/src/components/composer.tsx
index 1212e201c..c52330b36 100644
--- a/packages/app/src/components/composer.tsx
+++ b/packages/app/src/components/composer.tsx
@@ -177,6 +177,7 @@ function buildAgentStateSelector(serverId: string, agentId: string) {
status: agent?.status ?? null,
contextWindowMaxTokens: agent?.lastUsage?.contextWindowMaxTokens ?? null,
contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null,
+ totalCostUsd: agent?.lastUsage?.totalCostUsd ?? null,
};
};
}
@@ -184,10 +185,15 @@ function buildAgentStateSelector(serverId: string, agentId: string) {
function renderContextWindowMeterSlot(
contextWindowMaxTokens: number | null,
contextWindowUsedTokens: number | null,
+ totalCostUsd: number | null,
): ReactElement {
const meter =
contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? (
-
+
) : null;
return {meter};
}
@@ -1419,8 +1425,13 @@ export function Composer({
);
const beforeVoiceContent = useMemo(
- () => renderContextWindowMeterSlot(contextWindowMaxTokens, contextWindowUsedTokens),
- [contextWindowMaxTokens, contextWindowUsedTokens],
+ () =>
+ renderContextWindowMeterSlot(
+ contextWindowMaxTokens,
+ contextWindowUsedTokens,
+ agentState.totalCostUsd,
+ ),
+ [contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd],
);
const githubSearchQueryTrimmed = githubSearchQuery.trim();
diff --git a/packages/app/src/components/context-window-meter.tsx b/packages/app/src/components/context-window-meter.tsx
index 9e63f70a2..0b8da5ef5 100644
--- a/packages/app/src/components/context-window-meter.tsx
+++ b/packages/app/src/components/context-window-meter.tsx
@@ -6,6 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
interface ContextWindowMeterProps {
maxTokens: number;
usedTokens: number;
+ totalCostUsd?: number | null;
}
const SVG_SIZE = 16;
@@ -43,6 +44,16 @@ function formatTokenCount(value: number): string {
return Math.round(value).toString();
}
+function formatSessionCost(value: number): string | null {
+ if (!Number.isFinite(value) || value <= 0) {
+ return null;
+ }
+ if (value < 0.01) {
+ return `$${value.toFixed(4)}`;
+ }
+ return `$${value.toFixed(2)}`;
+}
+
function getMeterColors(
percentage: number,
theme: ReturnType["theme"],
@@ -57,7 +68,11 @@ function getMeterColors(
return { progress: theme.colors.foregroundMuted, track };
}
-export function ContextWindowMeter({ maxTokens, usedTokens }: ContextWindowMeterProps) {
+export function ContextWindowMeter({
+ maxTokens,
+ usedTokens,
+ totalCostUsd,
+}: ContextWindowMeterProps) {
const { theme } = useUnistyles();
const percentage = getUsagePercentage(maxTokens, usedTokens);
@@ -69,6 +84,8 @@ export function ContextWindowMeter({ maxTokens, usedTokens }: ContextWindowMeter
const roundedPercentage = Math.round(percentage);
const dashOffset = CIRCUMFERENCE - (clampedPercentage / 100) * CIRCUMFERENCE;
const colors = getMeterColors(clampedPercentage, theme);
+ const formattedSessionCost =
+ typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null;
return (
@@ -115,6 +132,9 @@ export function ContextWindowMeter({ maxTokens, usedTokens }: ContextWindowMeter
{`${formatTokenCount(usedTokens)} / ${formatTokenCount(maxTokens)} tokens`}
+ {formattedSessionCost ? (
+ {`Session cost ${formattedSessionCost}`}
+ ) : null}
diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts
index 436fcb14e..ca85cba57 100644
--- a/packages/server/src/server/agent/providers/opencode-agent.ts
+++ b/packages/server/src/server/agent/providers/opencode-agent.ts
@@ -424,6 +424,16 @@ function readPositiveFiniteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
}
+function maxFiniteNumber(left: number | undefined, right: number): number {
+ return left === undefined ? right : Math.max(left, right);
+}
+
+function assignUsageNumber(usage: AgentUsage, key: keyof AgentUsage, value: number | undefined) {
+ if (value !== undefined) {
+ usage[key] = value;
+ }
+}
+
function buildOpenCodeModelLookupKey(providerId: string, modelId: string): string {
return `${providerId}/${modelId}`;
}
@@ -591,6 +601,7 @@ function mergeOpenCodeStepFinishUsage(
};
};
},
+ options: { totalCostUsd?: number } = {},
): void {
const inputTokens = readPositiveFiniteNumber(part.tokens?.input);
const outputTokens = readPositiveFiniteNumber(part.tokens?.output);
@@ -605,20 +616,14 @@ function mergeOpenCodeStepFinishUsage(
(cacheWriteTokens ?? 0);
const cost = readPositiveFiniteNumber(part.cost);
- if (inputTokens !== undefined) {
- usage.inputTokens = inputTokens;
- }
- if (cacheReadTokens !== undefined) {
- usage.cachedInputTokens = cacheReadTokens;
- }
- if (outputTokens !== undefined) {
- usage.outputTokens = outputTokens;
- }
+ assignUsageNumber(usage, "inputTokens", inputTokens);
+ assignUsageNumber(usage, "cachedInputTokens", cacheReadTokens);
+ assignUsageNumber(usage, "outputTokens", outputTokens);
if (totalTokens > 0) {
usage.contextWindowUsedTokens = totalTokens;
}
if (cost !== undefined) {
- usage.totalCostUsd = (usage.totalCostUsd ?? 0) + cost;
+ usage.totalCostUsd = options.totalCostUsd ?? (usage.totalCostUsd ?? 0) + cost;
}
}
@@ -1348,6 +1353,7 @@ export interface OpenCodeEventTranslationState {
cwd?: string;
messageRoles: Map;
accumulatedUsage: AgentUsage;
+ sessionTotalCostUsd?: number;
streamedPartKeys: Set;
emittedStructuredMessageIds: Set;
/** Tracks the type of each part by ID, learned from message.part.updated events. */
@@ -1932,7 +1938,13 @@ function appendOpenCodeSessionCreatedOrUpdated(
state: OpenCodeEventTranslationState,
events: AgentStreamEvent[],
): void {
+ const info = readOpenCodeRecord(event.properties.info);
if (event.properties.info.id === state.sessionId) {
+ const sessionCost = readPositiveFiniteNumber(info?.cost);
+ if (sessionCost !== undefined) {
+ state.sessionTotalCostUsd = maxFiniteNumber(state.sessionTotalCostUsd, sessionCost);
+ state.accumulatedUsage.totalCostUsd = state.sessionTotalCostUsd;
+ }
events.push({
type: "thread_started",
sessionId: state.sessionId,
@@ -1941,7 +1953,6 @@ function appendOpenCodeSessionCreatedOrUpdated(
return;
}
- const info = readOpenCodeRecord(event.properties.info);
const parentSessionId = readNonEmptyString(info?.parentID) ?? readNonEmptyString(info?.parentId);
if (parentSessionId === state.sessionId) {
appendOpenCodeSubAgentChildSessionLinked(event.properties.info.id, state, events);
@@ -2022,7 +2033,13 @@ function appendOpenCodeMessagePartUpdated(
return;
}
if (part.type === "step-finish") {
- mergeOpenCodeStepFinishUsage(state.accumulatedUsage, part);
+ const stepCost = readPositiveFiniteNumber(part.cost);
+ if (stepCost !== undefined) {
+ state.sessionTotalCostUsd = (state.sessionTotalCostUsd ?? 0) + stepCost;
+ }
+ mergeOpenCodeStepFinishUsage(state.accumulatedUsage, part, {
+ totalCostUsd: state.sessionTotalCostUsd,
+ });
if (hasNormalizedOpenCodeUsage(state.accumulatedUsage)) {
events.push({
type: "usage_updated",
@@ -2300,6 +2317,7 @@ class OpenCodeAgentSession implements AgentSession {
private abortController: AbortController | null = null;
private pendingAbortPromise: Promise | null = null;
private accumulatedUsage: AgentUsage = {};
+ private sessionTotalCostUsd: number | undefined;
private mcpConfigured = false;
private mcpSetupPromise: Promise | null = null;
/** Tracks the role of each message by ID to distinguish user from assistant messages */
@@ -3198,6 +3216,7 @@ class OpenCodeAgentSession implements AgentSession {
cwd: this.config.cwd,
messageRoles: this.messageRoles,
accumulatedUsage: this.accumulatedUsage,
+ sessionTotalCostUsd: this.sessionTotalCostUsd,
streamedPartKeys: this.streamedPartKeys,
emittedStructuredMessageIds: this.emittedStructuredMessageIds,
partTypes: this.partTypes,
@@ -3214,6 +3233,12 @@ class OpenCodeAgentSession implements AgentSession {
});
const events: AgentStreamEvent[] = [];
+ if (typeof this.accumulatedUsage.totalCostUsd === "number") {
+ this.sessionTotalCostUsd = maxFiniteNumber(
+ this.sessionTotalCostUsd,
+ this.accumulatedUsage.totalCostUsd,
+ );
+ }
for (const translatedEvent of translated) {
if (translatedEvent.type === "permission_requested") {
diff --git a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts
index daae7c51a..548a40f44 100644
--- a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts
+++ b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts
@@ -418,6 +418,72 @@ describe("translateOpenCodeEvent", () => {
});
});
+ it("reports totalCostUsd as cumulative session cost across turns", () => {
+ const state = createState();
+ state.accumulatedUsage.contextWindowMaxTokens = 400_000;
+ state.sessionTotalCostUsd = 0.5;
+
+ const events = translateOpenCodeEvent(
+ {
+ type: "message.part.updated",
+ properties: {
+ part: {
+ id: "step-finish-1",
+ sessionID: "session-1",
+ messageID: "message-usage-1",
+ type: "step-finish",
+ reason: "stop",
+ cost: 0.25,
+ tokens: {
+ total: 55_000,
+ input: 30_000,
+ output: 12_000,
+ reasoning: 10_000,
+ cache: {
+ read: 2_000,
+ write: 1_000,
+ },
+ },
+ },
+ },
+ },
+ state,
+ );
+
+ expect(events).toEqual([
+ {
+ type: "usage_updated",
+ provider: "opencode",
+ usage: expect.objectContaining({
+ totalCostUsd: 0.75,
+ }),
+ },
+ ]);
+ expect(state.sessionTotalCostUsd).toBe(0.75);
+ expect(state.accumulatedUsage.totalCostUsd).toBe(0.75);
+ });
+
+ it("seeds cumulative session cost from OpenCode session updates", () => {
+ const state = createState();
+
+ translateOpenCodeEvent(
+ {
+ type: "session.updated",
+ properties: {
+ sessionID: "session-1",
+ info: {
+ id: "session-1",
+ cost: 1.25,
+ },
+ },
+ } as Parameters[0],
+ state,
+ );
+
+ expect(state.sessionTotalCostUsd).toBe(1.25);
+ expect(state.accumulatedUsage.totalCostUsd).toBe(1.25);
+ });
+
it("emits normalized todo timeline items from todo.updated", () => {
const state = createState();