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
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.
2 changes: 1 addition & 1 deletion packages/opencode/src/cli/cmd/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const GenerateCommand = {
{
lang: "js",
source: [
`import { createKiloClient } from "@kilocode/sdk`,
`import { createKiloClient } from "@kilocode/sdk"`,
``,
`const client = createKiloClient()`,
`await client.${operation.operationId}({`,
Expand Down
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)
}),
)
2 changes: 2 additions & 0 deletions packages/opencode/src/kilocode/server/httpapi/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export function matchLegacyKiloOpenApi(input: Record<string, unknown>) {

const json = (path: string) => spec.paths?.[path]?.get?.responses?.["200"]?.content?.["application/json"]
const profile = json("/kilo/profile")?.schema?.properties
const pass = profile?.kiloPass?.properties
if (pass?.nextBillingAt) pass.nextBillingAt = nullable(pass.nextBillingAt)
if (profile?.balance) profile.balance = nullable(profile.balance)
if (profile?.kiloPass) profile.kiloPass = nullable(profile.kiloPass)
if (profile?.currentOrgId) profile.currentOrgId = nullable(profile.currentOrgId)
Expand Down
Loading
Loading