diff --git a/.changeset/show-routed-step-models.md b/.changeset/show-routed-step-models.md index 04d06d28a4f..700643bf9ff 100644 --- a/.changeset/show-routed-step-models.md +++ b/.changeset/show-routed-step-models.md @@ -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. diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/usage.ts b/packages/opencode/src/cli/cmd/tui/routes/session/usage.ts deleted file mode 100644 index dcc3e6cc1c6..00000000000 --- a/packages/opencode/src/cli/cmd/tui/routes/session/usage.ts +++ /dev/null @@ -1,26 +0,0 @@ -// kilocode_change - new file -import type { Message } from "@kilocode/sdk/v2" - -const fmt = new Intl.NumberFormat("en-US") - -export function getUsage(msg: readonly Message[]) { - return msg.reduce( - (sum, item) => { - if (item.role !== "assistant") return sum - return { - input: sum.input + item.tokens.input, - output: sum.output + item.tokens.output, - cached: sum.cached + item.tokens.cache.read, - } - }, - { - input: 0, - output: 0, - cached: 0, - }, - ) -} - -export function formatCount(input: number) { - return fmt.format(input) -} diff --git a/packages/opencode/src/kilocode/plugins/model-usage.ts b/packages/opencode/src/kilocode/plugins/model-usage.ts new file mode 100644 index 00000000000..99f58031f0b --- /dev/null +++ b/packages/opencode/src/kilocode/plugins/model-usage.ts @@ -0,0 +1,37 @@ +import type { KilocodeSessionModelUsageResponse } 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 +} + +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) +} diff --git a/packages/opencode/src/kilocode/plugins/sidebar-usage.tsx b/packages/opencode/src/kilocode/plugins/sidebar-usage.tsx index 1749750358f..8606fe83703 100644 --- a/packages/opencode/src/kilocode/plugins/sidebar-usage.tsx +++ b/packages/opencode/src/kilocode/plugins/sidebar-usage.tsx @@ -1,30 +1,66 @@ -// 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 { fmtAttemptCost, fmtScore } from "@/kilocode/components/model-info-panel-utils" +import { + failed, + formatCost, + formatCount, + formatRate, + select, + type SessionModelUsage, + type UsageResult, +} from "@/kilocode/plugins/model-usage" const id = "internal:kilo-sidebar-usage" +function identity(model: SessionModelUsage["models"][number]) { + if (model.providerID === "kilo" && model.modelID.includes("/")) return model.modelID + return `${model.providerID}/${model.modelID}` +} + 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 => + 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 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 }) => ( + + {props.label} + {props.value} + + ) + + onMount(() => { + const refresh = () => void refetch() + const offs = [ + props.api.event.on("message.part.updated", (event) => { + if (event.properties.part.type === "step-finish") refresh() + }), + props.api.event.on("message.part.removed", refresh), + props.api.event.on("message.removed", refresh), + props.api.event.on("session.created", refresh), + props.api.event.on("session.deleted", refresh), + props.api.event.on("server.connected", refresh), + ] + onCleanup(() => { + for (const off of offs) off() + }) + }) return ( @@ -32,18 +68,22 @@ function View(props: { api: TuiPluginApi; session_id: string }) { Token Usage - - Input - {usage().input} - - - Output - {usage().output} - - - Cached - {usage().cached} - + {unavailable() ? "Usage unavailable" : "Loading usage..."}} + > + {(data) => ( + <> + + + + + + + + + )} + {(value) => ( @@ -51,14 +91,41 @@ function View(props: { api: TuiPluginApi; session_id: string }) { Terminal Bench 2.0 - - Completion - {fmtScore(value().overallScore)} - - - Cost / attempt - {fmtAttemptCost(value().avgAttemptCostUsd)} - + + + + )} + + + {(data) => ( + + + Models ({data().models.length}) + + 0} fallback={No model usage yet}> + + + {(model) => ( + + + {identity(model)} + + + Steps {formatCount(model.steps)} | Cost {formatCost(model.cost)} + + + In {formatCount(model.tokens.input)} | Out {formatCount(model.tokens.output)} | Reason{" "} + {formatCount(model.tokens.reasoning)} + + + Cache R {formatCount(model.tokens.cache.read)} | W {formatCount(model.tokens.cache.write)} | + Rate {formatRate(model.tokens)} + + + )} + + + )} diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts b/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts index a579021295b..070f80e709f 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts @@ -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" @@ -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") @@ -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({ diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts index a742c10e408..d105775b068 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts @@ -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) => @@ -77,6 +79,14 @@ 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) @@ -84,5 +94,6 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode" .handle("notebookList", notebookList) .handle("notebookReply", notebookReply) .handle("notebookReject", notebookReject) + .handle("sessionModelUsage", sessionModelUsage) }), ) diff --git a/packages/opencode/src/kilocode/session/model-usage.ts b/packages/opencode/src/kilocode/session/model-usage.ts new file mode 100644 index 00000000000..05ab22bdf16 --- /dev/null +++ b/packages/opencode/src/kilocode/session/model-usage.ts @@ -0,0 +1,165 @@ +import { NonNegativeInt } from "@opencode-ai/core/schema" +import { Effect, Schema } from "effect" +import { InstanceState } from "@/effect/instance-state" +import { ModelID, ProviderID } from "@/provider/schema" +import { SessionID } from "@/session/schema" +import { Database } from "@/storage/db" + +export namespace ModelUsage { + const Tokens = Schema.Struct({ + input: NonNegativeInt, + output: NonNegativeInt, + reasoning: NonNegativeInt, + cache: Schema.Struct({ + read: NonNegativeInt, + write: NonNegativeInt, + }), + }) + + const Usage = Schema.Struct({ + steps: NonNegativeInt, + cost: Schema.Finite, + tokens: Tokens, + }) + + const Model = Schema.Struct({ + providerID: ProviderID, + modelID: ModelID, + ...Usage.fields, + }) + + type Model = typeof Model.Type + + export const Info = Schema.Struct({ + totals: Usage, + models: Schema.Array(Model), + }) + + type Info = typeof Info.Type + + type Ancestor = { + id: SessionID + parentID: SessionID | null + } + + type Row = { + providerID: ProviderID + modelID: ModelID + steps: number + cost: number + input: number + output: number + reasoning: number + read: number + write: number + } + + const ANCESTORS_SQL = ` + WITH RECURSIVE ancestor(id, parent_id) AS ( + SELECT id, parent_id + FROM session + WHERE id = ? AND project_id = ? + + UNION + + SELECT parent.id, parent.parent_id + FROM session AS parent + JOIN ancestor AS child ON child.parent_id = parent.id + WHERE parent.project_id = ? + ) + SELECT id, parent_id AS parentID + FROM ancestor` + + const USAGE_SQL = ` + WITH RECURSIVE family(id) AS ( + SELECT id + FROM session + WHERE id = ? AND project_id = ? + + UNION + + SELECT child.id + FROM session AS child + JOIN family AS parent ON child.parent_id = parent.id + WHERE child.project_id = ? + ), step AS ( + SELECT + coalesce(json_extract(part.data, '$.model.providerID'), json_extract(message.data, '$.providerID')) AS providerID, + coalesce(json_extract(part.data, '$.model.modelID'), json_extract(message.data, '$.modelID')) AS modelID, + max(0.0, cast(coalesce(json_extract(part.data, '$.cost'), 0) AS REAL)) AS cost, + max(0, cast(coalesce(json_extract(part.data, '$.tokens.input'), 0) AS INTEGER)) AS input, + max(0, cast(coalesce(json_extract(part.data, '$.tokens.output'), 0) AS INTEGER)) AS output, + max(0, cast(coalesce(json_extract(part.data, '$.tokens.reasoning'), 0) AS INTEGER)) AS reasoning, + max(0, cast(coalesce(json_extract(part.data, '$.tokens.cache.read'), 0) AS INTEGER)) AS cache_read, + max(0, cast(coalesce(json_extract(part.data, '$.tokens.cache.write'), 0) AS INTEGER)) AS cache_write + FROM family + JOIN part ON part.session_id = family.id + JOIN message ON message.id = part.message_id AND message.session_id = part.session_id + WHERE json_extract(part.data, '$.type') = 'step-finish' + AND json_extract(message.data, '$.role') = 'assistant' + ) + SELECT + providerID, + modelID, + count(*) AS steps, + coalesce(sum(cost), 0) AS cost, + coalesce(sum(input), 0) AS input, + coalesce(sum(output), 0) AS output, + coalesce(sum(reasoning), 0) AS reasoning, + coalesce(sum(cache_read), 0) AS read, + coalesce(sum(cache_write), 0) AS write + FROM step + WHERE providerID IS NOT NULL AND modelID IS NOT NULL + GROUP BY providerID, modelID + ORDER BY cost DESC, providerID, modelID` + + const empty = () => ({ + steps: 0, + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + }) + + export const get = Effect.fn("ModelUsage.get")(function* (sessionID: SessionID) { + const ctx = yield* InstanceState.context + return yield* Effect.sync(() => { + const db = Database.Client().$client + const args = [sessionID, ctx.project.id, ctx.project.id] as const + const ancestors = db.prepare(ANCESTORS_SQL).all(...args) + if (ancestors.length === 0) return undefined + + const ids = new Set(ancestors.map((item) => item.id)) + const rootID = ancestors.find((item) => !item.parentID || !ids.has(item.parentID))?.id ?? sessionID + const familyArgs = [rootID, ctx.project.id, ctx.project.id] as const + const rows = db.prepare(USAGE_SQL).all(...familyArgs) + const totals = empty() + const models = rows.map((row): Model => { + totals.steps += row.steps + totals.cost += row.cost + totals.tokens.input += row.input + totals.tokens.output += row.output + totals.tokens.reasoning += row.reasoning + totals.tokens.cache.read += row.read + totals.tokens.cache.write += row.write + return { + providerID: row.providerID, + modelID: row.modelID, + steps: row.steps, + cost: row.cost, + tokens: { + input: row.input, + output: row.output, + reasoning: row.reasoning, + cache: { read: row.read, write: row.write }, + }, + } + }) + + return { totals, models } satisfies Info + }) + }) +} diff --git a/packages/opencode/test/cli/tui/usage.test.ts b/packages/opencode/test/cli/tui/usage.test.ts deleted file mode 100644 index b5f7c74d19e..00000000000 --- a/packages/opencode/test/cli/tui/usage.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -// kilocode_change - new file -import { describe, expect, test } from "bun:test" -import type { AssistantMessage, Message, UserMessage } from "@kilocode/sdk/v2" -import { formatCount, getUsage } from "../../../src/cli/cmd/tui/routes/session/usage" - -function assistant(id: string, input: number, output: number, read: number): AssistantMessage { - return { - id, - sessionID: "ses_1", - role: "assistant", - time: { created: 1 }, - parentID: "msg_parent", - modelID: "claude-sonnet", - providerID: "anthropic", - mode: "code", - agent: "code", - path: { cwd: "/tmp", root: "/tmp" }, - cost: 0, - tokens: { - input, - output, - reasoning: 99, - cache: { - read, - write: 7, - }, - }, - } -} - -function user(): UserMessage { - return { - id: "msg_user", - sessionID: "ses_1", - role: "user", - time: { created: 0 }, - agent: "code", - model: { - providerID: "anthropic", - modelID: "claude-sonnet", - }, - } -} - -describe("session usage", () => { - test("sums input, output, and cache read across assistant messages only", () => { - const msg: Message[] = [user(), assistant("a", 1200, 45, 300), assistant("b", 800, 55, 700)] - - expect(getUsage(msg)).toEqual({ - input: 2000, - output: 100, - cached: 1000, - }) - }) - - test("formats full counts with thousands separators", () => { - expect(formatCount(0)).toBe("0") - expect(formatCount(12345)).toBe("12,345") - expect(formatCount(9876543)).toBe("9,876,543") - }) -}) diff --git a/packages/opencode/test/kilocode/cli/cmd/tui/model-usage.test.ts b/packages/opencode/test/kilocode/cli/cmd/tui/model-usage.test.ts new file mode 100644 index 00000000000..babaf4d40a1 --- /dev/null +++ b/packages/opencode/test/kilocode/cli/cmd/tui/model-usage.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test" +import { failed, formatRate, select, type SessionModelUsage } from "@/kilocode/plugins/model-usage" + +const data = { + totals: { + steps: 0, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + models: [], +} satisfies SessionModelUsage + +describe("TUI model usage", () => { + test("rejects stale session results and computes the cache rate", () => { + expect(select({ sessionID: "ses_old", data }, "ses_current")).toBeUndefined() + expect(failed({ sessionID: "ses_old" }, "ses_current")).toBeFalse() + expect(select({ sessionID: "ses_current", data }, "ses_current")).toBe(data) + expect(failed({ sessionID: "ses_current" }, "ses_current")).toBeTrue() + expect(formatRate({ input: 100, output: 0, reasoning: 0, cache: { read: 300, write: 100 } })).toBe("60.0%") + }) +}) diff --git a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts index 507d78116d4..35ea6faf154 100644 --- a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts +++ b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts @@ -261,6 +261,19 @@ export const kiloScenarios: Scenario[] = [ .post("/enhance-prompt", "enhancePrompt.enhance") .at((ctx) => ({ path: "/enhance-prompt", headers: ctx.headers(), body: { text: "" } })) .status(400), + http.protected + .get("/session/{sessionID}/model-usage", "kilocode.sessionModelUsage") + .seeded((ctx) => ctx.session({ title: "Model usage" })) + .at((ctx) => ({ + path: route("/session/{sessionID}/model-usage", { sessionID: ctx.state.id }), + headers: ctx.headers(), + })) + .json(200, (body) => { + object(body) + array(body.models) + object(body.totals) + check(body.models.length === 0, "a new session should have no model usage") + }), http.protected .post("/kilocode/heap/snapshot", "kilocode.heap.snapshot") .mutating() diff --git a/packages/opencode/test/kilocode/server/httpapi-public.test.ts b/packages/opencode/test/kilocode/server/httpapi-public.test.ts index 5389b540a35..ca260197ec8 100644 --- a/packages/opencode/test/kilocode/server/httpapi-public.test.ts +++ b/packages/opencode/test/kilocode/server/httpapi-public.test.ts @@ -6,6 +6,7 @@ import { BackgroundProcessPaths } from "../../../src/kilocode/server/httpapi/gro import { ConfigConsolePaths } from "../../../src/kilocode/server/httpapi/groups/config-console" import { IndexingPaths, KiloEmbeddingModel } from "../../../src/kilocode/server/httpapi/groups/indexing" import { KiloGatewayPaths } from "../../../src/kilocode/server/httpapi/groups/kilo-gateway" +import { KilocodePaths } from "../../../src/kilocode/server/httpapi/groups/kilocode" import { NetworkPaths } from "../../../src/kilocode/server/httpapi/groups/network" import { TelemetryPaths } from "../../../src/kilocode/server/httpapi/groups/telemetry" import { ExperimentalPaths } from "../../../src/server/routes/instance/httpapi/groups/experimental" @@ -136,6 +137,7 @@ describe("Kilo PublicApi OpenAPI contract", () => { { method: "get", path: ConfigConsolePaths.tuiConfig }, { method: "get", path: ConfigConsolePaths.tuiKeybinds }, { method: "patch", path: ConfigConsolePaths.tuiConfig }, + { method: "get", path: KilocodePaths.sessionModelUsage }, ] satisfies Array<{ method: Method; path: string }> for (const route of routes) { diff --git a/packages/opencode/test/kilocode/session-model-usage.test.ts b/packages/opencode/test/kilocode/session-model-usage.test.ts new file mode 100644 index 00000000000..7a3b6a855f4 --- /dev/null +++ b/packages/opencode/test/kilocode/session-model-usage.test.ts @@ -0,0 +1,137 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { ModelUsage } from "@/kilocode/session/model-usage" +import { MessageV2 } from "@/session/message-v2" +import { Session } from "@/session/session" +import { MessageID, PartID, SessionID } from "@/session/schema" +import { ModelID, ProviderID } from "@/provider/schema" +import { testEffect } from "../lib/effect" + +const it = testEffect(Session.defaultLayer) + +const ref = (providerID: string, modelID: string) => ({ + providerID: ProviderID.make(providerID), + modelID: ModelID.make(modelID), +}) + +const seed = Effect.fn("ModelUsageTest.seed")(function* (sessionID: SessionID, model: ReturnType) { + const sessions = yield* Session.Service + const user = yield* sessions.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID, + agent: "build", + model, + time: { created: Date.now() }, + }) + return yield* sessions.updateMessage({ + id: MessageID.ascending(), + role: "assistant", + parentID: user.id, + sessionID, + mode: "build", + agent: "build", + cost: 99, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: model.modelID, + providerID: model.providerID, + time: { created: Date.now() }, + } satisfies MessageV2.Assistant) +}) + +const step = Effect.fn("ModelUsageTest.step")(function* (input: { + sessionID: SessionID + messageID: MessageID + model?: ReturnType + cost: number + tokens: MessageV2.StepFinishPart["tokens"] +}) { + const sessions = yield* Session.Service + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: input.messageID, + sessionID: input.sessionID, + type: "step-finish", + reason: "stop", + model: input.model, + cost: input.cost, + tokens: input.tokens, + }) +}) + +describe("session model usage", () => { + it.instance("aggregates direct step usage by model across the top-level session tree", () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const root = yield* sessions.create({ title: "root" }) + const child = yield* sessions.create({ title: "child", parentID: root.id }) + const sibling = yield* sessions.create({ title: "sibling", parentID: root.id }) + const unrelated = yield* sessions.create({ title: "unrelated" }) + const auto = ref("kilo", "kilo-auto/efficient") + const routed = ref("kilo", "openai/gpt-5") + const direct = ref("google", "gemini-pro") + + const rootMessage = yield* seed(root.id, auto) + yield* step({ + sessionID: root.id, + messageID: rootMessage.id, + model: routed, + cost: 0.25, + tokens: { input: 100, output: 20, reasoning: 5, cache: { read: 200, write: 10 } }, + }) + + const childMessage = yield* seed(child.id, direct) + yield* step({ + sessionID: child.id, + messageID: childMessage.id, + cost: 0.75, + tokens: { input: 200, output: 40, reasoning: 15, cache: { read: 400, write: 30 } }, + }) + + const siblingMessage = yield* seed(sibling.id, direct) + yield* step({ + sessionID: sibling.id, + messageID: siblingMessage.id, + cost: 0.125, + tokens: { input: 50, output: 10, reasoning: 0, cache: { read: 100, write: 5 } }, + }) + + const unrelatedMessage = yield* seed(unrelated.id, ref("test", "excluded")) + yield* step({ + sessionID: unrelated.id, + messageID: unrelatedMessage.id, + cost: 9, + tokens: { input: 9_000, output: 9_000, reasoning: 9_000, cache: { read: 9_000, write: 9_000 } }, + }) + + expect(yield* ModelUsage.get(child.id)).toEqual({ + totals: { + steps: 3, + cost: 1.125, + tokens: { input: 350, output: 70, reasoning: 20, cache: { read: 700, write: 45 } }, + }, + models: [ + { + ...direct, + steps: 2, + cost: 0.875, + tokens: { input: 250, output: 50, reasoning: 15, cache: { read: 500, write: 35 } }, + }, + { + ...routed, + steps: 1, + cost: 0.25, + tokens: { input: 100, output: 20, reasoning: 5, cache: { read: 200, write: 10 } }, + }, + ], + }) + }), + ) + + it.instance("returns undefined for a missing session", () => + Effect.gen(function* () { + expect(yield* ModelUsage.get(SessionID.make("ses_missing"))).toBeUndefined() + }), + ) +}) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 2a883139209..96cb8fa793d 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -166,6 +166,8 @@ import type { KilocodeSessionImportProjectResponses, KilocodeSessionImportSessionErrors, KilocodeSessionImportSessionResponses, + KilocodeSessionModelUsageErrors, + KilocodeSessionModelUsageResponses, KiloEditErrors, KiloEditResponses, KiloFimErrors, @@ -7564,6 +7566,42 @@ export class Kilocode extends HeyApiClient { ) } + /** + * Get session model usage + * + * Get token usage and direct cost by model for the complete top-level session tree. + */ + public sessionModelUsage( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get< + KilocodeSessionModelUsageResponses, + KilocodeSessionModelUsageErrors, + ThrowOnError + >({ + url: "/session/{sessionID}/model-usage", + ...options, + ...params, + }) + } + private _heap?: Heap get heap(): Heap { return (this._heap ??= new Heap({ client: this.client }))