From 73567f3570a4987bf3d523b19de15528fda95b25 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:10:30 -0500 Subject: [PATCH] feat(core): record title and compaction usage in session totals (#37441) --- .../client/src/promise/generated/types.ts | 11 ++++ packages/core/src/session.ts | 11 ++-- packages/core/src/session/compaction.ts | 41 +++++++++++--- packages/core/src/session/message-updater.ts | 1 + packages/core/src/session/projector.ts | 3 +- packages/core/src/session/runner/llm.ts | 34 ++---------- .../src/session/runner/publish-llm-event.ts | 23 ++------ packages/core/src/session/title.ts | 19 ++++++- packages/core/src/session/usage.ts | 55 +++++++++++++++++++ packages/core/test/session-compaction.test.ts | 37 ++++++++++++- packages/core/test/session-runner.test.ts | 5 +- packages/core/test/session-title.test.ts | 36 +++++++++++- packages/schema/src/session-event.ts | 18 +++++- packages/schema/test/event-manifest.test.ts | 14 ++++- 14 files changed, 234 insertions(+), 74 deletions(-) create mode 100644 packages/core/src/session/usage.ts diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 25241fb908..a6f20cf2f9 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -803,6 +803,16 @@ export type SessionRevertCommitted = { 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 = { id: string created: number @@ -2216,6 +2226,7 @@ export type SessionEventDurable = | SessionRevertStaged | SessionRevertCleared | SessionRevertCommitted + | SessionUsageRecorded export type SessionMessagesResponse = { data: Array diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index e2c27f58b4..73fd80e141 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -210,12 +210,11 @@ export interface Interface { */ readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect /** - * Durable, ordered session log read. Replays public durable - * session events after the exclusive `after` cursor, emits a `Synced` - * marker at the captured replay watermark, then continues live when `follow` - * is set. - * The marker's seq may exceed the last emitted event because non-public - * durable events share the aggregate's sequence space. + * Durable, ordered session log read. Replays durable session events after + * the exclusive `after` cursor, emits a `Synced` marker at the captured + * replay watermark, then continues live when `follow` is set. + * The marker's seq may exceed the last emitted event because other durable + * events share the aggregate's sequence space. */ readonly log: (input: { sessionID: SessionSchema.ID diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 547d5797bd..b49fc50ba8 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -14,6 +14,8 @@ import { SessionRunnerModel } from "./runner/model" import { SessionSchema } from "./schema" import { toSessionError } from "./to-session-error" import { Token } from "../util/token" +import type { ModelV2 } from "../model" +import { SessionUsage } from "./usage" const DEFAULT_BUFFER = 20_000 const DEFAULT_KEEP_TOKENS = 8_000 @@ -70,6 +72,7 @@ export type AutoInput = { readonly session: SessionSchema.Info readonly messages: readonly SessionMessage.Info[] readonly model: Model + readonly cost: ModelV2.Info["cost"] } export type ManualInput = { @@ -81,6 +84,7 @@ export type ManualInput = { type Plan = { readonly session: SessionSchema.Info readonly model: Model + readonly cost: ModelV2.Info["cost"] readonly reason: SessionMessage.Compaction["reason"] readonly prompt: string readonly recent: string @@ -240,6 +244,16 @@ const make = (dependencies: Dependencies) => { const chunks: string[] = [] 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 .stream( LLM.request({ @@ -263,6 +277,10 @@ const make = (dependencies: Dependencies) => { 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 }), Effect.catchTag("LLM.Error", (error) => @@ -271,16 +289,21 @@ const make = (dependencies: Dependencies) => { }), ), Effect.onInterrupt(() => - plan.reason === "auto" - ? failed({ - sessionID: plan.session.id, - reason: plan.reason, - error: { type: "compaction.interrupted", message: "Compaction was interrupted" }, - inputID: plan.inputID, - }).pipe(Effect.asVoid) - : Effect.void, + recordUsage.pipe( + Effect.andThen( + plan.reason === "auto" + ? failed({ + sessionID: plan.session.id, + reason: plan.reason, + error: { type: "compaction.interrupted", message: "Compaction was interrupted" }, + inputID: plan.inputID, + }).pipe(Effect.asVoid) + : Effect.void, + ), + ), ), ) + yield* recordUsage const summary = chunks.join("") if (failure || !summary.trim()) { const error = failure ?? { type: "compaction.failed" as const, message: "Compaction produced no summary" } @@ -305,6 +328,7 @@ const make = (dependencies: Dependencies) => { return yield* execute({ session: input.session, model: input.model, + cost: input.cost, reason: "auto", ...content, }) @@ -353,6 +377,7 @@ const make = (dependencies: Dependencies) => { return yield* execute({ session: input.session, model: resolved.model, + cost: resolved.cost, reason: "manual", inputID: input.inputID, ...content, diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 2f7f1d3d12..e4c895284c 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -142,6 +142,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { return Effect.gen(function* () { yield* SessionEvent.All.match(event, { "session.usage.updated": () => Effect.void, + "session.usage.recorded": () => Effect.void, "session.agent.selected": (event) => { return adapter.appendMessage( SessionMessage.AgentSelected.make({ diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 3c3dccee6f..62bd34ecc6 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -609,6 +609,7 @@ const layer = Layer.effectDiscard( .run() .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.InputPromoted, (event) => Effect.gen(function* () { @@ -781,7 +782,7 @@ const layer = Layer.effectDiscard( 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) => { if ( event.type === SessionEvent.Step.Failed.type && diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 63dc59dbdd..3495cfdc4f 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -2,11 +2,9 @@ export * as SessionRunnerLLM from "./llm" import { LLMClient, LLMError, LLMEvent, isContextOverflowFailure, type ProviderErrorEvent } from "@opencode-ai/ai" 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 { Database } from "../../database/database" import { EventV2 } from "../../event" -import { ModelV2 } from "../../model" import { PermissionV2 } from "../../permission" import { QuestionTool } from "../../tool/question" import { ToolOutputStore } from "../../tool-output-store" @@ -28,30 +26,7 @@ import { llmClient } from "../../effect/app-node-platform" import { StepFailedError } from "../error" import { toSessionError } from "../to-session-error" import { SessionRunnerRetry } from "./retry" - -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, - ) -} +import { SessionUsage } from "../usage" const layer = Layer.effect( Service, @@ -127,7 +102,7 @@ const layer = Layer.effect( const agent = loaded.agent const resolved = loaded.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))) { const compacted = yield* compaction.compact(compactionInput) if (compacted.status === "completed") return { _tag: "RestartAfterCompaction", step: currentStep } as const @@ -216,7 +191,7 @@ const layer = Layer.effect( ) const stepUsage = (settlement: NonNullable>) => ({ - cost: calculateCost(resolved.cost, settlement.tokens), + cost: SessionUsage.calculateCost(resolved.cost, settlement.tokens), tokens: settlement.tokens, }) @@ -258,7 +233,8 @@ const layer = Layer.effect( recoverOverflow && !publisher.hasRetryEvidence() && 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 diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index 35d80d7329..6a71f5d888 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -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 { EventV2 } from "../../event" import { ModelV2 } from "../../model" @@ -9,6 +9,7 @@ import { SessionError } from "@opencode-ai/schema/session-error" import { Money } from "@opencode-ai/schema/money" import { AgentV2 } from "../../agent" import { Snapshot } from "../../snapshot" +import { SessionUsage } from "../usage" type Input = { readonly sessionID: SessionSchema.ID @@ -19,20 +20,6 @@ type Input = { 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 => typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) : { value } @@ -77,7 +64,7 @@ export const createLLMEventPublisher = (events: Pick["reason"] - readonly tokens: ReturnType + readonly tokens: ReturnType } | undefined @@ -251,7 +238,7 @@ export const createLLMEventPublisher = (events: Pick + readonly tokens: ReturnType }) { if (stepFailed || stepFailure === undefined) return const assistantMessageID = yield* startAssistant() @@ -429,7 +416,7 @@ export const createLLMEventPublisher = (events: Pick { if (!resolved) return const chunks: string[] = [] 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 .stream( LLM.request({ @@ -65,11 +76,17 @@ const make = (dependencies: Dependencies) => { Stream.runForEach((event) => { if (LLMEvent.is.providerError(event)) failed = true 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 }), Effect.as(true), Effect.catchTag("LLM.Error", () => Effect.succeed(false)), + Effect.onInterrupt(() => recordUsage.pipe(Effect.asVoid)), ) + yield* recordUsage if (!streamed || failed) return const title = chunks .join("") diff --git a/packages/core/src/session/usage.ts b/packages/core/src/session/usage.ts new file mode 100644 index 0000000000..33db0926e1 --- /dev/null +++ b/packages/core/src/session/usage.ts @@ -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, + }, + }, +}) diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index f9cf2e4c69..ad3e0a2bfb 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -21,6 +21,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql" import { Flag } from "@opencode-ai/core/flag/flag" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { AbsolutePath } from "@opencode-ai/core/schema" +import { Money } from "@opencode-ai/schema/money" import { DateTime, Effect, Fiber, Layer, Stream } from "effect" import { asc, eq } from "drizzle-orm" import { testEffect } from "./lib/effect" @@ -31,17 +32,44 @@ const model = Model.make({ provider: "test", 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)({ prepare: () => Effect.die("unused"), stream: (request: LLMRequest) => { 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"), }) const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) }) const models = Layer.mock(SessionRunnerModel.Service)({ - resolve: () => Effect.succeed(SessionRunnerModel.resolved(model)), + resolve: () => Effect.succeed(SessionRunnerModel.resolved(model, undefined, cost)), }) const it = testEffect( AppNodeBuilder.build( @@ -169,6 +197,10 @@ it.effect("manual compaction summarizes short context instead of no-op", () => expect(yield* store.context(sessionID)).toMatchObject([ { 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( yield* db .select({ type: EventTable.type }) @@ -179,6 +211,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () => .pipe(Effect.orDie), ).toEqual([ { type: EventV2.versionedType(SessionEvent.Compaction.Started.type, 1) }, + { type: EventV2.versionedType(SessionEvent.UsageRecorded.type, 1) }, { type: EventV2.versionedType(SessionEvent.Compaction.Ended.type, 1) }, ]) }), diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 0bb14b020f..39cf62e847 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -42,6 +42,7 @@ import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator import { SessionRunner } from "@opencode-ai/core/session/runner" import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm" 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 { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" 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", () => { expect( - SessionRunnerLLM.calculateCost( + SessionUsage.calculateCost( [ { 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", () => { expect( - SessionRunnerLLM.calculateCost( + SessionUsage.calculateCost( [ { tier: { type: "context", size: 100 }, diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index dccfaf63df..cf3d7f4ea0 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -20,7 +20,8 @@ import { ProjectTable } from "@opencode-ai/core/project/sql" import { Flag } from "@opencode-ai/core/flag/flag" import { InstallationVersion } from "@opencode-ai/core/installation/version" 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" let requests: LLMRequest[] = [] @@ -29,16 +30,43 @@ const model = Model.make({ provider: "test", 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)({ prepare: () => Effect.die("unused"), stream: (request: LLMRequest) => { 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"), }) const models = Layer.mock(SessionRunnerModel.Service)({ - resolve: () => Effect.succeed(SessionRunnerModel.resolved(model)), + resolve: () => Effect.succeed(SessionRunnerModel.resolved(model, undefined, cost)), }) const it = testEffect( 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") const renamed = yield* store.get(sessionID) 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) }), ) diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index 92a78273d1..aaeb6565c3 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -86,6 +86,18 @@ export const Renamed = Event.durable({ }) 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({ type: "session.usage.updated", schema: { @@ -569,8 +581,10 @@ export const Definitions = Event.inventory( RevertEvent.Committed, ) +// UsageRecorded is durable but internal: excluded from Definitions so it never reaches the public manifest. export const DurableDefinitions = Event.inventory( ...Definitions.filter((definition) => definition.durability === "durable"), + UsageRecorded, ) 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" }) 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 Type = Event["type"] diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index 0df33ec8c3..512528f556 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -104,6 +104,7 @@ describe("public event manifest", () => { "session.model.selected.1", "session.moved.1", "session.renamed.1", + "session.usage.recorded.1", "session.forked.2", "session.input.promoted.1", "session.input.admitted.1", @@ -139,9 +140,16 @@ describe("public event manifest", () => { "session.revert.committed.1", ].toSorted(), ) - expect(SessionEvent.DurableDefinitions).toEqual( - SessionEvent.Definitions.filter((definition) => definition.durability === "durable"), - ) + expect(SessionEvent.DurableDefinitions).toEqual([ + ...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.Compaction.Delta.durability).toBe("ephemeral") expect(EventManifest.Durable.has("session.compaction.delta.1")).toBe(false)