mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
feat(vscode): show usage by routed model
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"@kilocode/sdk": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Show full task token usage and a provider-grouped model breakdown in the VS Code session header.
|
||||
@@ -983,6 +983,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
case "loadSessions":
|
||||
this.handleLoadSessions().catch((e) => console.error("[Kilo New] handleLoadSessions failed:", e))
|
||||
break
|
||||
case "requestSessionModelUsage":
|
||||
void this.fetchAndSendSessionModelUsage(message.sessionID, message.requestID)
|
||||
break
|
||||
case "login": {
|
||||
const attempt = ++this.loginAttempt
|
||||
await handleLogin(this.authCtx, attempt, () => this.loginAttempt)
|
||||
@@ -1662,6 +1665,29 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
.catch((e: unknown) => console.error("[Kilo New] KiloProvider: Failed to fetch session statuses:", e))
|
||||
}
|
||||
|
||||
private fetchAndSendSessionModelUsage(sessionID: string, requestID: string): Promise<void> {
|
||||
const directory = this.getWorkspaceDirectory(sessionID)
|
||||
return this.connectionService
|
||||
.getClientAsync(directory)
|
||||
.then(async (client) => {
|
||||
const load = () => client.kilocode.sessionModelUsage({ sessionID, directory }, { throwOnError: true })
|
||||
const first = await load()
|
||||
const added = first.data.sessionIDs.some((id) => {
|
||||
if (this.trackedSessionIds.has(id)) return false
|
||||
this.trackedSessionIds.add(id)
|
||||
return true
|
||||
})
|
||||
return added ? load() : first
|
||||
})
|
||||
.then((response) => {
|
||||
this.postMessage({ type: "sessionModelUsageLoaded", sessionID, requestID, data: response.data })
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.warn("[Kilo New] KiloProvider: Failed to load session model usage:", error)
|
||||
this.postMessage({ type: "sessionModelUsageLoaded", sessionID, requestID })
|
||||
})
|
||||
}
|
||||
|
||||
private async handleLoadMessages(
|
||||
sessionID: string,
|
||||
options: { mode?: MessageLoadMode; before?: string; limit?: number; preserveStream?: boolean } = {},
|
||||
@@ -3412,7 +3438,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
: sessionPatchToWebview(event.properties.sessionID, event.properties.info),
|
||||
}
|
||||
case "session.deleted":
|
||||
return null
|
||||
return {
|
||||
type: "sessionDeleted" as const,
|
||||
sessionID: event.properties.sessionID,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3548,6 +3577,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.setCurrentSession(applySessionPatch(this.currentSession, event.properties.info))
|
||||
this.contextSessionID = event.properties.sessionID
|
||||
}
|
||||
if (event.type === "session.deleted") {
|
||||
const sid = event.properties.sessionID
|
||||
this.trackedSessionIds.delete(sid)
|
||||
this.sessionDirectories.delete(sid)
|
||||
this.connectionService.pruneSession(sid)
|
||||
}
|
||||
|
||||
// Auto-adopt child sessions as soon as the task tool part reveals their ID.
|
||||
// This means the child's permission/question events are tracked immediately —
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Provider, SessionModelUsage } from "../../webview-ui/src/types/messages"
|
||||
import {
|
||||
groupModelUsage,
|
||||
hasModelUsage,
|
||||
isSameSessionTree,
|
||||
modelUsageName,
|
||||
tokenSummary,
|
||||
} from "../../webview-ui/src/context/model-usage"
|
||||
|
||||
const tokens = { input: 10, output: 2, reasoning: 1, cache: { read: 20, write: 5 } }
|
||||
const models = [
|
||||
{ providerID: "kilo", modelID: "qwen/qwen3.7-plus-20260602", steps: 1, cost: 0.01, tokens },
|
||||
{ providerID: "minimax", modelID: "minimax-m3", steps: 1, cost: 0.02, tokens },
|
||||
]
|
||||
const usage = {
|
||||
sessionIDs: ["root", "child"],
|
||||
totals: { steps: 2, cost: 0.03, tokens },
|
||||
models,
|
||||
} satisfies SessionModelUsage
|
||||
const providers = {
|
||||
kilo: {
|
||||
id: "kilo",
|
||||
name: "Kilo Gateway",
|
||||
models: {
|
||||
"qwen/qwen3.7-plus": { id: "qwen/qwen3.7-plus", name: "Qwen: Qwen3.7 Plus (20% off)" },
|
||||
},
|
||||
},
|
||||
minimax: {
|
||||
id: "minimax",
|
||||
name: "MiniMax",
|
||||
models: { "minimax-m3": { id: "minimax-m3", name: "MiniMax M3" } },
|
||||
},
|
||||
} satisfies Record<string, Provider>
|
||||
|
||||
describe("model usage", () => {
|
||||
test("groups billing routes and resolves compact catalog names", () => {
|
||||
expect(hasModelUsage(usage)).toBeTrue()
|
||||
expect(
|
||||
hasModelUsage({
|
||||
sessionIDs: [],
|
||||
totals: { steps: 0, cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } },
|
||||
models: [],
|
||||
}),
|
||||
).toBeFalse()
|
||||
expect(tokenSummary(usage)).toEqual({ input: 10, output: 2, cached: 20 })
|
||||
expect(groupModelUsage(models, providers).map((group) => group.providerName)).toEqual(["Kilo Gateway", "MiniMax"])
|
||||
expect(modelUsageName(models[0], providers)).toBe("Qwen 3.7 Plus")
|
||||
expect(modelUsageName(models[1], providers)).toBe("MiniMax M3")
|
||||
expect(modelUsageName({ ...models[0], modelID: "moonshotai/kimi-k2.7-code-20260612" }, {})).toBe("kimi-k2.7-code")
|
||||
})
|
||||
|
||||
test("matches sessions through their top-level tree", () => {
|
||||
const sessions = new Map([
|
||||
["root", {}],
|
||||
["child", { parentID: "root" }],
|
||||
["sibling", { parentID: "root" }],
|
||||
["other", {}],
|
||||
])
|
||||
const get = (id: string) => sessions.get(id)
|
||||
|
||||
expect(isSameSessionTree("child", "sibling", get)).toBeTrue()
|
||||
expect(isSameSessionTree("child", "new", get, "sibling")).toBeTrue()
|
||||
expect(isSameSessionTree("child", "other", get)).toBeFalse()
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,8 @@ import { useLanguage } from "../../context/language"
|
||||
import { useVSCode } from "../../context/vscode"
|
||||
import { TaskTimeline } from "./TaskTimeline"
|
||||
import { ContextProgress } from "./ContextProgress"
|
||||
import { TaskUsage } from "./TaskUsage"
|
||||
import { hasModelUsage, tokenSummary } from "../../context/model-usage"
|
||||
import { SessionRenameEditor } from "../shared/SessionRenameEditor"
|
||||
import { target as todoTarget } from "../../context/todo-revert"
|
||||
import type { Part, TodoItem, ExtensionMessage } from "../../types/messages"
|
||||
@@ -68,7 +70,10 @@ export const TaskHeader: Component<TaskHeaderProps> = (props) => {
|
||||
return { tokens, pct }
|
||||
})
|
||||
|
||||
const tokens = createMemo(() => calcTokenUsage(session.visibleMessages()))
|
||||
const tokens = createMemo(() => {
|
||||
const usage = session.modelUsage()
|
||||
return hasModelUsage(usage) ? tokenSummary(usage) : calcTokenUsage(session.visibleMessages())
|
||||
})
|
||||
|
||||
const hasTimeline = createMemo(() => {
|
||||
for (const m of session.visibleMessages()) {
|
||||
@@ -78,12 +83,6 @@ export const TaskHeader: Component<TaskHeaderProps> = (props) => {
|
||||
return false
|
||||
})
|
||||
|
||||
const fmtNum = (n: number): string => {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
|
||||
return String(n)
|
||||
}
|
||||
|
||||
const vscode = useVSCode()
|
||||
const [expanded, setExpanded] = createSignal(true)
|
||||
|
||||
@@ -233,31 +232,7 @@ export const TaskHeader: Component<TaskHeaderProps> = (props) => {
|
||||
<div data-slot="task-header-graph-row">
|
||||
<ContextProgress />
|
||||
</div>
|
||||
<Show when={tokens()}>
|
||||
{(tk) => (
|
||||
<div class="task-header-tokens">
|
||||
<span class="task-header-tokens-label">Tokens</span>
|
||||
<Show when={tk().input > 0}>
|
||||
<span class="task-header-tokens-value">
|
||||
<Icon name="arrow-up" size="small" />
|
||||
{fmtNum(tk().input)}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={tk().output > 0}>
|
||||
<span class="task-header-tokens-value">
|
||||
<Icon name="arrow-down-to-line" size="small" />
|
||||
{fmtNum(tk().output)}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={tk().cached > 0}>
|
||||
<span class="task-header-tokens-value">
|
||||
<Icon name="arrow-down-to-line" size="small" />
|
||||
cache {fmtNum(tk().cached)}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={tokens()}>{(tk) => <TaskUsage tokens={tk()} usage={session.modelUsage()} />}</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={hasTodos()}>
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { Component } from "solid-js"
|
||||
import { For, Show, createMemo } from "solid-js"
|
||||
import { Collapsible } from "@kilocode/kilo-ui/collapsible"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { useProvider } from "../../context/provider"
|
||||
import type { SessionModelUsage } from "../../types/messages"
|
||||
import { groupModelUsage, modelUsageName, type TokenSummary } from "../../context/model-usage"
|
||||
|
||||
interface TaskUsageProps {
|
||||
tokens: TokenSummary
|
||||
usage?: SessionModelUsage
|
||||
defaultOpen?: boolean
|
||||
}
|
||||
|
||||
export const TaskUsage: Component<TaskUsageProps> = (props) => {
|
||||
const language = useLanguage()
|
||||
const provider = useProvider()
|
||||
const groups = createMemo(() => groupModelUsage(props.usage?.models ?? [], provider.providers()))
|
||||
const money = createMemo(
|
||||
() =>
|
||||
new Intl.NumberFormat(language.locale(), {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 6,
|
||||
}),
|
||||
)
|
||||
|
||||
const number = (value: number) => {
|
||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
|
||||
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`
|
||||
return String(value)
|
||||
}
|
||||
const count = (value: number) => value.toLocaleString(language.locale())
|
||||
const cost = (input: number) => {
|
||||
const value = Math.max(0, Number.isFinite(input) ? input : 0)
|
||||
if (value > 0 && value < 0.000001) return "<$0.000001"
|
||||
return money().format(value)
|
||||
}
|
||||
const rate = (model: SessionModelUsage["models"][number]) => {
|
||||
const total = model.tokens.input + model.tokens.cache.read + model.tokens.cache.write
|
||||
if (total === 0) return "-"
|
||||
return `${((model.tokens.cache.read / total) * 100).toFixed(1)}%`
|
||||
}
|
||||
|
||||
const Summary = () => (
|
||||
<>
|
||||
<span class="task-header-tokens-label">Tokens</span>
|
||||
<Show when={props.tokens.input > 0}>
|
||||
<span class="task-header-tokens-value">
|
||||
<Icon name="arrow-up" size="small" />
|
||||
{number(props.tokens.input)}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={props.tokens.output > 0}>
|
||||
<span class="task-header-tokens-value">
|
||||
<Icon name="arrow-down-to-line" size="small" />
|
||||
{number(props.tokens.output)}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={props.tokens.cached > 0}>
|
||||
<span class="task-header-tokens-value">
|
||||
<Icon name="arrow-down-to-line" size="small" />
|
||||
cache {number(props.tokens.cached)}
|
||||
</span>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={props.usage?.models.length}
|
||||
fallback={
|
||||
<div class="task-header-tokens">
|
||||
<Summary />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Collapsible variant="ghost" class="task-header-usage tool-collapsible" defaultOpen={props.defaultOpen}>
|
||||
<Collapsible.Trigger class="task-header-usage-trigger">
|
||||
<span class="task-header-tokens">
|
||||
<Summary />
|
||||
</span>
|
||||
<Collapsible.Arrow />
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<div class="task-header-usage-detail">
|
||||
<For each={groups()}>
|
||||
{(group) => (
|
||||
<section class="task-header-usage-provider">
|
||||
<h4>{group.providerName}</h4>
|
||||
<For each={group.models}>
|
||||
{(model) => (
|
||||
<div class="task-header-usage-model">
|
||||
<div class="task-header-usage-model-name" title={`${model.providerID}/${model.modelID}`}>
|
||||
{modelUsageName(model, provider.providers())}
|
||||
</div>
|
||||
<div class="task-header-usage-meta">
|
||||
{model.steps} {model.steps === 1 ? "step" : "steps"} · {cost(model.cost)}
|
||||
</div>
|
||||
<div class="task-header-usage-meta">
|
||||
In {count(model.tokens.input)} · Out {count(model.tokens.output)} · Reason{" "}
|
||||
{count(model.tokens.reasoning)}
|
||||
</div>
|
||||
<div class="task-header-usage-meta">
|
||||
Cache R {count(model.tokens.cache.read)} · W {count(model.tokens.cache.write)} · Rate{" "}
|
||||
{rate(model)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</section>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Provider, SessionModelUsage } from "../types/messages"
|
||||
|
||||
const DATE_SUFFIX = /(?:-(?:20\d{6}|20\d{2}-\d{2}-\d{2}))(?:-v\d+(?::\d+)?)?$/i
|
||||
|
||||
export type TokenSummary = { input: number; output: number; cached: number }
|
||||
|
||||
export function isSameSessionTree(
|
||||
current: string,
|
||||
sessionID: string,
|
||||
get: (id: string) => { parentID?: string | null } | undefined,
|
||||
parentID?: string | null,
|
||||
) {
|
||||
const top = (start: string, first?: string | null): string => {
|
||||
const seen = new Set<string>()
|
||||
const visit = (id: string, parent?: string | null): string => {
|
||||
if (seen.has(id)) return start
|
||||
seen.add(id)
|
||||
const next = parent ?? get(id)?.parentID
|
||||
return next ? visit(next) : id
|
||||
}
|
||||
return visit(start, first)
|
||||
}
|
||||
return top(current) === top(sessionID, parentID)
|
||||
}
|
||||
|
||||
export function hasModelUsage(usage: SessionModelUsage | undefined): usage is SessionModelUsage {
|
||||
if (!usage) return false
|
||||
const tokens = usage.totals.tokens
|
||||
return (
|
||||
usage.models.length > 0 ||
|
||||
usage.totals.steps > 0 ||
|
||||
usage.totals.cost > 0 ||
|
||||
tokens.input > 0 ||
|
||||
tokens.output > 0 ||
|
||||
tokens.reasoning > 0 ||
|
||||
tokens.cache.read > 0 ||
|
||||
tokens.cache.write > 0
|
||||
)
|
||||
}
|
||||
|
||||
export function tokenSummary(usage: SessionModelUsage): TokenSummary {
|
||||
return {
|
||||
input: usage.totals.tokens.input,
|
||||
output: usage.totals.tokens.output,
|
||||
cached: usage.totals.tokens.cache.read,
|
||||
}
|
||||
}
|
||||
|
||||
export function groupModelUsage(models: SessionModelUsage["models"], providers: Record<string, Provider>) {
|
||||
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: providers[model.providerID]?.name ?? model.providerID,
|
||||
models: [],
|
||||
}
|
||||
group.models.push(model)
|
||||
groups.set(model.providerID, group)
|
||||
}
|
||||
return [...groups.values()]
|
||||
}
|
||||
|
||||
export function modelUsageName(model: SessionModelUsage["models"][number], providers: Record<string, Provider>) {
|
||||
const provider = providers[model.providerID]
|
||||
const id = model.modelID.replace(DATE_SUFFIX, "")
|
||||
const name = provider?.models[model.modelID]?.name ?? provider?.models[id]?.name ?? id
|
||||
return name
|
||||
.replace(/^[^:]+:\s*/, "")
|
||||
.replace(/^[^/]+\//, "")
|
||||
.replace(/\s*\([^)]*%\s*off[^)]*\)\s*$/i, "")
|
||||
.replace(/^qwen(?=\d)/i, "Qwen ")
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { useLanguage } from "./language"
|
||||
import { showToast } from "@kilocode/kilo-ui/toast"
|
||||
import type {
|
||||
SessionInfo,
|
||||
SessionModelUsage,
|
||||
SessionUpdate,
|
||||
Message,
|
||||
Part,
|
||||
@@ -74,6 +75,7 @@ import { KILO_AUTO, KILO_PROVIDER_ID, parseModelString } from "../../../src/shar
|
||||
import { reviewMetadata, type ReviewMessageData } from "../../../src/shared/review-comments"
|
||||
import { visibleMessages as filterVisibleMessages } from "./session-queue"
|
||||
import { createAbortState } from "./abort-state"
|
||||
import { isSameSessionTree } from "./model-usage"
|
||||
|
||||
const RECENT_LIMIT = 5
|
||||
const MESSAGE_PAGE_LIMIT = 80
|
||||
@@ -109,6 +111,7 @@ interface SessionStore {
|
||||
variantSelections: Record<string, string> // session/agent scoped variant key -> variant name
|
||||
recentModels: ModelSelection[]
|
||||
favoriteModels: ModelSelection[]
|
||||
modelUsage: Record<string, { requestID: string; data?: SessionModelUsage }>
|
||||
}
|
||||
|
||||
interface SessionContextValue {
|
||||
@@ -194,6 +197,8 @@ interface SessionContextValue {
|
||||
// Cost and context usage for the current session
|
||||
costBreakdown: Accessor<Array<{ label: string; cost: number }>>
|
||||
contextUsage: Accessor<ContextUsage | undefined>
|
||||
modelUsage: Accessor<SessionModelUsage | undefined>
|
||||
refreshModelUsage: () => void
|
||||
|
||||
// Skills loaded from the CLI backend
|
||||
skills: Accessor<SkillInfo[]>
|
||||
@@ -500,7 +505,27 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
variantSelections: {},
|
||||
recentModels: [],
|
||||
favoriteModels: [],
|
||||
modelUsage: {},
|
||||
})
|
||||
const [modelUsageReady, setModelUsageReady] = createSignal(false)
|
||||
let modelUsageQueued = false
|
||||
|
||||
function refreshModelUsage() {
|
||||
const sessionID = currentSessionID()
|
||||
if (!sessionID || sessionID.startsWith("cloud:")) return
|
||||
const requestID = crypto.randomUUID()
|
||||
setStore("modelUsage", sessionID, { requestID, data: store.modelUsage[sessionID]?.data })
|
||||
vscode.postMessage({ type: "requestSessionModelUsage", sessionID, requestID })
|
||||
}
|
||||
|
||||
function queueModelUsageRefresh() {
|
||||
if (modelUsageQueued) return
|
||||
modelUsageQueued = true
|
||||
queueMicrotask(() => {
|
||||
modelUsageQueued = false
|
||||
refreshModelUsage()
|
||||
})
|
||||
}
|
||||
|
||||
// Per-session agent selection
|
||||
const selectedAgentName = createMemo<string>(() => {
|
||||
@@ -983,9 +1008,42 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
return false
|
||||
}
|
||||
|
||||
function handleModelUsageMessage(message: ExtensionMessage): boolean {
|
||||
if (message.type !== "sessionModelUsageLoaded") return false
|
||||
const state = store.modelUsage[message.sessionID]
|
||||
if (state?.requestID === message.requestID) {
|
||||
setStore("modelUsage", message.sessionID, { requestID: message.requestID, data: message.data })
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function refreshModelUsageForMessage(message: ExtensionMessage) {
|
||||
if (message.type === "partUpdated") {
|
||||
if (message.part.type === "step-finish" && modelUsageRelated(message.sessionID)) queueModelUsageRefresh()
|
||||
return
|
||||
}
|
||||
if (message.type === "partsUpdated") {
|
||||
if (message.updates.some((item) => item.part.type === "step-finish" && modelUsageRelated(item.sessionID))) {
|
||||
queueModelUsageRefresh()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (message.type === "partRemoved" || message.type === "messageRemoved" || message.type === "sessionDeleted") {
|
||||
if (modelUsageRelated(message.sessionID, store.sessions[message.sessionID]?.parentID)) queueModelUsageRefresh()
|
||||
return
|
||||
}
|
||||
if (message.type === "sessionCreated" && modelUsageRelated(message.session.id, message.session.parentID)) {
|
||||
queueModelUsageRefresh()
|
||||
return
|
||||
}
|
||||
if (message.type === "extensionDataReady") queueModelUsageRefresh()
|
||||
}
|
||||
|
||||
function handleExtensionMessage(message: ExtensionMessage): void {
|
||||
// Route suggestion messages (extracted to stay within complexity limit)
|
||||
routeSuggestionMessage(message)
|
||||
if (handleModelUsageMessage(message)) return
|
||||
refreshModelUsageForMessage(message)
|
||||
if (handleStreamMessage(message)) return
|
||||
switch (message.type) {
|
||||
case "sessionCreated":
|
||||
@@ -1110,6 +1168,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
// Handle messages from extension
|
||||
onMount(() => {
|
||||
const unsubscribe = vscode.onMessage(handleExtensionMessage)
|
||||
setModelUsageReady(true)
|
||||
onCleanup(unsubscribe)
|
||||
})
|
||||
|
||||
@@ -1758,10 +1817,23 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
return sessionIDs(rootID, (sid) => store.messages[sid] ?? [])
|
||||
}
|
||||
|
||||
function modelUsageRelated(sessionID: string, parentID?: string | null): boolean {
|
||||
const current = currentSessionID()
|
||||
if (!current) return false
|
||||
const ids = store.modelUsage[current]?.data?.sessionIDs
|
||||
if (ids?.includes(sessionID) || (!!parentID && ids?.includes(parentID))) return true
|
||||
return isSameSessionTree(current, sessionID, (id) => store.sessions[id], parentID)
|
||||
}
|
||||
|
||||
function visibleFamily(rootID: string): Set<string> {
|
||||
return sessionIDs(rootID, visible)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!modelUsageReady() || !server.isConnected() || !currentSessionID()) return
|
||||
untrack(refreshModelUsage)
|
||||
})
|
||||
|
||||
/** Return permissions scoped to the given session's family (self + subagents). */
|
||||
function scopedPermissions(sessionID: string | undefined): PermissionRequest[] {
|
||||
if (!sessionID) return []
|
||||
@@ -1872,6 +1944,14 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
delete todos[sessionID]
|
||||
}),
|
||||
)
|
||||
setStore(
|
||||
"modelUsage",
|
||||
produce((usage) => {
|
||||
for (const [id, state] of Object.entries(usage)) {
|
||||
if (id === sessionID || state.data?.sessionIDs.includes(sessionID)) delete usage[id]
|
||||
}
|
||||
}),
|
||||
)
|
||||
setPages(
|
||||
produce((map) => {
|
||||
delete map[sessionID]
|
||||
@@ -2684,6 +2764,11 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
return fallback
|
||||
})
|
||||
|
||||
const modelUsage = createMemo<SessionModelUsage | undefined>(() => {
|
||||
const id = currentSessionID()
|
||||
return id ? store.modelUsage[id]?.data : undefined
|
||||
})
|
||||
|
||||
const contextUsage = createMemo<ContextUsage | undefined>(() => {
|
||||
const msgs = visibleMessages()
|
||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||
@@ -2740,6 +2825,8 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
clearModelOverride,
|
||||
costBreakdown,
|
||||
contextUsage,
|
||||
modelUsage,
|
||||
refreshModelUsage,
|
||||
agents,
|
||||
allAgents,
|
||||
skills,
|
||||
|
||||
@@ -220,6 +220,8 @@ export function mockSessionValue(overrides?: {
|
||||
clearModelOverride: noop,
|
||||
costBreakdown: () => [],
|
||||
contextUsage: () => undefined,
|
||||
modelUsage: () => undefined,
|
||||
refreshModelUsage: noop,
|
||||
agents: () => [{ name: "code", description: "Code mode", mode: "primary" as const }],
|
||||
allAgents: () => [{ name: "code", description: "Code mode", mode: "primary" as const }],
|
||||
skills: () => [],
|
||||
|
||||
@@ -13,15 +13,25 @@ import { StoryProviders, defaultMockData, mockSessionValue } from "./StoryProvid
|
||||
import { ChatView } from "../components/chat/ChatView"
|
||||
import { ErrorDisplay } from "../components/chat/ErrorDisplay"
|
||||
import { TaskHeader } from "../components/chat/TaskHeader"
|
||||
import { TaskUsage } from "../components/chat/TaskUsage"
|
||||
import { QuestionDock } from "../components/chat/QuestionDock"
|
||||
import { SuggestBar } from "../components/chat/SuggestBar"
|
||||
import { MessageList } from "../components/chat/MessageList"
|
||||
import { VscodeUserMessage } from "../components/chat/VscodeUserMessage"
|
||||
import { TurnOutcome } from "../components/shared/TurnOutcome"
|
||||
import { SessionContext } from "../context/session"
|
||||
import { ProviderContext } from "../context/provider"
|
||||
import { ServerContext } from "../context/server"
|
||||
import { WorktreeModeProvider } from "../context/worktree-mode"
|
||||
import type { Message, Part, QuestionRequest, ReviewComment, SuggestionRequest, TodoItem } from "../types/messages"
|
||||
import type {
|
||||
Message,
|
||||
Part,
|
||||
QuestionRequest,
|
||||
ReviewComment,
|
||||
SessionModelUsage,
|
||||
SuggestionRequest,
|
||||
TodoItem,
|
||||
} from "../types/messages"
|
||||
import { formatReviewCommentsMarkdown } from "../utils/review-comment-markdown"
|
||||
import { reviewMetadata } from "../../../src/shared/review-comments"
|
||||
|
||||
@@ -850,6 +860,84 @@ export const TaskHeaderWithTodosAllDone: Story = {
|
||||
},
|
||||
}
|
||||
|
||||
const usageTokens = { input: 25_900_000, output: 52_000, reasoning: 4_100, cache: { read: 10_500_000, write: 80_000 } }
|
||||
const usageData = {
|
||||
sessionIDs: [SESSION_ID, "story-subagent-001"],
|
||||
totals: {
|
||||
steps: 4,
|
||||
cost: 0.097214,
|
||||
tokens: { input: 25_908_400, output: 52_710, reasoning: 4_220, cache: { read: 10_514_000, write: 80_900 } },
|
||||
},
|
||||
models: [
|
||||
{ providerID: "kilo", modelID: "qwen/qwen3.7-plus-20260602", steps: 3, cost: 0.067214, tokens: usageTokens },
|
||||
{
|
||||
providerID: "minimax",
|
||||
modelID: "minimax-m3",
|
||||
steps: 1,
|
||||
cost: 0.03,
|
||||
tokens: { input: 8_400, output: 710, reasoning: 120, cache: { read: 14_000, write: 900 } },
|
||||
},
|
||||
],
|
||||
} satisfies SessionModelUsage
|
||||
const usageProviders = {
|
||||
kilo: {
|
||||
id: "kilo",
|
||||
name: "Kilo Gateway",
|
||||
models: {
|
||||
"qwen/qwen3.7-plus": { id: "qwen/qwen3.7-plus", name: "Qwen: Qwen3.7 Plus (20% off)" },
|
||||
},
|
||||
},
|
||||
minimax: {
|
||||
id: "minimax",
|
||||
name: "MiniMax",
|
||||
models: { "minimax-m3": { id: "minimax-m3", name: "MiniMax M3" } },
|
||||
},
|
||||
}
|
||||
const usageProvider = {
|
||||
providers: () => usageProviders,
|
||||
connected: () => ["kilo", "minimax"],
|
||||
defaults: () => ({}),
|
||||
defaultSelection: () => ({ providerID: "kilo", modelID: "qwen/qwen3.7-plus" }),
|
||||
models: () => [],
|
||||
findModel: () => undefined,
|
||||
authMethods: () => ({}),
|
||||
authStates: () => ({}),
|
||||
isModelValid: () => true,
|
||||
}
|
||||
|
||||
const usageStory = (open: boolean) => () => (
|
||||
<StoryProviders sessionID={SESSION_ID} status="idle" noPadding>
|
||||
<ProviderContext.Provider value={usageProvider as any}>
|
||||
<div style={{ "max-height": "560px", overflow: "auto" }}>
|
||||
<TaskUsage
|
||||
defaultOpen={open}
|
||||
usage={usageData}
|
||||
tokens={{
|
||||
input: usageData.totals.tokens.input,
|
||||
output: usageData.totals.tokens.output,
|
||||
cached: usageData.totals.tokens.cache.read,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ProviderContext.Provider>
|
||||
</StoryProviders>
|
||||
)
|
||||
|
||||
export const TaskUsageCollapsed: Story = {
|
||||
name: "Task usage — collapsed",
|
||||
render: usageStory(false),
|
||||
}
|
||||
|
||||
export const TaskUsageExpanded: Story = {
|
||||
name: "Task usage — provider and model breakdown",
|
||||
render: usageStory(true),
|
||||
}
|
||||
|
||||
export const TaskUsageExpanded200: Story = {
|
||||
name: "Task usage — provider and model breakdown, narrow",
|
||||
render: usageStory(true),
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Welcome screen with AccountSwitcher + KiloNotifications
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -254,11 +254,11 @@
|
||||
.task-header-tokens {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px 6px;
|
||||
padding: 0 8px;
|
||||
font-size: var(--kilo-font-size-11);
|
||||
color: var(--vscode-descriptionForeground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-header-tokens-label {
|
||||
@@ -271,6 +271,84 @@
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.task-header-usage-trigger {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
min-height: 28px;
|
||||
height: auto;
|
||||
padding: 2px 0;
|
||||
border-radius: 3px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.task-header-usage-trigger:hover {
|
||||
background: var(--vscode-toolbar-hoverBackground);
|
||||
}
|
||||
|
||||
.task-header-usage-trigger:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.task-header-usage-trigger .task-header-tokens {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.task-header-usage-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: min(320px, 45vh);
|
||||
margin: 4px 8px 2px;
|
||||
padding-left: 8px;
|
||||
overflow-y: auto;
|
||||
border-left: 1px solid var(--border-weak-base);
|
||||
}
|
||||
|
||||
.task-header-usage-provider {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-header-usage-provider h4 {
|
||||
margin: 0;
|
||||
color: var(--vscode-foreground);
|
||||
font-size: var(--kilo-font-size-11);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.task-header-usage-model {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-header-usage-model + .task-header-usage-model {
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--border-weak-base);
|
||||
}
|
||||
|
||||
.task-header-usage-model-name {
|
||||
overflow: hidden;
|
||||
color: var(--vscode-foreground);
|
||||
font-size: var(--kilo-font-size-11);
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-header-usage-meta {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: var(--kilo-font-size-11);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Expand toggle */
|
||||
[data-slot="task-header-expand"] {
|
||||
all: unset;
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
MessageLoadMode,
|
||||
SessionCloseReason,
|
||||
SessionInfo,
|
||||
SessionModelUsage,
|
||||
SessionUpdate,
|
||||
} from "./sessions"
|
||||
import type { PermissionRequest } from "./permissions"
|
||||
@@ -194,6 +195,13 @@ export interface MessagesLoadedMessage {
|
||||
since?: number
|
||||
}
|
||||
|
||||
export interface SessionModelUsageLoadedMessage {
|
||||
type: "sessionModelUsageLoaded"
|
||||
sessionID: string
|
||||
requestID: string
|
||||
data?: SessionModelUsage
|
||||
}
|
||||
|
||||
export interface MessageCreatedMessage {
|
||||
type: "messageCreated"
|
||||
message: Message
|
||||
@@ -1027,6 +1035,7 @@ export type ExtensionMessage =
|
||||
| SessionDeletedMessage
|
||||
| MessageRemovedMessage
|
||||
| MessagesLoadedMessage
|
||||
| SessionModelUsageLoadedMessage
|
||||
| MessageCreatedMessage
|
||||
| SessionsLoadedMessage
|
||||
| CloudSessionsLoadedMessage
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { KilocodeSessionModelUsageResponse } from "@kilocode/sdk/v2"
|
||||
import type { Part, TokenUsage } from "./parts"
|
||||
|
||||
export type SessionModelUsage = KilocodeSessionModelUsageResponse
|
||||
|
||||
export type SessionCloseReason = "completed" | "error" | "interrupted"
|
||||
|
||||
// Message structure (simplified for webview)
|
||||
|
||||
@@ -82,6 +82,12 @@ export interface LoadSessionsRequest {
|
||||
type: "loadSessions"
|
||||
}
|
||||
|
||||
export interface RequestSessionModelUsageMessage {
|
||||
type: "requestSessionModelUsage"
|
||||
sessionID: string
|
||||
requestID: string
|
||||
}
|
||||
|
||||
export interface RequestCloudSessionsMessage {
|
||||
type: "requestCloudSessions"
|
||||
cursor?: string
|
||||
@@ -1145,6 +1151,7 @@ export type WebviewMessage =
|
||||
| ClearSessionRequest
|
||||
| LoadMessagesRequest
|
||||
| LoadSessionsRequest
|
||||
| RequestSessionModelUsageMessage
|
||||
| RequestCloudSessionsMessage
|
||||
| RequestGitRemoteUrlMessage
|
||||
| LoginRequest
|
||||
|
||||
@@ -31,6 +31,7 @@ export namespace ModelUsage {
|
||||
type Model = typeof Model.Type
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
sessionIDs: Schema.Array(SessionID),
|
||||
totals: Usage,
|
||||
models: Schema.Array(Model),
|
||||
})
|
||||
@@ -76,6 +77,23 @@ export namespace ModelUsage {
|
||||
SELECT id, parent_id AS parentID
|
||||
FROM ancestor`
|
||||
|
||||
const FAMILY_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 = ?
|
||||
)
|
||||
SELECT id
|
||||
FROM family
|
||||
ORDER BY id`
|
||||
|
||||
const USAGE_SQL = `
|
||||
WITH RECURSIVE family(id) AS (
|
||||
SELECT id
|
||||
@@ -141,6 +159,10 @@ export namespace ModelUsage {
|
||||
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, anchor.projectID, anchor.projectID] as const
|
||||
const sessionIDs = db
|
||||
.prepare<{ id: SessionID }, [string, string, string]>(FAMILY_SQL)
|
||||
.all(...familyArgs)
|
||||
.map((item) => item.id)
|
||||
const rows = db.prepare<Row, [string, string, string]>(USAGE_SQL).all(...familyArgs)
|
||||
const totals = empty()
|
||||
const models = rows.map((row): Model => {
|
||||
@@ -165,7 +187,7 @@ export namespace ModelUsage {
|
||||
}
|
||||
})
|
||||
|
||||
return { totals, models } satisfies Info
|
||||
return { sessionIDs, totals, models } satisfies Info
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ const session = (id: string, parentID?: string) =>
|
||||
}) satisfies Session
|
||||
|
||||
const data = {
|
||||
sessionIDs: ["ses_current"],
|
||||
totals: {
|
||||
steps: 0,
|
||||
cost: 0,
|
||||
|
||||
@@ -129,6 +129,7 @@ describe("session model usage", () => {
|
||||
})
|
||||
|
||||
expect(yield* ModelUsage.get(child.id)).toEqual({
|
||||
sessionIDs: [root.id, sibling.id, child.id].sort(),
|
||||
totals: {
|
||||
steps: 3,
|
||||
cost: 1.125,
|
||||
|
||||
@@ -10927,6 +10927,7 @@ export type KilocodeSessionModelUsageResponses = {
|
||||
* Model usage for a session tree
|
||||
*/
|
||||
200: {
|
||||
sessionIDs: Array<string>
|
||||
totals: {
|
||||
steps: number
|
||||
cost: number
|
||||
|
||||
@@ -15092,6 +15092,13 @@
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sessionIDs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
}
|
||||
},
|
||||
"totals": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15198,7 +15205,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["totals", "models"],
|
||||
"required": ["sessionIDs", "totals", "models"],
|
||||
"additionalProperties": false,
|
||||
"description": "Model usage for a session tree"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user