Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
3 changes: 2 additions & 1 deletion .changeset/show-routed-step-models.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
"@kilocode/cli": patch
"@kilocode/sdk": patch
"kilo-code": patch
---

Show the concrete model reported for routed Kilo auto-model steps in CLI and VS Code session timelines.
Show the concrete model reported for routed Kilo auto-model steps in CLI and VS Code session timelines, and break down TUI sidebar token usage, cache rate, and cost by model across subagent sessions.
26 changes: 0 additions & 26 deletions packages/opencode/src/cli/cmd/tui/routes/session/usage.ts

This file was deleted.

73 changes: 73 additions & 0 deletions packages/opencode/src/kilocode/plugins/model-usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { KilocodeSessionModelUsageResponse, Session } from "@kilocode/sdk/v2"

export type SessionModelUsage = KilocodeSessionModelUsageResponse
export type UsageResult = { sessionID: string; data?: SessionModelUsage }

export function select(result: UsageResult | undefined, sessionID: string) {
if (result?.sessionID !== sessionID) return undefined
return result.data
}

export function failed(result: UsageResult | undefined, sessionID: string) {
return result?.sessionID === sessionID && !result.data
}

export function isSessionTreeMember(input: {
root: string
sessionID: string
get: (sessionID: string) => Session | undefined
info?: Session
}) {
const seen = new Set<string>()
const visit = (sessionID: string, info?: Session): boolean => {
if (sessionID === input.root) return true
if (seen.has(sessionID)) return false
seen.add(sessionID)
const session = info ?? input.get(sessionID)
if (!session?.parentID) return false
return visit(session.parentID)
}
return visit(input.sessionID, input.info)
}

export function groupModelsByProvider(
models: SessionModelUsage["models"],
providers: ReadonlyArray<{ id: string; name: string }>,
) {
const names = new Map(providers.map((provider) => [provider.id, provider.name]))
const groups = new Map<string, { providerID: string; providerName: string; models: SessionModelUsage["models"] }>()
for (const model of models) {
const group = groups.get(model.providerID) ?? {
providerID: model.providerID,
providerName: names.get(model.providerID) ?? model.providerID,
models: [],
}
group.models.push(model)
groups.set(model.providerID, group)
}
return [...groups.values()]
}

const count = new Intl.NumberFormat("en-US")
const currency = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits: 6,
})

export function formatCount(value: number) {
return count.format(value)
}

export function formatRate(tokens: SessionModelUsage["totals"]["tokens"]) {
const total = tokens.input + tokens.cache.read + tokens.cache.write
if (total === 0) return "-"
return `${((tokens.cache.read / total) * 100).toFixed(1)}%`
}

export function formatCost(input: number) {
const value = Math.max(0, Number.isFinite(input) ? input : 0)
if (value > 0 && value < 0.000001) return "<$0.000001"
return currency.format(value)
}
154 changes: 121 additions & 33 deletions packages/opencode/src/kilocode/plugins/sidebar-usage.tsx
Original file line number Diff line number Diff line change
@@ -1,64 +1,152 @@
// kilocode_change - new file
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@kilocode/plugin/tui"
import { createMemo, Show } from "solid-js"
import { createMemo, createResource, For, onCleanup, onMount, Show } from "solid-js"
import { useLocal } from "@tui/context/local"
import { formatCount, getUsage } from "@tui/routes/session/usage"
import * as Model from "@tui/util/model"
import { RoutedModelMeta } from "@/kilocode/cli/cmd/tui/routes/session/routed-model-meta"
import { fmtAttemptCost, fmtScore } from "@/kilocode/components/model-info-panel-utils"
import {
failed,
formatCost,
formatCount,
formatRate,
groupModelsByProvider,
isSessionTreeMember,
select,
type UsageResult,
} from "@/kilocode/plugins/model-usage"

const id = "internal:kilo-sidebar-usage"

