diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index f265aa6fc9..55ad95eab0 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -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)) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 0aeaa0e052..75eb368e33 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -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, diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 2b81e609ea..793868f89e 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -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()("@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, } }), }) diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index 1d77303d43..ba0d9fde19 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -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, + }) }), ) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 6856b6a7ce..80ad249f3a 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -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( diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 82a7150c9e..e98b8c5733 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -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 diff --git a/packages/tui/src/feature-plugins/sidebar/context.tsx b/packages/tui/src/feature-plugins/sidebar/context.tsx index f1c99d9679..071821247a 100644 --- a/packages/tui/src/feature-plugins/sidebar/context.tsx +++ b/packages/tui/src/feature-plugins/sidebar/context.tsx @@ -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, diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index d9e82c2d3c..872e386bf2 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -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 @@ -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",