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();