fix: update v2 session usage metrics
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user