refactor(core): centralize reported cost accounting

This commit is contained in:
Aiden Cline
2026-08-11 22:31:52 -05:00
parent 41fe90c63b
commit 39f7ca9152
5 changed files with 27 additions and 20 deletions
+9 -2
View File
@@ -548,8 +548,15 @@ function providerOptions(input: LLMRequest["providerOptions"]): SharedV3Provider
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsonObject(value)]))
}
interface StreamState {
step: number
toolNames: Record<string, string>
copilot: boolean
cost?: number
}
function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallOptions, copilot: boolean) {
const state = { step: 0, toolNames: {} as Record<string, string>, copilot, cost: undefined as number | undefined }
const state: StreamState = { step: 0, toolNames: {}, copilot }
return Stream.concat(
Stream.make(LLMEvent.stepStart({ index: state.step })),
Stream.unwrap(
@@ -572,7 +579,7 @@ function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallO
}
function streamPartEvents(
state: { step: number; toolNames: Record<string, string>; copilot: boolean; cost: number | undefined },
state: StreamState,
event: LanguageModelV3StreamPart,
): Effect.Effect<ReadonlyArray<LLMEvent>, AIError> {
switch (event.type) {
+2 -4
View File
@@ -272,10 +272,8 @@ const layer = Layer.effect(
snapshot: startSnapshot,
assistantMessageID,
})
const stepUsage = (finish: NonNullable<StepRecord["finish"]>) => ({
cost: SessionUsage.calculateCost(resolved.cost, finish.tokens, finish.cost),
tokens: finish.tokens,
})
const stepUsage = (finish: NonNullable<StepRecord["finish"]>) =>
SessionUsage.record(finish.usage, resolved.cost)
const captureStepEnd = Effect.fnUntraced(function* () {
const snapshot = yield* snapshots.capture()
@@ -35,8 +35,7 @@ export interface StepRecord {
/** Present once the provider finished the step normally. */
readonly finish?: {
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]["normalized"]
readonly tokens: ReturnType<typeof SessionUsage.tokens>
readonly cost?: number
readonly usage: Extract<LLMEvent, { type: "step-finish" }>["usage"]
}
readonly calls: ReadonlyArray<{
readonly id: string
@@ -496,11 +495,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
case "step-finish":
yield* flush()
if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish"))
stepSettlement = {
finish: event.reason.normalized,
tokens: SessionUsage.tokens(event.usage),
cost: event.usage?.cost,
}
stepSettlement = { finish: event.reason.normalized, usage: event.usage }
if (event.reason.normalized === "content-filter") {
providerFailed = true
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" })
+9 -3
View File
@@ -17,8 +17,7 @@ export const tokens = (usage: Usage | undefined): TokenUsage.Info => ({
},
})
export function calculateCost(costs: Model.Info["cost"], usage: TokenUsage.Info, reported?: number) {
if (reported !== undefined && Number.isFinite(reported) && reported >= 0) return Money.USD.make(reported)
export function calculateCost(costs: Model.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)
@@ -38,7 +37,14 @@ export type Recorded = { readonly tokens: TokenUsage.Info; readonly cost: Money.
export const record = (usage: Usage | undefined, costs: Model.Info["cost"]): Recorded => {
const normalized = tokens(usage)
return { tokens: normalized, cost: calculateCost(costs, normalized, usage?.cost) }
const reported = usage?.cost
return {
tokens: normalized,
cost:
reported !== undefined && Number.isFinite(reported) && reported >= 0
? Money.USD.make(reported)
: calculateCost(costs, normalized),
}
}
export const add = (a: Recorded, b: Recorded): Recorded => ({
@@ -1,7 +1,6 @@
import { expect, test } from "bun:test"
import { Cause, Effect, Exit, Schema } from "effect"
import { LLMEvent } from "@opencode-ai/ai"
import { Money } from "@opencode-ai/schema/money"
import { Bus } from "@opencode-ai/core/bus"
import { Event } from "@opencode-ai/schema/event"
import { Agent } from "@opencode-ai/core/agent"
@@ -13,6 +12,7 @@ import { Provider } from "@opencode-ai/core/provider"
import { RelativePath } from "@opencode-ai/core/schema"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publish-llm-event"
import { SessionUsage } from "@opencode-ai/core/session/usage"
const sessionID = Session.ID.make("ses_tool_event_test")
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
@@ -280,6 +280,7 @@ test("content-filter finish retains failure evidence until step closeout", async
nonCachedInputTokens: 8,
outputTokens: 3,
reasoningTokens: 1,
cost: 1.25,
},
}),
),
@@ -289,13 +290,13 @@ test("content-filter finish retains failure evidence until step closeout", async
const settlement = publisher.record().finish
expect(settlement).toMatchObject({
finish: "content-filter",
tokens: { input: 8, output: 2, reasoning: 1 },
usage: { nonCachedInputTokens: 8, outputTokens: 3, reasoningTokens: 1, cost: 1.25 },
})
if (!settlement) throw new Error("Expected content-filter settlement")
const recorded = SessionUsage.record(settlement.usage, [])
await Effect.runPromise(
publisher.publishStepFailure({
cost: Money.USD.make(1.25),
tokens: settlement.tokens,
...recorded,
snapshot: Snapshot.ID.make("tree-end"),
files: [RelativePath.make("src/changed.ts")],
}),