mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4630e2922e | |||
| 39f7ca9152 | |||
| 41fe90c63b |
@@ -157,6 +157,7 @@ const OpenAIChatUsage = Schema.StructWithRest(
|
|||||||
prompt_tokens: optionalNull(Schema.Number),
|
prompt_tokens: optionalNull(Schema.Number),
|
||||||
completion_tokens: optionalNull(Schema.Number),
|
completion_tokens: optionalNull(Schema.Number),
|
||||||
total_tokens: optionalNull(Schema.Number),
|
total_tokens: optionalNull(Schema.Number),
|
||||||
|
cost: optionalNull(Schema.Number),
|
||||||
prompt_tokens_details: optionalNull(
|
prompt_tokens_details: optionalNull(
|
||||||
Schema.StructWithRest(
|
Schema.StructWithRest(
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
@@ -595,6 +596,7 @@ const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
|
|||||||
cacheWriteInputTokens: cacheWrite,
|
cacheWriteInputTokens: cacheWrite,
|
||||||
reasoningTokens: reasoning,
|
reasoningTokens: reasoning,
|
||||||
totalTokens: ProviderShared.totalTokens(input, output, usage.total_tokens ?? undefined),
|
totalTokens: ProviderShared.totalTokens(input, output, usage.total_tokens ?? undefined),
|
||||||
|
cost: usage.cost ?? undefined,
|
||||||
providerMetadata: { openai: usage },
|
providerMetadata: { openai: usage },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ export class Usage extends Schema.Class<Usage>("AI.Usage")({
|
|||||||
cacheWriteInputTokens: Schema.optional(Schema.Number),
|
cacheWriteInputTokens: Schema.optional(Schema.Number),
|
||||||
reasoningTokens: Schema.optional(Schema.Number),
|
reasoningTokens: Schema.optional(Schema.Number),
|
||||||
totalTokens: Schema.optional(Schema.Number),
|
totalTokens: Schema.optional(Schema.Number),
|
||||||
|
/** Provider-reported cost for this physical request, normalized to USD. */
|
||||||
|
cost: Schema.optional(Schema.Number),
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
}) {
|
}) {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -246,6 +246,24 @@ describe("OpenRouter", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("reports OpenRouter's streamed USD cost", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const model = OpenRouter.configure({ apiKey: "test-key" }).model("openai/gpt-4o-mini")
|
||||||
|
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents({
|
||||||
|
choices: [{ delta: { content: "Hello" }, finish_reason: "stop" }],
|
||||||
|
usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12, cost: 0.00123 },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.usage?.cost).toBe(0.00123)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("fails on a mid-stream provider error", () =>
|
it.effect("fails on a mid-stream provider error", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const model = OpenRouter.configure({ apiKey: "test-key" }).model("openai/gpt-4o-mini")
|
const model = OpenRouter.configure({ apiKey: "test-key" }).model("openai/gpt-4o-mini")
|
||||||
|
|||||||
@@ -343,7 +343,8 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
|
|||||||
model: (input) =>
|
model: (input) =>
|
||||||
LanguageModel.make({ ...input, provider: "provider" in input ? input.provider : info.providerID, route }),
|
LanguageModel.make({ ...input, provider: "provider" in input ? input.provider : info.providerID, route }),
|
||||||
prepareTransport: (body) => Effect.succeed(body),
|
prepareTransport: (body) => Effect.succeed(body),
|
||||||
streamPrepared: (prepared) => streamLanguage(language, prepared as LanguageModelV3CallOptions),
|
streamPrepared: (prepared) =>
|
||||||
|
streamLanguage(language, prepared as LanguageModelV3CallOptions, info.providerID === Provider.ID.githubCopilot),
|
||||||
}
|
}
|
||||||
return LanguageModel.make({
|
return LanguageModel.make({
|
||||||
id: info.modelID ?? info.id,
|
id: info.modelID ?? info.id,
|
||||||
@@ -427,6 +428,7 @@ function callOptions(request: LLMRequest): LanguageModelV3CallOptions {
|
|||||||
toolChoice: toolChoice(request.toolChoice),
|
toolChoice: toolChoice(request.toolChoice),
|
||||||
headers: request.http?.headers,
|
headers: request.http?.headers,
|
||||||
providerOptions: providerOptions(request.providerOptions),
|
providerOptions: providerOptions(request.providerOptions),
|
||||||
|
includeRawChunks: request.model.provider === ProviderID.make(Provider.ID.githubCopilot),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,8 +549,15 @@ function providerOptions(input: LLMRequest["providerOptions"]): SharedV3Provider
|
|||||||
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsonObject(value)]))
|
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsonObject(value)]))
|
||||||
}
|
}
|
||||||
|
|
||||||
function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallOptions) {
|
interface StreamState {
|
||||||
const state = { step: 0, toolNames: {} as Record<string, string> }
|
step: number
|
||||||
|
toolNames: Record<string, string>
|
||||||
|
copilot: boolean
|
||||||
|
cost?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallOptions, copilot: boolean) {
|
||||||
|
const state: StreamState = { step: 0, toolNames: {}, copilot }
|
||||||
return Stream.concat(
|
return Stream.concat(
|
||||||
Stream.make(LLMEvent.stepStart({ index: state.step })),
|
Stream.make(LLMEvent.stepStart({ index: state.step })),
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
@@ -571,17 +580,19 @@ function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallO
|
|||||||
}
|
}
|
||||||
|
|
||||||
function streamPartEvents(
|
function streamPartEvents(
|
||||||
state: { step: number; toolNames: Record<string, string> },
|
state: StreamState,
|
||||||
event: LanguageModelV3StreamPart,
|
event: LanguageModelV3StreamPart,
|
||||||
): Effect.Effect<ReadonlyArray<LLMEvent>, AIError> {
|
): Effect.Effect<ReadonlyArray<LLMEvent>, AIError> {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "stream-start":
|
case "stream-start":
|
||||||
case "response-metadata":
|
case "response-metadata":
|
||||||
case "raw":
|
|
||||||
case "file":
|
case "file":
|
||||||
case "source":
|
case "source":
|
||||||
case "tool-approval-request":
|
case "tool-approval-request":
|
||||||
return Effect.succeed([])
|
return Effect.succeed([])
|
||||||
|
case "raw":
|
||||||
|
if (state.copilot) state.cost = copilotCost(event.rawValue) ?? state.cost
|
||||||
|
return Effect.succeed([])
|
||||||
case "text-start":
|
case "text-start":
|
||||||
return Effect.succeed([
|
return Effect.succeed([
|
||||||
LLMEvent.textStart({ id: event.id, providerMetadata: providerMetadata(event.providerMetadata) }),
|
LLMEvent.textStart({ id: event.id, providerMetadata: providerMetadata(event.providerMetadata) }),
|
||||||
@@ -672,16 +683,18 @@ function streamPartEvents(
|
|||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
case "finish":
|
case "finish":
|
||||||
|
const normalized = usage(event.usage, state.cost)
|
||||||
|
state.cost = undefined
|
||||||
return Effect.succeed([
|
return Effect.succeed([
|
||||||
LLMEvent.stepFinish({
|
LLMEvent.stepFinish({
|
||||||
index: state.step++,
|
index: state.step++,
|
||||||
reason: { normalized: finishReason(event.finishReason), raw: event.finishReason.raw },
|
reason: { normalized: finishReason(event.finishReason), raw: event.finishReason.raw },
|
||||||
usage: usage(event.usage),
|
usage: normalized,
|
||||||
providerMetadata: providerMetadata(event.providerMetadata),
|
providerMetadata: providerMetadata(event.providerMetadata),
|
||||||
}),
|
}),
|
||||||
LLMEvent.finish({
|
LLMEvent.finish({
|
||||||
reason: { normalized: finishReason(event.finishReason), raw: event.finishReason.raw },
|
reason: { normalized: finishReason(event.finishReason), raw: event.finishReason.raw },
|
||||||
usage: usage(event.usage),
|
usage: normalized,
|
||||||
providerMetadata: providerMetadata(event.providerMetadata),
|
providerMetadata: providerMetadata(event.providerMetadata),
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
@@ -690,7 +703,10 @@ function streamPartEvents(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function usage(input: Extract<LanguageModelV3StreamPart, { type: "finish" }>["usage"]): UsageInput | undefined {
|
function usage(
|
||||||
|
input: Extract<LanguageModelV3StreamPart, { type: "finish" }>["usage"],
|
||||||
|
cost?: number,
|
||||||
|
): UsageInput | undefined {
|
||||||
const output = {
|
const output = {
|
||||||
inputTokens: input.inputTokens.total,
|
inputTokens: input.inputTokens.total,
|
||||||
nonCachedInputTokens: input.inputTokens.noCache,
|
nonCachedInputTokens: input.inputTokens.noCache,
|
||||||
@@ -702,10 +718,22 @@ function usage(input: Extract<LanguageModelV3StreamPart, { type: "finish" }>["us
|
|||||||
input.inputTokens.total === undefined || input.outputTokens.total === undefined
|
input.inputTokens.total === undefined || input.outputTokens.total === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: input.inputTokens.total + input.outputTokens.total,
|
: input.inputTokens.total + input.outputTokens.total,
|
||||||
|
cost,
|
||||||
}
|
}
|
||||||
return Object.values(output).some((value) => value !== undefined) ? output : undefined
|
return Object.values(output).some((value) => value !== undefined) ? output : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function copilotCost(input: unknown): number | undefined {
|
||||||
|
if (!ProviderShared.isRecord(input)) return undefined
|
||||||
|
const raw = input
|
||||||
|
const response = ProviderShared.isRecord(raw.response) ? raw.response : undefined
|
||||||
|
const usage = raw.copilot_usage ?? response?.copilot_usage
|
||||||
|
if (!ProviderShared.isRecord(usage)) return undefined
|
||||||
|
const total = usage.total_nano_aiu
|
||||||
|
if (typeof total !== "number" || !Number.isFinite(total) || total < 0) return undefined
|
||||||
|
return total / 100_000_000_000
|
||||||
|
}
|
||||||
|
|
||||||
function finishReason(value: LanguageModelV3FinishReason): FinishReason {
|
function finishReason(value: LanguageModelV3FinishReason): FinishReason {
|
||||||
return value.unified === "other" ? "unknown" : value.unified
|
return value.unified === "other" ? "unknown" : value.unified
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -272,10 +272,8 @@ const layer = Layer.effect(
|
|||||||
snapshot: startSnapshot,
|
snapshot: startSnapshot,
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
})
|
})
|
||||||
const stepUsage = (finish: NonNullable<StepRecord["finish"]>) => ({
|
const stepUsage = (finish: NonNullable<StepRecord["finish"]>) =>
|
||||||
cost: SessionUsage.calculateCost(resolved.cost, finish.tokens),
|
SessionUsage.record(finish.usage, resolved.cost)
|
||||||
tokens: finish.tokens,
|
|
||||||
})
|
|
||||||
|
|
||||||
const captureStepEnd = Effect.fnUntraced(function* () {
|
const captureStepEnd = Effect.fnUntraced(function* () {
|
||||||
const snapshot = yield* snapshots.capture()
|
const snapshot = yield* snapshots.capture()
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export interface StepRecord {
|
|||||||
/** Present once the provider finished the step normally. */
|
/** Present once the provider finished the step normally. */
|
||||||
readonly finish?: {
|
readonly finish?: {
|
||||||
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]["normalized"]
|
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]["normalized"]
|
||||||
readonly tokens: ReturnType<typeof SessionUsage.tokens>
|
readonly usage: Extract<LLMEvent, { type: "step-finish" }>["usage"]
|
||||||
}
|
}
|
||||||
readonly calls: ReadonlyArray<{
|
readonly calls: ReadonlyArray<{
|
||||||
readonly id: string
|
readonly id: string
|
||||||
@@ -495,7 +495,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
|||||||
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.normalized, tokens: SessionUsage.tokens(event.usage) }
|
stepSettlement = { finish: event.reason.normalized, usage: event.usage }
|
||||||
if (event.reason.normalized === "content-filter") {
|
if (event.reason.normalized === "content-filter") {
|
||||||
providerFailed = true
|
providerFailed = true
|
||||||
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" })
|
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" })
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ export const tokens = (usage: Usage | undefined): TokenUsage.Info => ({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// TODO(#35765): Use Copilot's reported billed amount once billing has a dedicated typed runtime contract.
|
|
||||||
export function calculateCost(costs: Model.Info["cost"], usage: TokenUsage.Info) {
|
export function calculateCost(costs: Model.Info["cost"], usage: TokenUsage.Info) {
|
||||||
const context = usage.input + usage.cache.read + usage.cache.write
|
const context = usage.input + usage.cache.read + usage.cache.write
|
||||||
const tier = costs
|
const tier = costs
|
||||||
@@ -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 => {
|
export const record = (usage: Usage | undefined, costs: Model.Info["cost"]): Recorded => {
|
||||||
const normalized = tokens(usage)
|
const normalized = tokens(usage)
|
||||||
return { tokens: normalized, cost: calculateCost(costs, normalized) }
|
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 => ({
|
export const add = (a: Recorded, b: Recorded): Recorded => ({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { APICallError } from "@ai-sdk/provider"
|
import { APICallError } from "@ai-sdk/provider"
|
||||||
import type { LanguageModelV3, LanguageModelV3StreamPart } from "@ai-sdk/provider"
|
import type { LanguageModelV3, LanguageModelV3CallOptions, LanguageModelV3StreamPart } from "@ai-sdk/provider"
|
||||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||||
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
|
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
|
||||||
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
|
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
|
||||||
@@ -23,21 +23,26 @@ const model = (packageName: string, settings: Record<string, unknown> = {}) =>
|
|||||||
limit: { context: 100, output: 20 },
|
limit: { context: 100, output: 20 },
|
||||||
})
|
})
|
||||||
|
|
||||||
const streamModel = (events: ReadonlyArray<LanguageModelV3StreamPart>): LanguageModelV3 => ({
|
const streamModel = (
|
||||||
|
events: ReadonlyArray<LanguageModelV3StreamPart>,
|
||||||
|
inspect?: (options: LanguageModelV3CallOptions) => void,
|
||||||
|
): LanguageModelV3 => ({
|
||||||
specificationVersion: "v3",
|
specificationVersion: "v3",
|
||||||
provider: "test",
|
provider: "test",
|
||||||
modelId: "test",
|
modelId: "test",
|
||||||
supportedUrls: {},
|
supportedUrls: {},
|
||||||
doGenerate: () => Promise.reject(new Error("Unexpected non-streaming request")),
|
doGenerate: () => Promise.reject(new Error("Unexpected non-streaming request")),
|
||||||
doStream: () =>
|
doStream: (options) => {
|
||||||
Promise.resolve({
|
inspect?.(options)
|
||||||
|
return Promise.resolve({
|
||||||
stream: new ReadableStream({
|
stream: new ReadableStream({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
events.forEach((event) => controller.enqueue(event))
|
events.forEach((event) => controller.enqueue(event))
|
||||||
controller.close()
|
controller.close()
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}),
|
})
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const usage = {
|
const usage = {
|
||||||
@@ -375,6 +380,41 @@ it.effect("emits malformed AI SDK tool input without executing it", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("normalizes Copilot billed usage to USD", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const aisdk = yield* AISDK.Service
|
||||||
|
let options: LanguageModelV3CallOptions | undefined
|
||||||
|
yield* aisdk.hook.sdk((event) => {
|
||||||
|
event.sdk = {
|
||||||
|
languageModel: () =>
|
||||||
|
streamModel(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: "raw",
|
||||||
|
rawValue: { type: "message_delta", copilot_usage: { total_nano_aiu: 4_473_525_000 } },
|
||||||
|
},
|
||||||
|
{ type: "finish", finishReason: { unified: "stop", raw: "end_turn" }, usage },
|
||||||
|
],
|
||||||
|
(input) => {
|
||||||
|
options = input
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const resolved = yield* aisdk.model({
|
||||||
|
...model("@ai-sdk/github-copilot"),
|
||||||
|
providerID: Provider.ID.githubCopilot,
|
||||||
|
})
|
||||||
|
const response = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Hello" })).pipe(
|
||||||
|
Effect.provide(client),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(options?.includeRawChunks).toBeTrue()
|
||||||
|
expect(response.usage?.cost).toBeCloseTo(0.04473525)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("keeps malformed provider-executed AI SDK input terminal", () =>
|
it.effect("keeps malformed provider-executed AI SDK input terminal", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const aisdk = yield* AISDK.Service
|
const aisdk = yield* AISDK.Service
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { Cause, Effect, Exit, Schema } from "effect"
|
import { Cause, Effect, Exit, Schema } from "effect"
|
||||||
import { LLMEvent } from "@opencode-ai/ai"
|
import { LLMEvent } from "@opencode-ai/ai"
|
||||||
import { Money } from "@opencode-ai/schema/money"
|
|
||||||
import { Bus } from "@opencode-ai/core/bus"
|
import { Bus } from "@opencode-ai/core/bus"
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
import { Agent } from "@opencode-ai/core/agent"
|
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 { RelativePath } from "@opencode-ai/core/schema"
|
||||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||||
import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publish-llm-event"
|
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 sessionID = Session.ID.make("ses_tool_event_test")
|
||||||
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
|
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
|
||||||
@@ -280,6 +280,7 @@ test("content-filter finish retains failure evidence until step closeout", async
|
|||||||
nonCachedInputTokens: 8,
|
nonCachedInputTokens: 8,
|
||||||
outputTokens: 3,
|
outputTokens: 3,
|
||||||
reasoningTokens: 1,
|
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
|
const settlement = publisher.record().finish
|
||||||
expect(settlement).toMatchObject({
|
expect(settlement).toMatchObject({
|
||||||
finish: "content-filter",
|
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")
|
if (!settlement) throw new Error("Expected content-filter settlement")
|
||||||
|
const recorded = SessionUsage.record(settlement.usage, [])
|
||||||
await Effect.runPromise(
|
await Effect.runPromise(
|
||||||
publisher.publishStepFailure({
|
publisher.publishStepFailure({
|
||||||
cost: Money.USD.make(1.25),
|
...recorded,
|
||||||
tokens: settlement.tokens,
|
|
||||||
snapshot: Snapshot.ID.make("tree-end"),
|
snapshot: Snapshot.ID.make("tree-end"),
|
||||||
files: [RelativePath.make("src/changed.ts")],
|
files: [RelativePath.make("src/changed.ts")],
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import { Usage } from "@opencode-ai/ai"
|
||||||
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
|
import { SessionUsage } from "@opencode-ai/core/session/usage"
|
||||||
|
|
||||||
|
const costs = [
|
||||||
|
{
|
||||||
|
input: Money.USDPerMillionTokens.make(1),
|
||||||
|
output: Money.USDPerMillionTokens.make(2),
|
||||||
|
cache: { read: Money.USDPerMillionTokens.zero, write: Money.USDPerMillionTokens.zero },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
test("prefers provider-reported cost", () => {
|
||||||
|
expect(SessionUsage.record(new Usage({ nonCachedInputTokens: 1_000_000, cost: 0.25 }), costs).cost).toBe(
|
||||||
|
Money.USD.make(0.25),
|
||||||
|
)
|
||||||
|
expect(SessionUsage.record(new Usage({ nonCachedInputTokens: 1_000_000, cost: 0 }), costs).cost).toBe(Money.USD.zero)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("falls back to catalog pricing for invalid reported cost", () => {
|
||||||
|
expect(SessionUsage.record(new Usage({ nonCachedInputTokens: 1_000_000, cost: Number.NaN }), costs).cost).toBe(
|
||||||
|
Money.USD.make(1),
|
||||||
|
)
|
||||||
|
expect(SessionUsage.record(new Usage({ nonCachedInputTokens: 1_000_000, cost: -1 }), costs).cost).toBe(
|
||||||
|
Money.USD.make(1),
|
||||||
|
)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user