function View(props: { api: TuiPluginApi; session_id: string }) {
const theme = () => props.api.theme.current
const local = useLocal()
const msg = createMemo(() => props.api.state.session.messages(props.session_id))
const usage = createMemo(() => {
const total = getUsage(msg())
return {
input: formatCount(total.input),
output: formatCount(total.output),
cached: formatCount(total.cached),
}
})
const [result, { refetch }] = createResource(
() => props.session_id,
(sessionID): Promise<UsageResult> =>
props.api.client.kilocode.sessionModelUsage({ sessionID }).then(
(response) => ({ sessionID, data: response.data }),
() => ({ sessionID }),
),
)
const usage = createMemo(() => select(result(), props.session_id))
const unavailable = createMemo(() => failed(result(), props.session_id))
const providers = createMemo(() => Model.index([...props.api.state.provider]))
const groups = createMemo(() => groupModelsByProvider(usage()?.models ?? [], props.api.state.provider))
const bench = createMemo(() => {
const current = local.model.current()
if (!current) return
if (!current) return undefined
const provider = props.api.state.provider.find((item) => item.id === current.providerID)
return provider?.models[current.modelID]?.terminalBench
})
const Row = (props: { label: string; value: string }) => (
<box flexDirection="row" justifyContent="space-between">
<text fg={theme().textMuted}>{props.label}</text>
<text fg={theme().textMuted}>{props.value}</text>
</box>
)

onMount(() => {
const refresh = () => void refetch()
const related = (sessionID: string, info?: ReturnType<typeof props.api.state.session.get>) =>
isSessionTreeMember({ root: props.session_id, sessionID, info, get: props.api.state.session.get })
const offs = [
props.api.event.on("message.part.updated", (event) => {
if (event.properties.part.type === "step-finish" && related(event.properties.sessionID)) refresh()
}),
props.api.event.on("message.part.removed", (event) => {
if (related(event.properties.sessionID)) refresh()
}),
props.api.event.on("message.removed", (event) => {
if (related(event.properties.sessionID)) refresh()
}),
props.api.event.on("session.created", (event) => {
if (related(event.properties.sessionID, event.properties.info)) refresh()
}),
props.api.event.on("session.deleted", (event) => {
if (related(event.properties.sessionID, event.properties.info)) refresh()
}),
props.api.event.on("server.connected", refresh),
]
onCleanup(() => {
for (const off of offs) off()
})
})

return (
<box gap={1}>
<box>
<text fg={theme().text}>
<b>Token Usage</b>
</text>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme().textMuted}>Input</text>
<text fg={theme().textMuted}>{usage().input}</text>
</box>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme().textMuted}>Output</text>
<text fg={theme().textMuted}>{usage().output}</text>
</box>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme().textMuted}>Cached</text>
<text fg={theme().textMuted}>{usage().cached}</text>
</box>
<Show
when={usage()}
fallback={<text fg={theme().textMuted}>{unavailable() ? "Usage unavailable" : "Loading usage..."}</text>}
>
{(data) => (
<>
<Row label="Input" value={formatCount(data().totals.tokens.input)} />
<Row label="Output" value={formatCount(data().totals.tokens.output)} />
<Row label="Reasoning" value={formatCount(data().totals.tokens.reasoning)} />
<Row label="Cache read" value={formatCount(data().totals.tokens.cache.read)} />
<Row label="Cache write" value={formatCount(data().totals.tokens.cache.write)} />
<Row label="Cache rate" value={formatRate(data().totals.tokens)} />
<Row label="Cost" value={formatCost(data().totals.cost)} />
</>
)}
</Show>
</box>
<Show when={bench()}>
{(value) => (
<box>
<text fg={theme().text}>
<b>Terminal Bench 2.0</b>
</text>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme().textMuted}>Completion</text>
<text fg={theme().textMuted}>{fmtScore(value().overallScore)}</text>
</box>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme().textMuted}>Cost / attempt</text>
<text fg={theme().textMuted}>{fmtAttemptCost(value().avgAttemptCostUsd)}</text>
</box>
<Row label="Completion" value={fmtScore(value().overallScore)} />
<Row label="Cost / attempt" value={fmtAttemptCost(value().avgAttemptCostUsd)} />
</box>
)}
</Show>
<Show when={usage()}>
{(data) => (
<box>
<text fg={theme().text}>
<b>Models ({data().models.length})</b>
</text>
<Show when={data().models.length > 0} fallback={<text fg={theme().textMuted}>No model usage yet</text>}>
<box gap={1}>
<For each={groups()}>
{(group) => (
<box>
<text fg={theme().text}>
<b>{group.providerName}</b>
</text>
<box gap={1} paddingLeft={1}>
<For each={group.models}>
{(model) => (
<box>
<text fg={theme().text} wrapMode="char">
<b>{RoutedModelMeta.label(providers(), model)}</b>
</text>
<text fg={theme().textMuted} wrapMode="word">
Steps {formatCount(model.steps)} | Cost {formatCost(model.cost)}
</text>
<text fg={theme().textMuted} wrapMode="word">
In {formatCount(model.tokens.input)} | Out {formatCount(model.tokens.output)} | Reason{" "}
{formatCount(model.tokens.reasoning)}
</text>
<text fg={theme().textMuted} wrapMode="word">
Cache R {formatCount(model.tokens.cache.read)} | W{" "}
{formatCount(model.tokens.cache.write)} | Rate {formatRate(model.tokens)}
</text>
</box>
)}
</For>
</box>
</box>
)}
</For>
</box>
</Show>
</box>
)}
</Show>
Expand Down
15 changes: 15 additions & 0 deletions packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
RequestID as NotebookRequestID,
Result as NotebookResult,
} from "@/kilocode/notebook/protocol"
import { ModelUsage } from "@/kilocode/session/model-usage"
import { SessionID } from "@/session/schema"

