Merge remote-tracking branch 'origin/main'
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
sha256-H18zIrryRN+tfQA68vfbWHNMN1mOxhO4PAWcZ4yuNSc=
|
||||
sha256-s1HwJgifRYWfnIVit2JVrJmFSyu8lL8TpX67I/33dQQ=
|
||||
|
||||
@@ -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 ? (
|
||||
<ContextWindowMeter maxTokens={contextWindowMaxTokens} usedTokens={contextWindowUsedTokens} />
|
||||
<ContextWindowMeter
|
||||
maxTokens={contextWindowMaxTokens}
|
||||
usedTokens={contextWindowUsedTokens}
|
||||
totalCostUsd={totalCostUsd}
|
||||
/>
|
||||
) : null;
|
||||
return <View style={styles.contextWindowMeterSlot}>{meter}</View>;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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<typeof useUnistyles>["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 (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
|
||||
@@ -115,6 +132,9 @@ export function ContextWindowMeter({ maxTokens, usedTokens }: ContextWindowMeter
|
||||
<Text
|
||||
style={styles.tooltipDetail}
|
||||
>{`${formatTokenCount(usedTokens)} / ${formatTokenCount(maxTokens)} tokens`}</Text>
|
||||
{formattedSessionCost ? (
|
||||
<Text style={styles.tooltipDetail}>{`Session cost ${formattedSessionCost}`}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -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<string, OpenCodeMessageRole>;
|
||||
accumulatedUsage: AgentUsage;
|
||||
sessionTotalCostUsd?: number;
|
||||
streamedPartKeys: Set<string>;
|
||||
emittedStructuredMessageIds: Set<string>;
|
||||
/** 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<void> | null = null;
|
||||
private accumulatedUsage: AgentUsage = {};
|
||||
private sessionTotalCostUsd: number | undefined;
|
||||
private mcpConfigured = false;
|
||||
private mcpSetupPromise: Promise<void> | 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") {
|
||||
|
||||
@@ -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", () => {
|
||||
const state = createState();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user