diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index c6e60d3edebd..7d84d9798f2b 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -1,4 +1,4 @@ -import { Match, Show, Switch, createMemo } from "solid-js" +import { Match, Show, Switch, createMemo, createEffect } from "solid-js" import { Tooltip } from "@opencode-ai/ui/tooltip" import { ProgressCircle } from "@opencode-ai/ui/progress-circle" import { Button } from "@opencode-ai/ui/button" @@ -44,7 +44,15 @@ export function SessionContextUsage(props: SessionContextUsageProps) { }), ) - const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all)) + const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all, sync.data)) + createEffect(() => { + const missing = metrics().missing + if (missing.length > 0) { + for (const id of missing) { + sync.session.sync(id) + } + } + }) const context = createMemo(() => metrics().context) const cost = createMemo(() => { return usd().format(metrics().totalCost) @@ -92,16 +100,22 @@ export function SessionContextUsage(props: SessionContextUsageProps) { - {circle()} + +
+ {circle()} + {cost()} +
+
diff --git a/packages/app/src/components/session/session-context-metrics.test.ts b/packages/app/src/components/session/session-context-metrics.test.ts index 0e109a71bdac..b980700795dc 100644 --- a/packages/app/src/components/session/session-context-metrics.test.ts +++ b/packages/app/src/components/session/session-context-metrics.test.ts @@ -8,9 +8,11 @@ const assistant = ( cost: number, providerID = "openai", modelID = "gpt-4.1", + sessionID = "s1", ) => { return { id, + sessionID, role: "assistant", providerID, modelID, @@ -28,9 +30,10 @@ const assistant = ( } as unknown as Message } -const user = (id: string) => { +const user = (id: string, sessionID = "s1") => { return { id, + sessionID, role: "user", cost: 0, time: { created: 1 }, @@ -98,4 +101,44 @@ describe("getSessionContextMetrics", () => { expect(metrics.totalCost).toBe(0) expect(metrics.context).toBeUndefined() }) + + test("computes costs with store including sub-agents", () => { + const messages = [ + assistant("a1", { input: 10, output: 10, reasoning: 0, read: 0, write: 0 }, 0.5, "openai", "gpt-4.1", "s1"), + ] + const store = { + message: { + s1: [{ id: "a1", role: "assistant", cost: 0.5 }], + s2: [{ id: "a2", role: "assistant", cost: 1.0 }], + }, + part: { + a1: [{ type: "tool", tool: "task", state: { metadata: { sessionId: "s2" } } }], + }, + } + + const metrics = getSessionContextMetrics(messages, [], store) + + expect(metrics.totalCost).toBe(1.5) + expect(metrics.ownCost).toBe(0.5) + expect(metrics.missing).toEqual([]) + }) + + test("identifies missing child sessions in store", () => { + const messages = [ + assistant("a1", { input: 10, output: 10, reasoning: 0, read: 0, write: 0 }, 0.5, "openai", "gpt-4.1", "s1"), + ] + const store = { + message: { + s1: [{ id: "a1", role: "assistant", cost: 0.5 }], + }, + part: { + a1: [{ type: "tool", tool: "task", state: { metadata: { sessionId: "s2" } } }], + }, + } + + const metrics = getSessionContextMetrics(messages, [], store) + + expect(metrics.totalCost).toBe(0.5) + expect(metrics.missing).toEqual(["s2"]) + }) }) diff --git a/packages/app/src/components/session/session-context-metrics.ts b/packages/app/src/components/session/session-context-metrics.ts index 0789b05f1730..2846e5db6040 100644 --- a/packages/app/src/components/session/session-context-metrics.ts +++ b/packages/app/src/components/session/session-context-metrics.ts @@ -1,3 +1,4 @@ +import { costs, type CostStore } from "@opencode-ai/util/cost" import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client" type Provider = { @@ -31,6 +32,8 @@ type Context = { type Metrics = { totalCost: number + ownCost: number + missing: string[] context: Context | undefined } @@ -42,15 +45,25 @@ const lastAssistantWithTokens = (messages: Message[]) => { for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i] if (msg.role !== "assistant") continue - if (tokenTotal(msg) <= 0) continue - return msg + if (tokenTotal(msg as AssistantMessage) <= 0) continue + return msg as AssistantMessage } } -const build = (messages: Message[] = [], providers: Provider[] = []): Metrics => { - const totalCost = messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0) +const build = (messages: Message[] = [], providers: Provider[] = [], store?: CostStore): Metrics => { + const id = messages[0]?.sessionID + const result = store && id ? costs(id, store) : undefined + const message = lastAssistantWithTokens(messages) - if (!message) return { totalCost, context: undefined } + if (!message) + return { + totalCost: result + ? result.total + : messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0), + ownCost: result ? result.own : 0, + missing: result ? result.missing : [], + context: undefined, + } const provider = providers.find((item) => item.id === message.providerID) const model = provider?.models[message.modelID] @@ -58,7 +71,11 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Metrics => const total = tokenTotal(message) return { - totalCost, + totalCost: result + ? result.total + : messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0), + ownCost: result ? result.own : 0, + missing: result ? result.missing : [], context: { message, provider, @@ -77,6 +94,6 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Metrics => } } -export function getSessionContextMetrics(messages: Message[] = [], providers: Provider[] = []) { - return build(messages, providers) +export function getSessionContextMetrics(messages: Message[] = [], providers: Provider[] = [], store?: CostStore) { + return build(messages, providers, store) } diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index 81220b3adb2f..99e77415368b 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -104,7 +104,15 @@ export function SessionContextTab(props: SessionContextTabProps) { }), ) - const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all)) + const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all, sync.data)) + createEffect(() => { + const missing = metrics().missing + if (missing.length > 0) { + for (const id of missing) { + sync.session.sync(id) + } + } + }) const ctx = createMemo(() => metrics().context) const formatter = createMemo(() => createSessionContextFormatter(language.locale())) diff --git a/packages/app/src/custom-elements.d.ts b/packages/app/src/custom-elements.d.ts index e4ea0d6cebda..86cb284693e6 120000 --- a/packages/app/src/custom-elements.d.ts +++ b/packages/app/src/custom-elements.d.ts @@ -1 +1 @@ -../../ui/src/custom-elements.d.ts \ No newline at end of file +/// diff --git a/packages/enterprise/src/custom-elements.d.ts b/packages/enterprise/src/custom-elements.d.ts index e4ea0d6cebda..86cb284693e6 120000 --- a/packages/enterprise/src/custom-elements.d.ts +++ b/packages/enterprise/src/custom-elements.d.ts @@ -1 +1 @@ -../../ui/src/custom-elements.d.ts \ No newline at end of file +/// diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx index 0c5ea9a85723..e605c5b98673 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx @@ -1,6 +1,7 @@ import { type Accessor, createMemo, createSignal, Match, Show, Switch } from "solid-js" import { useRouteData } from "@tui/context/route" import { useSync } from "@tui/context/sync" +import { costs } from "@opencode-ai/util/cost" import { pipe, sumBy } from "remeda" import { useTheme } from "@tui/context/theme" import { SplitBorder } from "@tui/component/border" @@ -36,14 +37,11 @@ export function Header() { const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) const cost = createMemo(() => { - const total = pipe( - messages(), - sumBy((x) => (x.role === "assistant" ? x.cost : 0)), - ) + const result = costs(route.sessionID, sync.data) return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", - }).format(total) + }).format(result.total) }) const context = createMemo(() => { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index e83b9abe98ae..06b9d40d4151 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -15,6 +15,7 @@ import { Dynamic } from "solid-js/web" import path from "path" import { useRoute, useRouteData } from "@tui/context/route" import { useSync } from "@tui/context/sync" +import { costs } from "@opencode-ai/util/cost" import { SplitBorder } from "@tui/component/border" import { Spinner } from "@tui/component/spinner" import { selectedForeground, useTheme } from "@tui/context/theme" @@ -191,6 +192,15 @@ export function Session() { }) }) + createEffect(() => { + const result = costs(route.sessionID, sync.data) + if (result.missing.length > 0) { + for (const id of result.missing) { + sync.session.sync(id) + } + } + }) + const toast = useToast() const sdk = useSDK() @@ -1879,6 +1889,18 @@ function Task(props: ToolProps) { {props.input.description} ({tools().length} toolcalls) + + {(sessionId) => { + const result = createMemo(() => costs(sessionId(), sync.data)) + const total = createMemo(() => + new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(result().total), + ) + return ({total}) + }} + {(item) => { const title = item().state.status === "completed" ? (item().state as any).title : "" diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 4ffe91558ed7..c005bfcf310c 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -1,4 +1,5 @@ import { useSync } from "@tui/context/sync" +import { costs } from "@opencode-ai/util/cost" import { createMemo, For, Show, Switch, Match } from "solid-js" import { createStore } from "solid-js/store" import { useTheme } from "../../context/theme" @@ -41,11 +42,11 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { ) const cost = createMemo(() => { - const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0) + const result = costs(props.sessionID, sync.data) return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", - }).format(total) + }).format(result.total) }) const context = createMemo(() => { diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 255f4dd46010..446408d11418 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -581,22 +581,30 @@ export namespace Session { export const updateMessage = fn(MessageV2.Info, async (msg) => { const time_created = msg.role === "user" ? msg.time.created : msg.time.created const { id, sessionID, ...data } = msg - Database.use((db) => { - db.insert(MessageTable) - .values({ - id, - session_id: sessionID, - time_created, - data, - }) - .onConflictDoUpdate({ target: MessageTable.id, set: { data } }) - .run() - Database.effect(() => - Bus.publish(MessageV2.Event.Updated, { - info: msg, - }), - ) - }) + try { + Database.use((db) => { + db.insert(MessageTable) + .values({ + id, + session_id: sessionID, + time_created, + data, + }) + .onConflictDoUpdate({ target: MessageTable.id, set: { data } }) + .run() + Database.effect(() => + Bus.publish(MessageV2.Event.Updated, { + info: msg, + }), + ) + }) + } catch (e) { + if (e instanceof Error && e.message.includes("FOREIGN KEY constraint failed")) { + log.warn("ignoring message update for missing session", { sessionID, messageID: id }) + return msg + } + throw e + } return msg }) @@ -646,23 +654,31 @@ export namespace Session { export const updatePart = fn(UpdatePartInput, async (part) => { const { id, messageID, sessionID, ...data } = part const time = Date.now() - Database.use((db) => { - db.insert(PartTable) - .values({ - id, - message_id: messageID, - session_id: sessionID, - time_created: time, - data, - }) - .onConflictDoUpdate({ target: PartTable.id, set: { data } }) - .run() - Database.effect(() => - Bus.publish(MessageV2.Event.PartUpdated, { - part, - }), - ) - }) + try { + Database.use((db) => { + db.insert(PartTable) + .values({ + id, + message_id: messageID, + session_id: sessionID, + time_created: time, + data, + }) + .onConflictDoUpdate({ target: PartTable.id, set: { data } }) + .run() + Database.effect(() => + Bus.publish(MessageV2.Event.PartUpdated, { + part, + }), + ) + }) + } catch (e) { + if (e instanceof Error && e.message.includes("FOREIGN KEY constraint failed")) { + log.warn("ignoring part update for missing message/session", { sessionID, messageID, partID: id }) + return part + } + throw e + } return part }) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 3f61b3186d30..f4862b48accb 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -45,6 +45,7 @@ import { ImagePreview } from "./image-preview" import { findLast } from "@opencode-ai/util/array" import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path" import { checksum } from "@opencode-ai/util/encode" +import { costs } from "@opencode-ai/util/cost" import { Tooltip } from "./tooltip" import { IconButton } from "./icon-button" import { createAutoScroll } from "../hooks" @@ -921,6 +922,52 @@ ToolRegistry.register({ }, 50) } + const stats = createMemo(() => { + const sid = childSessionId() + if (!sid) return undefined + const messages = data.store.message[sid] + if (!messages || messages.length === 0) return undefined + + const last = findLast( + messages, + (m) => + m.role === "assistant" && + (m as AssistantMessage).tokens.input + + (m as AssistantMessage).tokens.output + + (m as AssistantMessage).tokens.reasoning > + 0, + ) as AssistantMessage | undefined + if (!last) return undefined + + const tokens = + last.tokens.input + + last.tokens.output + + last.tokens.reasoning + + last.tokens.cache.read + + last.tokens.cache.write + const limit = (data.store as any).provider?.all + ? (() => { + const providers = (data.store as any).provider.all as { + id: string + models: Record + }[] + const p = providers.find((item) => item.id === last.providerID) + return p?.models[last.modelID]?.limit.context + })() + : undefined + const usage = limit ? Math.round((tokens / limit) * 100) : undefined + const result = costs(sid, data.store) + return { tokens, usage, total: result.total, missing: result.missing } + }) + createEffect(() => { + const missing = stats()?.missing + if (missing && missing.length > 0) { + for (const id of missing) { + data.syncSession?.(id) + } + } + }) + const trigger = () => (
@@ -941,6 +988,15 @@ ToolRegistry.register({ + + {(s) => ( + + {s().tokens.toLocaleString() + " tokens"} + {s().usage !== undefined ? ` (${s().usage}%)` : ""} + {` ($${s().total.toFixed(2)})`} + + )} +
) diff --git a/packages/util/src/cost.test.ts b/packages/util/src/cost.test.ts new file mode 100644 index 000000000000..79b426864512 --- /dev/null +++ b/packages/util/src/cost.test.ts @@ -0,0 +1,111 @@ +import { expect, test } from "bun:test" +import { costs } from "./cost" + +test("no sub-agents", () => { + const store = { + message: { + s1: [ + { id: "m1", role: "user", cost: 0 }, + { id: "m2", role: "assistant", cost: 0.5 }, + ], + }, + part: {}, + } + const result = costs("s1", store as any) + expect(result.own).toBe(0.5) + expect(result.total).toBe(0.5) + expect(result.missing).toEqual([]) +}) + +test("one loaded sub-agent", () => { + const store = { + message: { + s1: [{ id: "m1", role: "assistant", cost: 0.5 }], + s2: [{ id: "m2", role: "assistant", cost: 1.0 }], + }, + part: { + m1: [{ type: "tool", tool: "task", state: { metadata: { sessionId: "s2" } } }], + }, + } + const result = costs("s1", store as any) + expect(result.own).toBe(0.5) + expect(result.total).toBe(1.5) + expect(result.missing).toEqual([]) +}) + +test("nested sub-agents", () => { + const store = { + message: { + s1: [{ id: "m1", role: "assistant", cost: 0.5 }], + s2: [{ id: "m2", role: "assistant", cost: 1.0 }], + s3: [{ id: "m3", role: "assistant", cost: 2.0 }], + }, + part: { + m1: [{ type: "tool", tool: "task", state: { metadata: { sessionId: "s2" } } }], + m2: [{ type: "tool", tool: "task", state: { metadata: { sessionId: "s3" } } }], + }, + } + const result = costs("s1", store as any) + expect(result.own).toBe(0.5) + expect(result.total).toBe(3.5) + expect(result.missing).toEqual([]) +}) + +test("cycle detection", () => { + const store = { + message: { + s1: [{ id: "m1", role: "assistant", cost: 0.5 }], + s2: [{ id: "m2", role: "assistant", cost: 1.0 }], + }, + part: { + m1: [{ type: "tool", tool: "task", state: { metadata: { sessionId: "s2" } } }], + m2: [{ type: "tool", tool: "task", state: { metadata: { sessionId: "s1" } } }], + }, + } + const result = costs("s1", store as any) + expect(result.own).toBe(0.5) + expect(result.total).toBe(1.5) + expect(result.missing).toEqual([]) +}) + +test("missing child session", () => { + const store = { + message: { + s1: [{ id: "m1", role: "assistant", cost: 0.5 }], + }, + part: { + m1: [{ type: "tool", tool: "task", state: { metadata: { sessionId: "s2" } } }], + }, + } + const result = costs("s1", store as any) + expect(result.own).toBe(0.5) + expect(result.total).toBe(0.5) + expect(result.missing).toEqual(["s2"]) +}) + +test("empty or undefined messages", () => { + const store = { + message: {}, + part: {}, + } + const result = costs("s1", store as any) + expect(result.own).toBe(0) + expect(result.total).toBe(0) + expect(result.missing).toEqual(["s1"]) +}) + +test("only assistant messages counted", () => { + const store = { + message: { + s1: [ + { id: "m1", role: "user", cost: 100 }, + { id: "m2", role: "assistant", cost: 0.5 }, + { id: "m3", role: "system", cost: 100 }, + ], + }, + part: {}, + } + const result = costs("s1", store as any) + expect(result.own).toBe(0.5) + expect(result.total).toBe(0.5) +}) diff --git a/packages/util/src/cost.ts b/packages/util/src/cost.ts new file mode 100644 index 000000000000..dfb40de6dc72 --- /dev/null +++ b/packages/util/src/cost.ts @@ -0,0 +1,52 @@ +export interface CostMessage { + id: string + role: string + cost?: number +} + +export interface CostPart { + type: string + tool?: string + state?: Record +} + +export interface CostStore { + message: Record + part: Record +} + +export function costs(id: string, store: CostStore) { + const missing: string[] = [] + const visited = new Set() + + const walk = (sid: string): number => { + if (visited.has(sid)) return 0 + visited.add(sid) + + const messages = store.message[sid] + if (!messages) { + missing.push(sid) + return 0 + } + + return messages + .filter((msg) => msg.role === "assistant") + .reduce((sum, msg) => { + const parts = store.part[msg.id] ?? [] + const subCosts = parts + .filter((part) => part.type === "tool" && part.tool === "task") + .map((part) => (part.state?.metadata as Record | undefined)?.sessionId) + .filter((child): child is string => typeof child === "string") + .reduce((acc, child) => acc + walk(child), 0) + return sum + (msg.cost ?? 0) + subCosts + }, 0) + } + + const own = (store.message[id] ?? []) + .filter((msg) => msg.role === "assistant") + .reduce((sum, msg) => sum + (msg.cost ?? 0), 0) + + const total = walk(id) + + return { own, total, missing } +}