const root = "/kilocode"

Expand All @@ -35,6 +37,7 @@ export const KilocodePaths = {
notebookList: `${root}/notebook`,
notebookReply: `${root}/notebook/:requestID/reply`,
notebookReject: `${root}/notebook/:requestID/reject`,
sessionModelUsage: `/session/:sessionID/model-usage`,
} as const

export const KilocodeApi = HttpApi.make("kilocode")
Expand Down Expand Up @@ -113,6 +116,18 @@ export const KilocodeApi = HttpApi.make("kilocode")
description: "Complete a pending native notebook request with a structured host error.",
}),
),
HttpApiEndpoint.get("sessionModelUsage", KilocodePaths.sessionModelUsage, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: described(ModelUsage.Info, "Model usage for a session tree"),
error: HttpApiError.NotFound,
}).annotateMerge(
OpenApi.annotations({
identifier: "kilocode.sessionModelUsage",
summary: "Get session model usage",
description: "Get token usage and direct cost by model for the complete top-level session tree.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import { Config } from "@/config/config"
import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state"
import { HeapSnapshot } from "@/kilocode/cli/heap-snapshot"
import { Notebook } from "@/kilocode/notebook/service"
import type { RequestID as NotebookRequestID } from "@/kilocode/notebook/protocol"
import { Notebook } from "@/kilocode/notebook/service"
import { ModelUsage } from "@/kilocode/session/model-usage"
import { InstanceStore } from "@/project/instance-store"
import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api"
import { Skill } from "@/skill"
import type { SessionID } from "@/session/schema"
import { NotebookRejectPayload, NotebookReplyPayload, RemoveAgentPayload, RemoveSkillPayload } from "../groups/kilocode"

export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode", (handlers) =>
Expand Down Expand Up @@ -77,12 +79,21 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode"
return true
})

const sessionModelUsage = Effect.fn("KilocodeHttpApi.sessionModelUsage")(function* (ctx: {
params: { sessionID: SessionID }
}) {
const usage = yield* ModelUsage.get(ctx.params.sessionID)
if (!usage) return yield* new HttpApiError.NotFound({})
return usage
})

return handlers
.handle("heapSnapshot", heapSnapshot)
.handle("removeSkill", removeSkill)
.handle("removeAgent", removeAgent)
.handle("notebookList", notebookList)
.handle("notebookReply", notebookReply)
.handle("notebookReject", notebookReject)
.handle("sessionModelUsage", sessionModelUsage)
}),
)
Loading
Loading