feat(core): record title and compaction usage in session totals (#37441)
This commit is contained in:
parent
91238441a6
commit
73567f3570
14 changed files with 234 additions and 74 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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 &&
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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("")
|
||||||
|
|
|
||||||
55
packages/core/src/session/usage.ts
Normal file
55
packages/core/src/session/usage.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -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) },
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue