Show cumulative session cost (#1163)

This commit is contained in:
Mohamed Boudra
2026-05-25 12:13:37 +08:00
committed by GitHub
parent 7fbbb44ad6
commit 5755ca77f8
4 changed files with 138 additions and 16 deletions
+14 -3
View File
@@ -177,6 +177,7 @@ function buildAgentStateSelector(serverId: string, agentId: string) {
status: agent?.status ?? null, status: agent?.status ?? null,
contextWindowMaxTokens: agent?.lastUsage?.contextWindowMaxTokens ?? null, contextWindowMaxTokens: agent?.lastUsage?.contextWindowMaxTokens ?? null,
contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null, contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null,
totalCostUsd: agent?.lastUsage?.totalCostUsd ?? null,
}; };
}; };
} }
@@ -184,10 +185,15 @@ function buildAgentStateSelector(serverId: string, agentId: string) {
function renderContextWindowMeterSlot( function renderContextWindowMeterSlot(
contextWindowMaxTokens: number | null, contextWindowMaxTokens: number | null,
contextWindowUsedTokens: number | null, contextWindowUsedTokens: number | null,
totalCostUsd: number | null,
): ReactElement { ): ReactElement {
const meter = const meter =
contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? ( contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? (
<ContextWindowMeter maxTokens={contextWindowMaxTokens} usedTokens={contextWindowUsedTokens} /> <ContextWindowMeter
maxTokens={contextWindowMaxTokens}
usedTokens={contextWindowUsedTokens}
totalCostUsd={totalCostUsd}
/>
) : null; ) : null;
return <View style={styles.contextWindowMeterSlot}>{meter}</View>; return <View style={styles.contextWindowMeterSlot}>{meter}</View>;
} }
@@ -1419,8 +1425,13 @@ export function Composer({
); );
const beforeVoiceContent = useMemo( const beforeVoiceContent = useMemo(
() => renderContextWindowMeterSlot(contextWindowMaxTokens, contextWindowUsedTokens), () =>
[contextWindowMaxTokens, contextWindowUsedTokens], renderContextWindowMeterSlot(
contextWindowMaxTokens,
contextWindowUsedTokens,
agentState.totalCostUsd,
),
[contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd],
); );
const githubSearchQueryTrimmed = githubSearchQuery.trim(); const githubSearchQueryTrimmed = githubSearchQuery.trim();
@@ -6,6 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
interface ContextWindowMeterProps { interface ContextWindowMeterProps {
maxTokens: number; maxTokens: number;
usedTokens: number; usedTokens: number;
totalCostUsd?: number | null;
} }
const SVG_SIZE = 16; const SVG_SIZE = 16;
@@ -43,6 +44,16 @@ function formatTokenCount(value: number): string {
return Math.round(value).toString(); 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( function getMeterColors(
percentage: number, percentage: number,
theme: ReturnType<typeof useUnistyles>["theme"], theme: ReturnType<typeof useUnistyles>["theme"],
@@ -57,7 +68,11 @@ function getMeterColors(
return { progress: theme.colors.foregroundMuted, track }; return { progress: theme.colors.foregroundMuted, track };
} }
export function ContextWindowMeter({ maxTokens, usedTokens }: ContextWindowMeterProps) { export function ContextWindowMeter({
maxTokens,
usedTokens,
totalCostUsd,
}: ContextWindowMeterProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const percentage = getUsagePercentage(maxTokens, usedTokens); const percentage = getUsagePercentage(maxTokens, usedTokens);
@@ -69,6 +84,8 @@ export function ContextWindowMeter({ maxTokens, usedTokens }: ContextWindowMeter
const roundedPercentage = Math.round(percentage); const roundedPercentage = Math.round(percentage);
const dashOffset = CIRCUMFERENCE - (clampedPercentage / 100) * CIRCUMFERENCE; const dashOffset = CIRCUMFERENCE - (clampedPercentage / 100) * CIRCUMFERENCE;
const colors = getMeterColors(clampedPercentage, theme); const colors = getMeterColors(clampedPercentage, theme);
const formattedSessionCost =
typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null;
return ( return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile> <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
@@ -115,6 +132,9 @@ export function ContextWindowMeter({ maxTokens, usedTokens }: ContextWindowMeter
<Text <Text
style={styles.tooltipDetail} style={styles.tooltipDetail}
>{`${formatTokenCount(usedTokens)} / ${formatTokenCount(maxTokens)} tokens`}</Text> >{`${formatTokenCount(usedTokens)} / ${formatTokenCount(maxTokens)} tokens`}</Text>
{formattedSessionCost ? (
<Text style={styles.tooltipDetail}>{`Session cost ${formattedSessionCost}`}</Text>
) : null}
</View> </View>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -424,6 +424,16 @@ function readPositiveFiniteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 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 { function buildOpenCodeModelLookupKey(providerId: string, modelId: string): string {
return `${providerId}/${modelId}`; return `${providerId}/${modelId}`;
} }
@@ -591,6 +601,7 @@ function mergeOpenCodeStepFinishUsage(
}; };
}; };
}, },
options: { totalCostUsd?: number } = {},
): void { ): void {
const inputTokens = readPositiveFiniteNumber(part.tokens?.input); const inputTokens = readPositiveFiniteNumber(part.tokens?.input);
const outputTokens = readPositiveFiniteNumber(part.tokens?.output); const outputTokens = readPositiveFiniteNumber(part.tokens?.output);
@@ -605,20 +616,14 @@ function mergeOpenCodeStepFinishUsage(
(cacheWriteTokens ?? 0); (cacheWriteTokens ?? 0);
const cost = readPositiveFiniteNumber(part.cost); const cost = readPositiveFiniteNumber(part.cost);
if (inputTokens !== undefined) { assignUsageNumber(usage, "inputTokens", inputTokens);
usage.inputTokens = inputTokens; assignUsageNumber(usage, "cachedInputTokens", cacheReadTokens);
} assignUsageNumber(usage, "outputTokens", outputTokens);
if (cacheReadTokens !== undefined) {
usage.cachedInputTokens = cacheReadTokens;
}
if (outputTokens !== undefined) {
usage.outputTokens = outputTokens;
}
if (totalTokens > 0) { if (totalTokens > 0) {
usage.contextWindowUsedTokens = totalTokens; usage.contextWindowUsedTokens = totalTokens;
} }
if (cost !== undefined) { 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; cwd?: string;
messageRoles: Map<string, OpenCodeMessageRole>; messageRoles: Map<string, OpenCodeMessageRole>;
accumulatedUsage: AgentUsage; accumulatedUsage: AgentUsage;
sessionTotalCostUsd?: number;
streamedPartKeys: Set<string>; streamedPartKeys: Set<string>;
emittedStructuredMessageIds: Set<string>; emittedStructuredMessageIds: Set<string>;
/** Tracks the type of each part by ID, learned from message.part.updated events. */ /** Tracks the type of each part by ID, learned from message.part.updated events. */
@@ -1932,7 +1938,13 @@ function appendOpenCodeSessionCreatedOrUpdated(
state: OpenCodeEventTranslationState, state: OpenCodeEventTranslationState,
events: AgentStreamEvent[], events: AgentStreamEvent[],
): void { ): void {
const info = readOpenCodeRecord(event.properties.info);
if (event.properties.info.id === state.sessionId) { 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({ events.push({
type: "thread_started", type: "thread_started",
sessionId: state.sessionId, sessionId: state.sessionId,
@@ -1941,7 +1953,6 @@ function appendOpenCodeSessionCreatedOrUpdated(
return; return;
} }
const info = readOpenCodeRecord(event.properties.info);
const parentSessionId = readNonEmptyString(info?.parentID) ?? readNonEmptyString(info?.parentId); const parentSessionId = readNonEmptyString(info?.parentID) ?? readNonEmptyString(info?.parentId);
if (parentSessionId === state.sessionId) { if (parentSessionId === state.sessionId) {
appendOpenCodeSubAgentChildSessionLinked(event.properties.info.id, state, events); appendOpenCodeSubAgentChildSessionLinked(event.properties.info.id, state, events);
@@ -2022,7 +2033,13 @@ function appendOpenCodeMessagePartUpdated(
return; return;
} }
if (part.type === "step-finish") { 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)) { if (hasNormalizedOpenCodeUsage(state.accumulatedUsage)) {
events.push({ events.push({
type: "usage_updated", type: "usage_updated",
@@ -2300,6 +2317,7 @@ class OpenCodeAgentSession implements AgentSession {
private abortController: AbortController | null = null; private abortController: AbortController | null = null;
private pendingAbortPromise: Promise<void> | null = null; private pendingAbortPromise: Promise<void> | null = null;
private accumulatedUsage: AgentUsage = {}; private accumulatedUsage: AgentUsage = {};
private sessionTotalCostUsd: number | undefined;
private mcpConfigured = false; private mcpConfigured = false;
private mcpSetupPromise: Promise<void> | null = null; private mcpSetupPromise: Promise<void> | null = null;
/** Tracks the role of each message by ID to distinguish user from assistant messages */ /** 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, cwd: this.config.cwd,
messageRoles: this.messageRoles, messageRoles: this.messageRoles,
accumulatedUsage: this.accumulatedUsage, accumulatedUsage: this.accumulatedUsage,
sessionTotalCostUsd: this.sessionTotalCostUsd,
streamedPartKeys: this.streamedPartKeys, streamedPartKeys: this.streamedPartKeys,
emittedStructuredMessageIds: this.emittedStructuredMessageIds, emittedStructuredMessageIds: this.emittedStructuredMessageIds,
partTypes: this.partTypes, partTypes: this.partTypes,
@@ -3214,6 +3233,12 @@ class OpenCodeAgentSession implements AgentSession {
}); });
const events: AgentStreamEvent[] = []; const events: AgentStreamEvent[] = [];
if (typeof this.accumulatedUsage.totalCostUsd === "number") {
this.sessionTotalCostUsd = maxFiniteNumber(
this.sessionTotalCostUsd,
this.accumulatedUsage.totalCostUsd,
);
}
for (const translatedEvent of translated) { for (const translatedEvent of translated) {
if (translatedEvent.type === "permission_requested") { if (translatedEvent.type === "permission_requested") {
@@ -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<typeof translateOpenCodeEvent>[0],
state,
);
expect(state.sessionTotalCostUsd).toBe(1.25);
expect(state.accumulatedUsage.totalCostUsd).toBe(1.25);
});
it("emits normalized todo timeline items from todo.updated", () => { it("emits normalized todo timeline items from todo.updated", () => {
const state = createState(); const state = createState();