feat(desktop): Display stored totals for Tokens and Cost in Desktop Session Context (#28887)
This commit is contained in:
parent
92025e9a47
commit
683aca5dbe
4 changed files with 74 additions and 78 deletions
|
|
@ -9,7 +9,7 @@ import { useSync } from "@/context/sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
|
import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { createSessionTabs } from "@/pages/session/helpers"
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
|
|
||||||
|
|
@ -45,6 +45,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||||
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
||||||
})
|
})
|
||||||
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
|
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
|
||||||
|
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
|
||||||
|
|
||||||
const usd = createMemo(
|
const usd = createMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -54,10 +55,10 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()]))
|
const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
|
||||||
const context = createMemo(() => metrics().context)
|
const tokens = createMemo(() => info()?.tokens)
|
||||||
const cost = createMemo(() => {
|
const cost = createMemo(() => {
|
||||||
return usd().format(metrics().totalCost)
|
return usd().format(info()?.cost ?? 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
const openContext = () => {
|
const openContext = () => {
|
||||||
|
|
@ -82,18 +83,20 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||||
|
|
||||||
const tooltipValue = () => (
|
const tooltipValue = () => (
|
||||||
<div>
|
<div>
|
||||||
|
<Show when={tokens()}>
|
||||||
|
{(value) => (
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-text-invert-strong">{getSessionTokenTotal(value())?.toLocaleString(language.intl())}</span>
|
||||||
|
<span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
<Show when={context()}>
|
<Show when={context()}>
|
||||||
{(ctx) => (
|
{(ctx) => (
|
||||||
<>
|
<div class="flex items-center gap-2">
|
||||||
<div class="flex items-center gap-2">
|
<span class="text-text-invert-strong">{ctx().usage ?? 0}%</span>
|
||||||
<span class="text-text-invert-strong">{ctx().total.toLocaleString(language.intl())}</span>
|
<span class="text-text-invert-base">{language.t("context.usage.usage")}</span>
|
||||||
<span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-text-invert-strong">{ctx().usage ?? 0}%</span>
|
|
||||||
<span class="text-text-invert-base">{language.t("context.usage.usage")}</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||||
import { getSessionContextMetrics } from "./session-context-metrics"
|
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
|
||||||
|
|
||||||
const assistant = (
|
const assistant = (
|
||||||
id: string,
|
id: string,
|
||||||
|
|
@ -37,8 +37,8 @@ const user = (id: string) => {
|
||||||
} as unknown as Message
|
} as unknown as Message
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("getSessionContextMetrics", () => {
|
describe("getSessionContext", () => {
|
||||||
test("computes totals and usage from latest assistant with tokens", () => {
|
test("computes usage from latest assistant with tokens", () => {
|
||||||
const messages = [
|
const messages = [
|
||||||
user("u1"),
|
user("u1"),
|
||||||
assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5),
|
assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5),
|
||||||
|
|
@ -57,45 +57,52 @@ describe("getSessionContextMetrics", () => {
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const metrics = getSessionContextMetrics(messages, providers)
|
const ctx = getSessionContext(messages, providers)
|
||||||
|
|
||||||
expect(metrics.totalCost).toBe(1.75)
|
expect(ctx?.message.id).toBe("a2")
|
||||||
expect(metrics.context?.message.id).toBe("a2")
|
expect(ctx?.usage).toBe(50)
|
||||||
expect(metrics.context?.total).toBe(500)
|
expect(ctx?.providerLabel).toBe("OpenAI")
|
||||||
expect(metrics.context?.usage).toBe(50)
|
expect(ctx?.modelLabel).toBe("GPT-4.1")
|
||||||
expect(metrics.context?.providerLabel).toBe("OpenAI")
|
|
||||||
expect(metrics.context?.modelLabel).toBe("GPT-4.1")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("preserves fallback labels and null usage when model metadata is missing", () => {
|
test("preserves fallback labels and null usage when model metadata is missing", () => {
|
||||||
const messages = [assistant("a1", { input: 40, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1, "p-1", "m-1")]
|
const messages = [assistant("a1", { input: 40, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1, "p-1", "m-1")]
|
||||||
const providers = [{ id: "p-1", models: {} }]
|
const providers = [{ id: "p-1", models: {} }]
|
||||||
|
|
||||||
const metrics = getSessionContextMetrics(messages, providers)
|
const ctx = getSessionContext(messages, providers)
|
||||||
|
|
||||||
expect(metrics.context?.providerLabel).toBe("p-1")
|
expect(ctx?.providerLabel).toBe("p-1")
|
||||||
expect(metrics.context?.modelLabel).toBe("m-1")
|
expect(ctx?.modelLabel).toBe("m-1")
|
||||||
expect(metrics.context?.limit).toBeUndefined()
|
expect(ctx?.limit).toBeUndefined()
|
||||||
expect(metrics.context?.usage).toBeNull()
|
expect(ctx?.usage).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("recomputes when message array is mutated in place", () => {
|
test("recomputes when message array is mutated in place", () => {
|
||||||
const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)]
|
const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)]
|
||||||
const providers = [{ id: "openai", models: {} }]
|
const providers = [{ id: "openai", models: {} }]
|
||||||
|
|
||||||
const one = getSessionContextMetrics(messages, providers)
|
const one = getSessionContext(messages, providers)
|
||||||
messages.push(assistant("a2", { input: 100, output: 20, reasoning: 0, read: 0, write: 0 }, 0.75))
|
messages.push(assistant("a2", { input: 100, output: 20, reasoning: 0, read: 0, write: 0 }, 0.75))
|
||||||
const two = getSessionContextMetrics(messages, providers)
|
const two = getSessionContext(messages, providers)
|
||||||
|
|
||||||
expect(one.context?.message.id).toBe("a1")
|
expect(one?.message.id).toBe("a1")
|
||||||
expect(two.context?.message.id).toBe("a2")
|
expect(two?.message.id).toBe("a2")
|
||||||
expect(two.totalCost).toBe(1)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("returns empty metrics when inputs are undefined", () => {
|
test("returns undefined when inputs are undefined", () => {
|
||||||
const metrics = getSessionContextMetrics(undefined, undefined)
|
const ctx = getSessionContext(undefined, undefined)
|
||||||
|
|
||||||
expect(metrics.totalCost).toBe(0)
|
expect(ctx).toBeUndefined()
|
||||||
expect(metrics.context).toBeUndefined()
|
})
|
||||||
|
|
||||||
|
test("computes stored session token totals", () => {
|
||||||
|
expect(
|
||||||
|
getSessionTokenTotal({
|
||||||
|
input: 10,
|
||||||
|
output: 20,
|
||||||
|
reasoning: 30,
|
||||||
|
cache: { read: 40, write: 50 },
|
||||||
|
}),
|
||||||
|
).toBe(150)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client"
|
import type { AssistantMessage, Message, Session } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
type Provider = {
|
type Provider = {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -21,19 +21,9 @@ type Context = {
|
||||||
modelLabel: string
|
modelLabel: string
|
||||||
limit: number | undefined
|
limit: number | undefined
|
||||||
input: number
|
input: number
|
||||||
output: number
|
|
||||||
reasoning: number
|
|
||||||
cacheRead: number
|
|
||||||
cacheWrite: number
|
|
||||||
total: number
|
|
||||||
usage: number | null
|
usage: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type Metrics = {
|
|
||||||
totalCost: number
|
|
||||||
context: Context | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokenTotal = (msg: AssistantMessage) => {
|
const tokenTotal = (msg: AssistantMessage) => {
|
||||||
return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
|
return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
|
||||||
}
|
}
|
||||||
|
|
@ -47,10 +37,9 @@ const lastAssistantWithTokens = (messages: Message[]) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const build = (messages: Message[] = [], providers: Provider[] = []): Metrics => {
|
const build = (messages: Message[] = [], providers: Provider[] = []): Context | undefined => {
|
||||||
const totalCost = messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0)
|
|
||||||
const message = lastAssistantWithTokens(messages)
|
const message = lastAssistantWithTokens(messages)
|
||||||
if (!message) return { totalCost, context: undefined }
|
if (!message) return undefined
|
||||||
|
|
||||||
const provider = providers.find((item) => item.id === message.providerID)
|
const provider = providers.find((item) => item.id === message.providerID)
|
||||||
const model = provider?.models[message.modelID]
|
const model = provider?.models[message.modelID]
|
||||||
|
|
@ -58,25 +47,22 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Metrics =>
|
||||||
const total = tokenTotal(message)
|
const total = tokenTotal(message)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalCost,
|
message,
|
||||||
context: {
|
provider,
|
||||||
message,
|
model,
|
||||||
provider,
|
providerLabel: provider?.name ?? message.providerID,
|
||||||
model,
|
modelLabel: model?.name ?? message.modelID,
|
||||||
providerLabel: provider?.name ?? message.providerID,
|
limit,
|
||||||
modelLabel: model?.name ?? message.modelID,
|
input: message.tokens.input,
|
||||||
limit,
|
usage: limit ? Math.round((total / limit) * 100) : null,
|
||||||
input: message.tokens.input,
|
|
||||||
output: message.tokens.output,
|
|
||||||
reasoning: message.tokens.reasoning,
|
|
||||||
cacheRead: message.tokens.cache.read,
|
|
||||||
cacheWrite: message.tokens.cache.write,
|
|
||||||
total,
|
|
||||||
usage: limit ? Math.round((total / limit) * 100) : null,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSessionContextMetrics(messages: Message[] = [], providers: Provider[] = []) {
|
export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) {
|
||||||
return build(messages, providers)
|
return build(messages, providers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSessionTokenTotal(tokens: Session["tokens"] | undefined) {
|
||||||
|
if (!tokens) return undefined
|
||||||
|
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import { useLanguage } from "@/context/language"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { getSessionContextMetrics } from "./session-context-metrics"
|
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
|
||||||
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
||||||
import { createSessionContextFormatter } from "./session-context-format"
|
import { createSessionContextFormatter } from "./session-context-format"
|
||||||
|
|
||||||
|
|
@ -134,12 +134,12 @@ export function SessionContextTab() {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()]))
|
const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
|
||||||
const ctx = createMemo(() => metrics().context)
|
const tokens = createMemo(() => info()?.tokens)
|
||||||
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
|
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
|
||||||
|
|
||||||
const cost = createMemo(() => {
|
const cost = createMemo(() => {
|
||||||
return usd().format(metrics().totalCost)
|
return usd().format(info()?.cost ?? 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
const counts = createMemo(() => {
|
const counts = createMemo(() => {
|
||||||
|
|
@ -204,14 +204,14 @@ export function SessionContextTab() {
|
||||||
{ label: "context.stats.provider", value: providerLabel },
|
{ label: "context.stats.provider", value: providerLabel },
|
||||||
{ label: "context.stats.model", value: modelLabel },
|
{ label: "context.stats.model", value: modelLabel },
|
||||||
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
|
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
|
||||||
{ label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) },
|
{ label: "context.stats.totalTokens", value: () => formatter().number(getSessionTokenTotal(tokens())) },
|
||||||
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
|
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
|
||||||
{ label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) },
|
{ label: "context.stats.inputTokens", value: () => formatter().number(tokens()?.input) },
|
||||||
{ label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.output) },
|
{ label: "context.stats.outputTokens", value: () => formatter().number(tokens()?.output) },
|
||||||
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.reasoning) },
|
{ label: "context.stats.reasoningTokens", value: () => formatter().number(tokens()?.reasoning) },
|
||||||
{
|
{
|
||||||
label: "context.stats.cacheTokens",
|
label: "context.stats.cacheTokens",
|
||||||
value: () => `${formatter().number(ctx()?.cacheRead)} / ${formatter().number(ctx()?.cacheWrite)}`,
|
value: () => `${formatter().number(tokens()?.cache.read)} / ${formatter().number(tokens()?.cache.write)}`,
|
||||||
},
|
},
|
||||||
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
|
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
|
||||||
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
|
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue