feat(core): record title and compaction usage in session totals (#37441)

This commit is contained in:
Aiden Cline 2026-07-17 00:10:30 -05:00 committed by GitHub
commit 73567f3570
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 234 additions and 74 deletions

View file

@ -803,6 +803,16 @@ export type SessionRevertCommitted = {
data: { sessionID: string; to: string } data: { sessionID: string; to: string }
} }
export type SessionUsageRecorded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.usage.recorded"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; source: "title" | "compaction"; cost: MoneyUSD; tokens: TokenUsageInfo }
}
export type ModelsDevRefreshed = { export type ModelsDevRefreshed = {
id: string id: string
created: number created: number
@ -2216,6 +2226,7 @@ export type SessionEventDurable =
| SessionRevertStaged | SessionRevertStaged
| SessionRevertCleared | SessionRevertCleared
| SessionRevertCommitted | SessionRevertCommitted
| SessionUsageRecorded
export type SessionMessagesResponse = { export type SessionMessagesResponse = {
data: Array<SessionMessageInfo> data: Array<SessionMessageInfo>

View file

@ -210,12 +210,11 @@ export interface Interface {
*/ */
readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError> readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError>
/** /**
* Durable, ordered session log read. Replays public durable * Durable, ordered session log read. Replays durable session events after
* session events after the exclusive `after` cursor, emits a `Synced` * the exclusive `after` cursor, emits a `Synced` marker at the captured
* marker at the captured replay watermark, then continues live when `follow` * replay watermark, then continues live when `follow` is set.
* is set. * The marker's seq may exceed the last emitted event because other durable
* The marker's seq may exceed the last emitted event because non-public * events share the aggregate's sequence space.
* durable events share the aggregate's sequence space.
*/ */
readonly log: (input: { readonly log: (input: {
sessionID: SessionSchema.ID sessionID: SessionSchema.ID

View file

@ -14,6 +14,8 @@ import { SessionRunnerModel } from "./runner/model"
import { SessionSchema } from "./schema" import { SessionSchema } from "./schema"
import { toSessionError } from "./to-session-error" import { toSessionError } from "./to-session-error"
import { Token } from "../util/token" import { Token } from "../util/token"
import type { ModelV2 } from "../model"
import { SessionUsage } from "./usage"
const DEFAULT_BUFFER = 20_000 const DEFAULT_BUFFER = 20_000
const DEFAULT_KEEP_TOKENS = 8_000 const DEFAULT_KEEP_TOKENS = 8_000
@ -70,6 +72,7 @@ export type AutoInput = {
readonly session: SessionSchema.Info readonly session: SessionSchema.Info
readonly messages: readonly SessionMessage.Info[] readonly messages: readonly SessionMessage.Info[]
readonly model: Model readonly model: Model
readonly cost: ModelV2.Info["cost"]
} }
export type ManualInput = { export type ManualInput = {
@ -81,6 +84,7 @@ export type ManualInput = {
type Plan = { type Plan = {
readonly session: SessionSchema.Info readonly session: SessionSchema.Info
readonly model: Model readonly model: Model
readonly cost: ModelV2.Info["cost"]
readonly reason: SessionMessage.Compaction["reason"] readonly reason: SessionMessage.Compaction["reason"]
readonly prompt: string readonly prompt: string
readonly recent: string readonly recent: string
@ -240,6 +244,16 @@ const make = (dependencies: Dependencies) => {
const chunks: string[] = [] const chunks: string[] = []
let failure: SessionError.Error | undefined let failure: SessionError.Error | undefined
let usage: SessionUsage.Recorded | undefined
const recordUsage = Effect.suspend(() =>
usage
? dependencies.events.publish(SessionEvent.UsageRecorded, {
sessionID: plan.session.id,
source: "compaction",
...usage,
})
: Effect.void,
)
yield* dependencies.llm yield* dependencies.llm
.stream( .stream(
LLM.request({ LLM.request({
@ -263,6 +277,10 @@ const make = (dependencies: Dependencies) => {
text: event.text, text: event.text,
}) })
} }
if (LLMEvent.is.stepFinish(event)) {
const step = SessionUsage.record(event.usage, plan.cost)
usage = usage ? SessionUsage.add(usage, step) : step
}
return Effect.void return Effect.void
}), }),
Effect.catchTag("LLM.Error", (error) => Effect.catchTag("LLM.Error", (error) =>
@ -271,16 +289,21 @@ const make = (dependencies: Dependencies) => {
}), }),
), ),
Effect.onInterrupt(() => Effect.onInterrupt(() =>
plan.reason === "auto" recordUsage.pipe(
? failed({ Effect.andThen(
sessionID: plan.session.id, plan.reason === "auto"
reason: plan.reason, ? failed({
error: { type: "compaction.interrupted", message: "Compaction was interrupted" }, sessionID: plan.session.id,
inputID: plan.inputID, reason: plan.reason,
}).pipe(Effect.asVoid) error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
: Effect.void, inputID: plan.inputID,
}).pipe(Effect.asVoid)
: Effect.void,
),
),
), ),
) )
yield* recordUsage
const summary = chunks.join("") const summary = chunks.join("")
if (failure || !summary.trim()) { if (failure || !summary.trim()) {
const error = failure ?? { type: "compaction.failed" as const, message: "Compaction produced no summary" } const error = failure ?? { type: "compaction.failed" as const, message: "Compaction produced no summary" }
@ -305,6 +328,7 @@ const make = (dependencies: Dependencies) => {
return yield* execute({ return yield* execute({
session: input.session, session: input.session,
model: input.model, model: input.model,
cost: input.cost,
reason: "auto", reason: "auto",
...content, ...content,
}) })
@ -353,6 +377,7 @@ const make = (dependencies: Dependencies) => {
return yield* execute({ return yield* execute({
session: input.session, session: input.session,
model: resolved.model, model: resolved.model,
cost: resolved.cost,
reason: "manual", reason: "manual",
inputID: input.inputID, inputID: input.inputID,
...content, ...content,

View file

@ -142,6 +142,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
return Effect.gen(function* () { return Effect.gen(function* () {
yield* SessionEvent.All.match(event, { yield* SessionEvent.All.match(event, {
"session.usage.updated": () => Effect.void, "session.usage.updated": () => Effect.void,
"session.usage.recorded": () => Effect.void,
"session.agent.selected": (event) => { "session.agent.selected": (event) => {
return adapter.appendMessage( return adapter.appendMessage(
SessionMessage.AgentSelected.make({ SessionMessage.AgentSelected.make({

View file

@ -609,6 +609,7 @@ const layer = Layer.effectDiscard(
.run() .run()
.pipe(Effect.orDie), .pipe(Effect.orDie),
) )
yield* events.project(SessionEvent.UsageRecorded, (event) => applyUsage(db, event.data.sessionID, event.data))
yield* events.project(SessionEvent.Forked, (event) => projectFork(db, event)) yield* events.project(SessionEvent.Forked, (event) => projectFork(db, event))
yield* events.project(SessionEvent.InputPromoted, (event) => yield* events.project(SessionEvent.InputPromoted, (event) =>
Effect.gen(function* () { Effect.gen(function* () {
@ -781,7 +782,7 @@ const layer = Layer.effectDiscard(
yield* InstructionState.reset(db, event.data.sessionID) yield* InstructionState.reset(db, event.data.sessionID)
}), }),
) )
yield* events.subscribe([SessionEvent.Step.Ended, SessionEvent.Step.Failed]).pipe( yield* events.subscribe([SessionEvent.Step.Ended, SessionEvent.Step.Failed, SessionEvent.UsageRecorded]).pipe(
Stream.runForEach((event) => { Stream.runForEach((event) => {
if ( if (
event.type === SessionEvent.Step.Failed.type && event.type === SessionEvent.Step.Failed.type &&

View file

@ -2,11 +2,9 @@ export * as SessionRunnerLLM from "./llm"
import { LLMClient, LLMError, LLMEvent, isContextOverflowFailure, type ProviderErrorEvent } from "@opencode-ai/ai" import { LLMClient, LLMError, LLMEvent, isContextOverflowFailure, type ProviderErrorEvent } from "@opencode-ai/ai"
import { SessionError } from "@opencode-ai/schema/session-error" import { SessionError } from "@opencode-ai/schema/session-error"
import { Money } from "@opencode-ai/schema/money"
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect" import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
import { Database } from "../../database/database" import { Database } from "../../database/database"
import { EventV2 } from "../../event" import { EventV2 } from "../../event"
import { ModelV2 } from "../../model"
import { PermissionV2 } from "../../permission" import { PermissionV2 } from "../../permission"
import { QuestionTool } from "../../tool/question" import { QuestionTool } from "../../tool/question"
import { ToolOutputStore } from "../../tool-output-store" import { ToolOutputStore } from "../../tool-output-store"
@ -28,30 +26,7 @@ import { llmClient } from "../../effect/app-node-platform"
import { StepFailedError } from "../error" import { StepFailedError } from "../error"
import { toSessionError } from "../to-session-error" import { toSessionError } from "../to-session-error"
import { SessionRunnerRetry } from "./retry" import { SessionRunnerRetry } from "./retry"
import { SessionUsage } from "../usage"
type StepTokens = {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
// TODO(#35765): Use Copilot's reported billed amount once billing has a dedicated typed runtime contract.
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)
if (!cost) return Money.USD.zero
return Money.USD.make(
(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,
)
}
const layer = Layer.effect( const layer = Layer.effect(
Service, Service,
@ -127,7 +102,7 @@ const layer = Layer.effect(
const agent = loaded.agent const agent = loaded.agent
const resolved = loaded.model const resolved = loaded.model
const model = resolved.model const model = resolved.model
const compactionInput = { session, messages: loaded.messages, model } const compactionInput = { session, messages: loaded.messages, model, cost: resolved.cost }
if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) { if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) {
const compacted = yield* compaction.compact(compactionInput) const compacted = yield* compaction.compact(compactionInput)
if (compacted.status === "completed") return { _tag: "RestartAfterCompaction", step: currentStep } as const if (compacted.status === "completed") return { _tag: "RestartAfterCompaction", step: currentStep } as const
@ -216,7 +191,7 @@ const layer = Layer.effect(
) )
const stepUsage = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) => ({ const stepUsage = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) => ({
cost: calculateCost(resolved.cost, settlement.tokens), cost: SessionUsage.calculateCost(resolved.cost, settlement.tokens),
tokens: settlement.tokens, tokens: settlement.tokens,
}) })
@ -258,7 +233,8 @@ const layer = Layer.effect(
recoverOverflow && recoverOverflow &&
!publisher.hasRetryEvidence() && !publisher.hasRetryEvidence() &&
isContextOverflowFailure(overflowFailure ?? streamFailure) && isContextOverflowFailure(overflowFailure ?? streamFailure) &&
(yield* restore(recoverOverflow({ session, messages: loaded.messages, model }))).status === "completed" (yield* restore(recoverOverflow({ session, messages: loaded.messages, model, cost: resolved.cost })))
.status === "completed"
) )
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const

View file

@ -1,4 +1,4 @@
import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue, type Usage } from "@opencode-ai/ai" import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai"
import { Effect } from "effect" import { Effect } from "effect"
import { EventV2 } from "../../event" import { EventV2 } from "../../event"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
@ -9,6 +9,7 @@ import { SessionError } from "@opencode-ai/schema/session-error"
import { Money } from "@opencode-ai/schema/money" import { Money } from "@opencode-ai/schema/money"
import { AgentV2 } from "../../agent" import { AgentV2 } from "../../agent"
import { Snapshot } from "../../snapshot" import { Snapshot } from "../../snapshot"
import { SessionUsage } from "../usage"
type Input = { type Input = {
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
@ -19,20 +20,6 @@ type Input = {
readonly assistantMessageID?: SessionMessage.ID readonly assistantMessageID?: SessionMessage.ID
} }
const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0)
const tokens = (usage: Usage | undefined) => {
const reasoning = safe(usage?.reasoningTokens)
const read = safe(usage?.cacheReadInputTokens)
const write = safe(usage?.cacheWriteInputTokens)
return {
input: safe(usage?.nonCachedInputTokens),
output: safe(usage?.visibleOutputTokens),
reasoning,
cache: { read, write },
}
}
const record = (value: unknown): Record<string, unknown> => const record = (value: unknown): Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : { value } typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : { value }
@ -77,7 +64,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
let stepSettlement: let stepSettlement:
| { | {
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"] readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]
readonly tokens: ReturnType<typeof tokens> readonly tokens: ReturnType<typeof SessionUsage.tokens>
} }
| undefined | undefined
@ -251,7 +238,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
const publishStepFailure = Effect.fnUntraced(function* (usage?: { const publishStepFailure = Effect.fnUntraced(function* (usage?: {
readonly cost: Money.USD readonly cost: Money.USD
readonly tokens: ReturnType<typeof tokens> readonly tokens: ReturnType<typeof SessionUsage.tokens>
}) { }) {
if (stepFailed || stepFailure === undefined) return if (stepFailed || stepFailure === undefined) return
const assistantMessageID = yield* startAssistant() const assistantMessageID = yield* startAssistant()
@ -429,7 +416,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
case "step-finish": case "step-finish":
yield* flush() yield* flush()
if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish")) if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish"))
stepSettlement = { finish: event.reason, tokens: tokens(event.usage) } stepSettlement = { finish: event.reason, tokens: SessionUsage.tokens(event.usage) }
if (event.reason === "content-filter") { if (event.reason === "content-filter") {
providerFailed = true providerFailed = true
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" }, true) yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" }, true)

View file

@ -1,7 +1,7 @@
export * as SessionTitle from "./title" export * as SessionTitle from "./title"
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai" import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
import { Context, DateTime, Effect, Layer, Stream } from "effect" import { Context, Effect, Layer, Stream } from "effect"
import { AgentV2 } from "../agent" import { AgentV2 } from "../agent"
import { Database } from "../database/database" import { Database } from "../database/database"
import { EventV2 } from "../event" import { EventV2 } from "../event"
@ -12,6 +12,7 @@ import { SessionHistory } from "./history"
import { SessionModelHeaders } from "./model-headers" import { SessionModelHeaders } from "./model-headers"
import { SessionRunnerModel } from "./runner/model" import { SessionRunnerModel } from "./runner/model"
import { SessionSchema } from "./schema" import { SessionSchema } from "./schema"
import { SessionUsage } from "./usage"
const MAX_LENGTH = 100 const MAX_LENGTH = 100
@ -51,6 +52,16 @@ const make = (dependencies: Dependencies) => {
if (!resolved) return if (!resolved) return
const chunks: string[] = [] const chunks: string[] = []
let failed = false let failed = false
let usage: SessionUsage.Recorded | undefined
const recordUsage = Effect.suspend(() =>
usage
? dependencies.events.publish(SessionEvent.UsageRecorded, {
sessionID: session.id,
source: "title",
...usage,
})
: Effect.void,
)
const streamed = yield* dependencies.llm const streamed = yield* dependencies.llm
.stream( .stream(
LLM.request({ LLM.request({
@ -65,11 +76,17 @@ const make = (dependencies: Dependencies) => {
Stream.runForEach((event) => { Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event)) failed = true if (LLMEvent.is.providerError(event)) failed = true
if (LLMEvent.is.textDelta(event)) chunks.push(event.text) if (LLMEvent.is.textDelta(event)) chunks.push(event.text)
if (LLMEvent.is.stepFinish(event)) {
const step = SessionUsage.record(event.usage, resolved.cost)
usage = usage ? SessionUsage.add(usage, step) : step
}
return Effect.void return Effect.void
}), }),
Effect.as(true), Effect.as(true),
Effect.catchTag("LLM.Error", () => Effect.succeed(false)), Effect.catchTag("LLM.Error", () => Effect.succeed(false)),
Effect.onInterrupt(() => recordUsage.pipe(Effect.asVoid)),
) )
yield* recordUsage
if (!streamed || failed) return if (!streamed || failed) return
const title = chunks const title = chunks
.join("") .join("")

View file

@ -0,0 +1,55 @@
export * as SessionUsage from "./usage"
import type { Usage } from "@opencode-ai/ai"
import { Money } from "@opencode-ai/schema/money"
import type { TokenUsage } from "@opencode-ai/schema/token-usage"
import type { ModelV2 } from "../model"
const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0)
export const tokens = (usage: Usage | undefined): TokenUsage.Info => ({
input: safe(usage?.nonCachedInputTokens),
output: safe(usage?.visibleOutputTokens),
reasoning: safe(usage?.reasoningTokens),
cache: {
read: safe(usage?.cacheReadInputTokens),
write: safe(usage?.cacheWriteInputTokens),
},
})
// TODO(#35765): Use Copilot's reported billed amount once billing has a dedicated typed runtime contract.
export function calculateCost(costs: ModelV2.Info["cost"], usage: TokenUsage.Info) {
const context = usage.input + usage.cache.read + usage.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)
if (!cost) return Money.USD.zero
return Money.USD.make(
(usage.input * cost.input +
(usage.output + usage.reasoning) * cost.output +
usage.cache.read * cost.cache.read +
usage.cache.write * cost.cache.write) /
1_000_000,
)
}
export type Recorded = { readonly tokens: TokenUsage.Info; readonly cost: Money.USD }
export const record = (usage: Usage | undefined, costs: ModelV2.Info["cost"]): Recorded => {
const normalized = tokens(usage)
return { tokens: normalized, cost: calculateCost(costs, normalized) }
}
export const add = (a: Recorded, b: Recorded): Recorded => ({
cost: Money.USD.make(a.cost + b.cost),
tokens: {
input: a.tokens.input + b.tokens.input,
output: a.tokens.output + b.tokens.output,
reasoning: a.tokens.reasoning + b.tokens.reasoning,
cache: {
read: a.tokens.cache.read + b.tokens.cache.read,
write: a.tokens.cache.write + b.tokens.cache.write,
},
},
})

View file

@ -21,6 +21,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { Money } from "@opencode-ai/schema/money"
import { DateTime, Effect, Fiber, Layer, Stream } from "effect" import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
import { asc, eq } from "drizzle-orm" import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
@ -31,17 +32,44 @@ const model = Model.make({
provider: "test", provider: "test",
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }), route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
}) })
const cost = [
{
input: Money.USDPerMillionTokens.make(1),
output: Money.USDPerMillionTokens.make(2),
cache: {
read: Money.USDPerMillionTokens.make(0.1),
write: Money.USDPerMillionTokens.make(0.5),
},
},
]
const client = Layer.mock(LLMClient.Service)({ const client = Layer.mock(LLMClient.Service)({
prepare: () => Effect.die("unused"), prepare: () => Effect.die("unused"),
stream: (request: LLMRequest) => { stream: (request: LLMRequest) => {
requests.push(request) requests.push(request)
return Stream.make(LLMEvent.textDelta({ id: "summary", text: "manual summary" })) return Stream.make(
LLMEvent.textDelta({ id: "summary", text: "manual summary" }),
LLMEvent.stepFinish({
index: 0,
reason: "stop",
usage: {
inputTokens: 15,
outputTokens: 6,
nonCachedInputTokens: 10,
cacheReadInputTokens: 3,
cacheWriteInputTokens: 2,
reasoningTokens: 2,
},
}),
LLMEvent.finish({
reason: "stop",
}),
)
}, },
generate: () => Effect.die("unused"), generate: () => Effect.die("unused"),
}) })
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) }) const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const models = Layer.mock(SessionRunnerModel.Service)({ const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model)), resolve: () => Effect.succeed(SessionRunnerModel.resolved(model, undefined, cost)),
}) })
const it = testEffect( const it = testEffect(
AppNodeBuilder.build( AppNodeBuilder.build(
@ -169,6 +197,10 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
expect(yield* store.context(sessionID)).toMatchObject([ expect(yield* store.context(sessionID)).toMatchObject([
{ type: "compaction", reason: "manual", summary: "manual summary", recent: "" }, { type: "compaction", reason: "manual", summary: "manual summary", recent: "" },
]) ])
expect(yield* store.get(sessionID)).toMatchObject({
cost: 0.0000233,
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 2 } },
})
expect( expect(
yield* db yield* db
.select({ type: EventTable.type }) .select({ type: EventTable.type })
@ -179,6 +211,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
.pipe(Effect.orDie), .pipe(Effect.orDie),
).toEqual([ ).toEqual([
{ type: EventV2.versionedType(SessionEvent.Compaction.Started.type, 1) }, { type: EventV2.versionedType(SessionEvent.Compaction.Started.type, 1) },
{ type: EventV2.versionedType(SessionEvent.UsageRecorded.type, 1) },
{ type: EventV2.versionedType(SessionEvent.Compaction.Ended.type, 1) }, { type: EventV2.versionedType(SessionEvent.Compaction.Ended.type, 1) },
]) ])
}), }),

View file

@ -42,6 +42,7 @@ import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator
import { SessionRunner } from "@opencode-ai/core/session/runner" import { SessionRunner } from "@opencode-ai/core/session/runner"
import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm" import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionUsage } from "@opencode-ai/core/session/usage"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks" import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
@ -165,7 +166,7 @@ const recoveryModel = Model.make({
test("calculates step cost using the matching context tier", () => { test("calculates step cost using the matching context tier", () => {
expect( expect(
SessionRunnerLLM.calculateCost( SessionUsage.calculateCost(
[ [
{ {
input: Money.USDPerMillionTokens.make(1), input: Money.USDPerMillionTokens.make(1),
@ -192,7 +193,7 @@ test("calculates step cost using the matching context tier", () => {
test("does not apply an ineligible tier without base pricing", () => { test("does not apply an ineligible tier without base pricing", () => {
expect( expect(
SessionRunnerLLM.calculateCost( SessionUsage.calculateCost(
[ [
{ {
tier: { type: "context", size: 100 }, tier: { type: "context", size: 100 },

View file

@ -20,7 +20,8 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { DateTime, Effect, Layer, Stream } from "effect" import { Money } from "@opencode-ai/schema/money"
import { Effect, Layer, Stream } from "effect"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
let requests: LLMRequest[] = [] let requests: LLMRequest[] = []
@ -29,16 +30,43 @@ const model = Model.make({
provider: "test", provider: "test",
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }), route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
}) })
const cost = [
{
input: Money.USDPerMillionTokens.make(1),
output: Money.USDPerMillionTokens.make(2),
cache: {
read: Money.USDPerMillionTokens.make(0.1),
write: Money.USDPerMillionTokens.make(0.5),
},
},
]
const client = Layer.mock(LLMClient.Service)({ const client = Layer.mock(LLMClient.Service)({
prepare: () => Effect.die("unused"), prepare: () => Effect.die("unused"),
stream: (request: LLMRequest) => { stream: (request: LLMRequest) => {
requests.push(request) requests.push(request)
return Stream.make(LLMEvent.textDelta({ id: "title", text: "Generated Title\n" })) return Stream.make(
LLMEvent.textDelta({ id: "title", text: "Generated Title\n" }),
LLMEvent.stepFinish({
index: 0,
reason: "stop",
usage: {
inputTokens: 15,
outputTokens: 6,
nonCachedInputTokens: 10,
cacheReadInputTokens: 3,
cacheWriteInputTokens: 2,
reasoningTokens: 2,
},
}),
LLMEvent.finish({
reason: "stop",
}),
)
}, },
generate: () => Effect.die("unused"), generate: () => Effect.die("unused"),
}) })
const models = Layer.mock(SessionRunnerModel.Service)({ const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model)), resolve: () => Effect.succeed(SessionRunnerModel.resolved(model, undefined, cost)),
}) })
const it = testEffect( const it = testEffect(
AppNodeBuilder.build( AppNodeBuilder.build(
@ -130,6 +158,8 @@ it.effect("generates a title from the sole user message and renames the session"
expect(JSON.stringify(requests[0]?.messages)).toContain("Help me debug the failing build") expect(JSON.stringify(requests[0]?.messages)).toContain("Help me debug the failing build")
const renamed = yield* store.get(sessionID) const renamed = yield* store.get(sessionID)
expect(renamed?.title).toBe("Generated Title") expect(renamed?.title).toBe("Generated Title")
expect(renamed?.tokens).toEqual({ input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 2 } })
expect(renamed?.cost).toBeCloseTo(0.0000233)
}), }),
) )

View file

@ -86,6 +86,18 @@ export const Renamed = Event.durable({
}) })
export type Renamed = typeof Renamed.Type export type Renamed = typeof Renamed.Type
export const UsageRecorded = Event.durable({
type: "session.usage.recorded",
...options,
schema: {
...Base,
source: Schema.Literals(["title", "compaction"]),
cost: Money.USD,
tokens: TokenUsage.Info,
},
})
export type UsageRecorded = typeof UsageRecorded.Type
export const UsageUpdated = Event.ephemeral({ export const UsageUpdated = Event.ephemeral({
type: "session.usage.updated", type: "session.usage.updated",
schema: { schema: {
@ -569,8 +581,10 @@ export const Definitions = Event.inventory(
RevertEvent.Committed, RevertEvent.Committed,
) )
// UsageRecorded is durable but internal: excluded from Definitions so it never reaches the public manifest.
export const DurableDefinitions = Event.inventory( export const DurableDefinitions = Event.inventory(
...Definitions.filter((definition) => definition.durability === "durable"), ...Definitions.filter((definition) => definition.durability === "durable"),
UsageRecorded,
) )
export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }) export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" })
@ -578,6 +592,8 @@ export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" })
.annotate({ identifier: "Session.Event.Durable" }) .annotate({ identifier: "Session.Event.Durable" })
export type DurableEvent = typeof Durable.Type export type DurableEvent = typeof Durable.Type
export const All = Schema.Union(Definitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type")) export const All = Schema.Union(Event.inventory(...Definitions, UsageRecorded), { mode: "oneOf" }).pipe(
Schema.toTaggedUnion("type"),
)
export type Event = typeof All.Type export type Event = typeof All.Type
export type Type = Event["type"] export type Type = Event["type"]

View file

@ -104,6 +104,7 @@ describe("public event manifest", () => {
"session.model.selected.1", "session.model.selected.1",
"session.moved.1", "session.moved.1",
"session.renamed.1", "session.renamed.1",
"session.usage.recorded.1",
"session.forked.2", "session.forked.2",
"session.input.promoted.1", "session.input.promoted.1",
"session.input.admitted.1", "session.input.admitted.1",
@ -139,9 +140,16 @@ describe("public event manifest", () => {
"session.revert.committed.1", "session.revert.committed.1",
].toSorted(), ].toSorted(),
) )
expect(SessionEvent.DurableDefinitions).toEqual( expect(SessionEvent.DurableDefinitions).toEqual([
SessionEvent.Definitions.filter((definition) => definition.durability === "durable"), ...SessionEvent.Definitions.filter((definition) => definition.durability === "durable"),
) SessionEvent.UsageRecorded,
])
expect(SessionEvent.UsageRecorded.durability).toBe("durable")
expect(EventManifest.Durable.get("session.usage.recorded.1")).toBe(SessionEvent.UsageRecorded)
expect(SessionEvent.Definitions).not.toContain(SessionEvent.UsageRecorded)
expect(EventManifest.Definitions).not.toContain(SessionEvent.UsageRecorded)
expect(EventManifest.ServerDefinitions).not.toContain(SessionEvent.UsageRecorded)
expect(EventManifest.Latest.has("session.usage.recorded")).toBe(false)
expect(SessionEvent.UsageUpdated.durability).toBe("ephemeral") expect(SessionEvent.UsageUpdated.durability).toBe("ephemeral")
expect(SessionEvent.Compaction.Delta.durability).toBe("ephemeral") expect(SessionEvent.Compaction.Delta.durability).toBe("ephemeral")
expect(EventManifest.Durable.has("session.compaction.delta.1")).toBe(false) expect(EventManifest.Durable.has("session.compaction.delta.1")).toBe(false)