refactor(session): consume native LLM events

This commit is contained in:
Kit Langton
2026-05-10 12:17:10 -04:00
parent 28f38fc871
commit 83d07b7e16
8 changed files with 485 additions and 361 deletions
+45 -230
View File
@@ -28,6 +28,7 @@ import { testEffect } from "../lib/effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { TestConfig } from "../fixture/config"
import { SyncEvent } from "@/sync"
import { LLMEvent, Usage } from "@opencode-ai/llm"
void Log.init({ print: false })
@@ -45,6 +46,10 @@ const ref = {
modelID: ModelID.make("test-model"),
}
const usage = (input: ConstructorParameters<typeof Usage>[0]) => new Usage(input)
const basicUsage = () => usage({ inputTokens: 1, outputTokens: 1, totalTokens: 2 })
afterEach(() => {
mock.restore()
})
@@ -289,11 +294,11 @@ function readCompactionPart(sessionID: SessionID) {
function llm() {
const queue: Array<
Stream.Stream<LLM.Event, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown>)
Stream.Stream<LLMEvent, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLMEvent, unknown>)
> = []
return {
push(stream: Stream.Stream<LLM.Event, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown>)) {
push(stream: Stream.Stream<LLMEvent, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLMEvent, unknown>)) {
queue.push(stream)
},
layer: Layer.succeed(
@@ -312,54 +317,22 @@ function llm() {
function reply(
text: string,
capture?: (input: LLM.StreamInput) => void,
): (input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown> {
): (input: LLM.StreamInput) => Stream.Stream<LLMEvent, unknown> {
return (input) => {
capture?.(input)
return Stream.make(
{ type: "start" } satisfies LLM.Event,
{ type: "text-start", id: "txt-0" } satisfies LLM.Event,
{ type: "text-delta", id: "txt-0", delta: text, text } as LLM.Event,
{ type: "text-end", id: "txt-0" } satisfies LLM.Event,
{
type: "finish-step",
finishReason: "stop",
rawFinishReason: "stop",
response: { id: "res", modelId: "test-model", timestamp: new Date() },
providerMetadata: undefined,
usage: {
inputTokens: 1,
outputTokens: 1,
totalTokens: 2,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
} satisfies LLM.Event,
{
type: "finish",
finishReason: "stop",
rawFinishReason: "stop",
totalUsage: {
inputTokens: 1,
outputTokens: 1,
totalTokens: 2,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
} satisfies LLM.Event,
LLMEvent.textStart({ id: "txt-0" }),
LLMEvent.textDelta({ id: "txt-0", text }),
LLMEvent.textEnd({ id: "txt-0" }),
LLMEvent.stepFinish({
index: 0,
reason: "stop",
usage: basicUsage(),
}),
LLMEvent.requestFinish({
reason: "stop",
usage: basicUsage(),
}),
)
}
}
@@ -1198,7 +1171,7 @@ describe("session.compaction.process", () => {
Stream.fromAsyncIterable(
{
async *[Symbol.asyncIterator]() {
yield { type: "start" } as LLM.Event
yield LLMEvent.stepStart({ index: 0 })
throw new APICallError({
message: "boom",
url: "https://example.com/v1/chat/completions",
@@ -1290,49 +1263,16 @@ describe("session.compaction.process", () => {
const stub = llm()
stub.push(
Stream.make(
{ type: "start" } satisfies LLM.Event,
{ type: "tool-input-start", id: "call-1", toolName: "_noop" } satisfies LLM.Event,
{ type: "tool-call", toolCallId: "call-1", toolName: "_noop", input: {} } satisfies LLM.Event,
{
type: "finish-step",
finishReason: "tool-calls",
rawFinishReason: "tool_calls",
response: { id: "res", modelId: "test-model", timestamp: new Date() },
providerMetadata: undefined,
usage: {
inputTokens: 1,
outputTokens: 1,
totalTokens: 2,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
} satisfies LLM.Event,
{
type: "finish",
finishReason: "tool-calls",
rawFinishReason: "tool_calls",
totalUsage: {
inputTokens: 1,
outputTokens: 1,
totalTokens: 2,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
} satisfies LLM.Event,
LLMEvent.toolCall({ id: "call-1", name: "_noop", input: {} }),
LLMEvent.stepFinish({
index: 0,
reason: "tool-calls",
usage: basicUsage(),
}),
LLMEvent.requestFinish({
reason: "tool-calls",
usage: basicUsage(),
}),
),
)
return Effect.gen(function* () {
@@ -1543,20 +1483,7 @@ describe("SessionNs.getUsage", () => {
const model = createModel({ context: 100_000, output: 32_000 })
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500 }),
})
expect(result.tokens.input).toBe(1000)
@@ -1570,20 +1497,7 @@ describe("SessionNs.getUsage", () => {
const model = createModel({ context: 100_000, output: 32_000 })
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
inputTokenDetails: {
noCacheTokens: 800,
cacheReadTokens: 200,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500, cacheReadInputTokens: 200 }),
})
expect(result.tokens.input).toBe(800)
@@ -1594,20 +1508,7 @@ describe("SessionNs.getUsage", () => {
const model = createModel({ context: 100_000, output: 32_000 })
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500 }),
metadata: {
anthropic: {
cacheCreationInputTokens: 300,
@@ -1623,20 +1524,7 @@ describe("SessionNs.getUsage", () => {
// AI SDK v6 normalizes inputTokens to include cached tokens for all providers
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
inputTokenDetails: {
noCacheTokens: 800,
cacheReadTokens: 200,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500, cacheReadInputTokens: 200 }),
metadata: {
anthropic: {},
},
@@ -1650,20 +1538,7 @@ describe("SessionNs.getUsage", () => {
const model = createModel({ context: 100_000, output: 32_000 })
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: 400,
reasoningTokens: 100,
},
},
usage: usage({ inputTokens: 1000, outputTokens: 500, reasoningTokens: 100, totalTokens: 1500 }),
})
expect(result.tokens.input).toBe(1000)
@@ -1684,20 +1559,7 @@ describe("SessionNs.getUsage", () => {
})
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 0,
outputTokens: 1_000_000,
totalTokens: 1_000_000,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: 750_000,
reasoningTokens: 250_000,
},
},
usage: usage({ inputTokens: 0, outputTokens: 1_000_000, reasoningTokens: 250_000, totalTokens: 1_000_000 }),
})
expect(result.tokens.output).toBe(750_000)
@@ -1709,20 +1571,7 @@ describe("SessionNs.getUsage", () => {
const model = createModel({ context: 100_000, output: 32_000 })
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
usage: usage({ inputTokens: 0, outputTokens: 0, totalTokens: 0 }),
})
expect(result.tokens.input).toBe(0)
@@ -1745,20 +1594,7 @@ describe("SessionNs.getUsage", () => {
})
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 1_000_000,
outputTokens: 100_000,
totalTokens: 1_100_000,
inputTokenDetails: {
noCacheTokens: undefined,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
usage: usage({ inputTokens: 1_000_000, outputTokens: 100_000, totalTokens: 1_100_000 }),
})
expect(result.cost).toBe(3 + 1.5)
@@ -1769,24 +1605,16 @@ describe("SessionNs.getUsage", () => {
(npm) => {
const model = createModel({ context: 100_000, output: 32_000, npm })
// AI SDK v6: inputTokens includes cached tokens for all providers
const usage = {
const item = usage({
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
inputTokenDetails: {
noCacheTokens: 800,
cacheReadTokens: 200,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
}
cacheReadInputTokens: 200,
})
if (npm === "@ai-sdk/amazon-bedrock") {
const result = SessionNs.getUsage({
model,
usage,
usage: item,
metadata: {
bedrock: {
usage: {
@@ -1807,7 +1635,7 @@ describe("SessionNs.getUsage", () => {
const result = SessionNs.getUsage({
model,
usage,
usage: item,
metadata: {
anthropic: {
cacheCreationInputTokens: 300,
@@ -1828,20 +1656,7 @@ describe("SessionNs.getUsage", () => {
const model = createModel({ context: 100_000, output: 32_000, npm: "@ai-sdk/google-vertex/anthropic" })
const result = SessionNs.getUsage({
model,
usage: {
inputTokens: 1000,
outputTokens: 500,
totalTokens: 1500,
inputTokenDetails: {
noCacheTokens: 800,
cacheReadTokens: 200,
cacheWriteTokens: undefined,
},
outputTokenDetails: {
textTokens: undefined,
reasoningTokens: undefined,
},
},
usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500, cacheReadInputTokens: 200 }),
metadata: {
vertex: {
cacheCreationInputTokens: 300,