Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/opencode/bunfig.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
preload = ["@opentui/solid/preload"]

[test]
preload = ["./test/preload.ts"]
preload = ["@opentui/solid/preload", "./test/preload.ts"]
# timeout is not actually parsed from bunfig.toml (see src/bunfig.zig in oven-sh/bun)
# using --timeout in package.json scripts instead
# https://github.com/oven-sh/bun/issues/7789
14 changes: 14 additions & 0 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { LocalProvider, useLocal } from "@tui/context/local"
import { DialogModel, useConnected } from "@tui/component/dialog-model"
import { DialogMcp } from "@tui/component/dialog-mcp"
import { DialogStatus } from "@tui/component/dialog-status"
import { DialogCost } from "@tui/component/dialog-cost"
import { DialogThemeList } from "@tui/component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
Expand Down Expand Up @@ -542,6 +543,19 @@ function App() {
},
category: "System",
},
{
title: "View usage & cost",
value: "opencode.cost",
slash: {
name: "cost",
aliases: ["usage"],
},
onSelect: () => {
const sessionID = route.data.type === "session" ? route.data.sessionID : undefined
dialog.replace(() => <DialogCost sessionID={sessionID ?? ""} />)
},
category: "System",
},
{
title: "Switch theme",
value: "theme.switch",
Expand Down
79 changes: 79 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/cost-metrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { Message, Provider, Model } from "@opencode-ai/sdk/v2"

export interface Metrics {
actual: number
noCache: number
cacheHitPct: number
}

export interface StatsInput {
totalCost: number
totalTokens: { input: number; cache: { read: number } }
modelUsage: Record<string, { cost: number; tokens: { input: number; cache: { read: number } } }>
}

export function computeSessionMetrics(messages: Message[], providers: Provider[]): Metrics {
let actual = 0
let cacheReadTokens = 0
let inputTokens = 0
let noCacheDelta = 0

for (const msg of messages) {
if (msg.role !== "assistant") continue
actual += msg.cost || 0
const tokens = msg.tokens
if (tokens) {
inputTokens += tokens.input || 0
cacheReadTokens += tokens.cache?.read || 0
const model = findModel(providers, msg.providerID, msg.modelID)
const inputPrice = model?.cost?.input ?? 0
const cacheReadPrice = model?.cost?.cache?.read ?? inputPrice
noCacheDelta += ((tokens.cache?.read || 0) * (inputPrice - cacheReadPrice)) / 1_000_000
}
}

const noCache = actual + noCacheDelta
const totalInput = inputTokens + cacheReadTokens
const cacheHitPct = totalInput > 0 ? (cacheReadTokens / totalInput) * 100 : 0

return { actual, noCache, cacheHitPct }
}

export function computeStatsMetrics(stats: StatsInput, providers: Provider[]): Metrics {
let noCacheDelta = 0
for (const [modelKey, usage] of Object.entries(stats.modelUsage)) {
const slash = modelKey.indexOf("/")
const providerID = modelKey.substring(0, slash)
const modelID = modelKey.substring(slash + 1)
const model = findModel(providers, providerID, modelID)
const inputPrice = model?.cost?.input ?? 0
const cacheReadPrice = model?.cost?.cache?.read ?? inputPrice
noCacheDelta += (usage.tokens.cache.read * (inputPrice - cacheReadPrice)) / 1_000_000
}

const totalInput = stats.totalTokens.input + stats.totalTokens.cache.read
const cacheHitPct = totalInput > 0 ? (stats.totalTokens.cache.read / totalInput) * 100 : 0

return {
actual: stats.totalCost,
noCache: stats.totalCost + noCacheDelta,
cacheHitPct,
}
}

export function findModel(providers: Provider[], providerID: string, modelID: string): Model | undefined {
const provider = providers.find((p) => p.id === providerID)
return provider?.models[modelID]
}

export function formatCost(n: number): string {
if (n >= 1000) return `$${(n / 1000).toFixed(1)}K`
return `$${n.toFixed(1)}`
}

export function formatRow(label: string, metrics: Metrics, maxCostWidth: number): string {
const costPair = `${formatCost(metrics.actual)}╱${formatCost(metrics.noCache)}`
const padded = costPair.padEnd(maxCostWidth)
const hitRate = `⟐${Math.round(metrics.cacheHitPct)}%`
return `${label.padEnd(4)} ${padded} ${hitRate}`
}
74 changes: 74 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/dialog-cost.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog } from "@tui/ui/dialog"
import { useSync } from "@tui/context/sync"
import { useSDK } from "@tui/context/sdk"
import { createMemo, createResource } from "solid-js"
import { computeSessionMetrics, computeStatsMetrics, formatCost, formatRow } from "./cost-metrics"

export function DialogCost(props: { sessionID: string }) {
const sync = useSync()
const { theme } = useTheme()
const dialog = useDialog()
const sdk = useSDK()

const sessionMetrics = createMemo(() => {
const msgs = sync.data.message[props.sessionID] ?? []
return computeSessionMetrics(msgs, sync.data.provider)
})

const [dailyStats] = createResource(async () => {
const res = await sdk.fetch(sdk.url + "/session/stats?days=1", {})
return res.json()
})

const [monthlyStats] = createResource(async () => {
const res = await sdk.fetch(sdk.url + "/session/stats?days=30", {})
return res.json()
})

const dailyMetrics = createMemo(() => {
const data = dailyStats()
if (!data) return { actual: 0, noCache: 0, cacheHitPct: 0 }
return computeStatsMetrics(data, sync.data.provider)
})

const monthlyMetrics = createMemo(() => {
const data = monthlyStats()
if (!data) return { actual: 0, noCache: 0, cacheHitPct: 0 }
return computeStatsMetrics(data, sync.data.provider)
})

const rows = createMemo(() => {
const sess = sessionMetrics()
const daily = dailyMetrics()
const monthly = monthlyMetrics()

const allMetrics = [sess, daily, monthly]
const maxCostWidth = Math.max(
...allMetrics.map((m) => `${formatCost(m.actual)}╱${formatCost(m.noCache)}`.length),
)

return [
formatRow("Sess", sess, maxCostWidth),
formatRow("☼-ly", daily, maxCostWidth),
formatRow("☽-ly", monthly, maxCostWidth),
]
})

return (
<box paddingLeft={2} paddingRight={2} gap={0} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text} attributes={TextAttributes.BOLD}>
Usage
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<text fg={theme.text}>{rows()[0]}</text>
<text fg={theme.text}>{rows()[1]}</text>
<text fg={theme.text}>{rows()[2]}</text>
</box>
)
}
31 changes: 31 additions & 0 deletions packages/opencode/src/server/routes/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { PermissionID } from "@/permission/schema"
import { ModelID, ProviderID } from "@/provider/schema"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { aggregateSessionStats } from "../../cli/cmd/stats"

const log = Log.create({ service: "server" })

Expand Down Expand Up @@ -92,6 +93,36 @@ export const SessionRoutes = lazy(() =>
return c.json(result)
},
)
.get(
"/stats",
describeRoute({
summary: "Get usage stats",
description: "Get aggregated usage and cost statistics across sessions.",
operationId: "session.stats",
responses: {
200: {
description: "Usage statistics",
content: {
"application/json": {
schema: resolver(z.any()),
},
},
},
},
}),
validator(
"query",
z.object({
days: z.coerce.number().optional(),
project: z.string().optional(),
}),
),
async (c) => {
const query = c.req.valid("query")
const stats = await aggregateSessionStats(query.days, query.project)
return c.json(stats)
},
)
.get(
"/:sessionID",
describeRoute({
Expand Down
Loading
Loading