fix: update v2 session usage metrics

This commit is contained in:
Aiden Cline
2026-07-05 14:09:06 -05:00
parent f9d1d3b259
commit a61c5e8eee
8 changed files with 106 additions and 14 deletions
+6 -1
View File
@@ -648,7 +648,12 @@ const layer = Layer.effectDiscard(
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.Step.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Step.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.Step.Ended, (event) =>
Effect.gen(function* () {
yield* run(db, event)
yield* applyUsage(db, event.data.sessionID, event.data)
}),
)
yield* events.project(SessionEvent.Step.Failed, (event) => run(db, event))
yield* events.project(SessionEvent.Text.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Text.Ended, (event) => run(db, event))
+25 -1
View File
@@ -16,6 +16,7 @@ import { Config } from "../../config"
import { Database } from "../../database/database"
import { EventV2 } from "../../event"
import { Location } from "../../location"
import { ModelV2 } from "../../model"
import { QuestionV2 } from "../../question"
import { SystemContext } from "../../system-context/index"
import { SystemContextBuiltIns } from "../../system-context/builtins"
@@ -44,6 +45,29 @@ import { Snapshot } from "../../snapshot"
import { makeLocationNode } from "../../effect/app-node"
import { llmClient } from "../../effect/app-node-platform"
type StepTokens = {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
export function calculateCost(costs: ModelV2.Info["cost"], tokens: StepTokens) {
const context = tokens.input + tokens.cache.read + tokens.cache.write
const tier = costs
.filter((cost) => cost.tier?.type === "context" && context > cost.tier.size)
.toSorted((a, b) => (b.tier?.size ?? 0) - (a.tier?.size ?? 0))[0]
const cost = tier ?? costs.find((cost) => cost.tier === undefined) ?? costs[0]
if (!cost) return 0
return (
(tokens.input * cost.input +
(tokens.output + tokens.reasoning) * cost.output +
tokens.cache.read * cost.cache.read +
tokens.cache.write * cost.cache.write) /
1_000_000
)
}
/**
* Runs one durable coding-agent Session until it settles.
*
@@ -298,7 +322,7 @@ const layer = Layer.effect(
sessionID: session.id,
assistantMessageID: yield* publisher.startAssistant(),
finish: settlement.finish,
cost: 0,
cost: calculateCost(resolved.cost, settlement.tokens),
tokens: settlement.tokens,
snapshot: endSnapshot,
files,
+5 -1
View File
@@ -80,6 +80,8 @@ export interface Resolved {
readonly model: Model
/** Selected catalog identity. Durable records and displays must use this, never the API model id. */
readonly ref: ModelV2.Ref
/** Catalog pricing in dollars per million tokens. */
readonly cost: ModelV2.Info["cost"]
}
export interface Interface {
@@ -92,13 +94,14 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
/** Builds a Resolved whose catalog identity mirrors the route model. Test or embedding seam. */
export const resolved = (model: Model, variant?: ModelV2.VariantID): Resolved => ({
export const resolved = (model: Model, variant?: ModelV2.VariantID, cost: ModelV2.Info["cost"] = []): Resolved => ({
model,
ref: ModelV2.Ref.make({
id: ModelV2.ID.make(model.id),
providerID: ProviderV2.ID.make(model.provider),
...(variant === undefined ? {} : { variant }),
}),
cost,
})
const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => {
@@ -265,6 +268,7 @@ const layer = Layer.effect(
providerID: selected.providerID,
...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
}),
cost: selected.cost,
}
}),
})
+14 -2
View File
@@ -466,8 +466,8 @@ describe("SessionProjector", () => {
sessionID,
assistantMessageID: SessionMessage.ID.make("msg_assistant_2"),
finish: "stop",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
cost: 1.25,
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
})
const rows = yield* db
@@ -484,8 +484,20 @@ describe("SessionProjector", () => {
expect(messages[1]).toMatchObject({
type: "assistant",
finish: "stop",
cost: 1.25,
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
time: { completed: DateTime.makeUnsafe(0) },
})
expect(
yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie),
).toMatchObject({
cost: 1.25,
tokens_input: 10,
tokens_output: 4,
tokens_reasoning: 2,
tokens_cache_read: 3,
tokens_cache_write: 1,
})
}),
)
+14 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import {
LLMClient,
LLMError,
@@ -115,6 +115,19 @@ const recoveryModel = Model.make({
provider: "fake",
route: OpenAIChat.route.with({ limits: { context: 20_000, output: 1_000 } }),
})
test("calculates step cost using the matching context tier", () => {
expect(
SessionRunnerLLM.calculateCost(
[
{ input: 1, output: 2, cache: { read: 0.1, write: 0.5 } },
{ tier: { type: "context", size: 100 }, input: 3, output: 4, cache: { read: 0.2, write: 0.6 } },
],
{ input: 80, output: 10, reasoning: 2, cache: { read: 20, write: 1 } },
),
).toBeCloseTo(0.0002926)
})
const authorizations: Tool.Context[] = []
const executions: string[] = []
const permission = Layer.succeed(
+14
View File
@@ -360,6 +360,20 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
break
case "session.step.ended":
setSessionStatus(event.data.sessionID, "running")
if (store.session.info[event.data.sessionID])
setStore(
"session",
"info",
event.data.sessionID,
produce((draft) => {
draft.cost += event.data.cost
draft.tokens.input += event.data.tokens.input
draft.tokens.output += event.data.tokens.output
draft.tokens.reasoning += event.data.tokens.reasoning
draft.tokens.cache.read += event.data.tokens.cache.read
draft.tokens.cache.write += event.data.tokens.cache.write
}),
)
message.update(event.data.sessionID, (draft, index) => {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
if (!currentAssistant) return
@@ -1,7 +1,7 @@
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins"
import { createMemo } from "solid-js"
import { useData } from "../../context/data"
const id = "internal:sidebar-context"
@@ -11,14 +11,15 @@ const money = new Intl.NumberFormat("en-US", {
})
function View(props: { api: TuiPluginApi; session_id: string }) {
const data = useData()
const theme = () => props.api.theme.current
const msg = createMemo(() => props.api.state.session.messages(props.session_id))
const session = createMemo(() => props.api.state.session.get(props.session_id))
const msg = createMemo(() => data.session.message.list(props.session_id))
const session = createMemo(() => data.session.get(props.session_id))
const cost = createMemo(() => session()?.cost ?? 0)
const state = createMemo(() => {
const last = msg().findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
if (!last) {
const last = msg().findLast((item) => item.type === "assistant" && item.tokens !== undefined)
if (last?.type !== "assistant" || !last.tokens) {
return {
tokens: 0,
percent: null,
@@ -27,7 +28,9 @@ function View(props: { api: TuiPluginApi; session_id: string }) {
const tokens =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
const model = props.api.state.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
const model = data.location
.model.list(session()?.location)
?.find((model) => model.providerID === last.model.providerID && model.id === last.model.id)
return {
tokens,
percent: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : null,
+19 -2
View File
@@ -351,6 +351,18 @@ test("tracks session status from active sessions and execution events", async ()
const calls = createFetch((url) => {
if (url.pathname === "/api/session/active")
return json({ data: { "session-active": { type: "running" } }, watermarks: {} })
if (url.pathname === "/api/session/session-live")
return json({
data: {
id: "session-live",
projectID: "proj_test",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
title: "Live session",
location: { directory },
},
})
}, events)
let data!: ReturnType<typeof useData>
@@ -374,6 +386,7 @@ test("tracks session status from active sessions and execution events", async ()
try {
await wait(() => data.session.status("session-active") === "running")
expect(data.session.status("session-idle")).toBe("idle")
await data.session.refresh("session-live")
emitEvent(events, {
id: "evt_step_started",
@@ -398,8 +411,8 @@ test("tracks session status from active sessions and execution events", async ()
sessionID: "session-live",
assistantMessageID: "message-live",
finish: "stop",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
cost: 0.75,
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
},
})
await wait(() => {
@@ -407,6 +420,10 @@ test("tracks session status from active sessions and execution events", async ()
return assistant?.type === "assistant" && assistant.finish === "stop"
})
expect(data.session.status("session-live")).toBe("running")
expect(data.session.get("session-live")).toMatchObject({
cost: 0.75,
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
})
emitEvent(events, {
id: "evt_execution_settled",