Preview native LLM runtime stack (#27114)
This commit is contained in:
@@ -30,6 +30,7 @@ import { TestConfig } from "../fixture/config"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { LLMEvent, Usage } from "@opencode-ai/llm"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
@@ -47,6 +48,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()
|
||||
})
|
||||
@@ -296,11 +301,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(
|
||||
@@ -319,54 +324,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.finish({
|
||||
reason: "stop",
|
||||
usage: basicUsage(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1204,7 +1177,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,55 +1263,62 @@ describe("session.compaction.process", () => {
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
itCompaction.instance(
|
||||
"silently drops reasoning-delta arriving without prior reasoning-start",
|
||||
() => {
|
||||
// Regression: PR initially auto-created a reasoning Part for orphan deltas (no preceding
|
||||
// reasoning-start). Reverted to match dev — drop silently. Pinned here so any future
|
||||
// change to processor.ts reasoning-delta handling triggers this test.
|
||||
const stub = llm()
|
||||
stub.push(
|
||||
Stream.make(
|
||||
LLMEvent.reasoningDelta({ id: "orphan-1", text: "stray reasoning" }),
|
||||
LLMEvent.textStart({ id: "txt-0" }),
|
||||
LLMEvent.textDelta({ id: "txt-0", text: "summary" }),
|
||||
LLMEvent.textEnd({ id: "txt-0" }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "stop", usage: basicUsage() }),
|
||||
LLMEvent.finish({ reason: "stop", usage: basicUsage() }),
|
||||
),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
const ssn = yield* SessionNs.Service
|
||||
const session = yield* ssn.create({})
|
||||
const msg = yield* createUserMessage(session.id, "hello")
|
||||
const msgs = yield* ssn.messages({ sessionID: session.id })
|
||||
yield* SessionCompaction.use.process({
|
||||
parentID: msg.id,
|
||||
messages: msgs,
|
||||
sessionID: session.id,
|
||||
auto: false,
|
||||
})
|
||||
|
||||
const summary = (yield* ssn.messages({ sessionID: session.id })).find(
|
||||
(item) => item.info.role === "assistant" && item.info.summary,
|
||||
)
|
||||
expect(summary?.parts.some((part) => part.type === "reasoning")).toBe(false)
|
||||
// Sanity: the text part still got through.
|
||||
expect(summary?.parts.some((part) => part.type === "text" && part.text === "summary")).toBe(true)
|
||||
}).pipe(withCompaction({ llm: stub.layer }))
|
||||
},
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
itCompaction.instance(
|
||||
"does not allow tool calls while generating the summary",
|
||||
() => {
|
||||
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.finish({
|
||||
reason: "tool-calls",
|
||||
usage: basicUsage(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
@@ -1544,20 +1524,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)
|
||||
@@ -1571,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: 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)
|
||||
@@ -1595,20 +1549,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,
|
||||
@@ -1624,20 +1565,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: {},
|
||||
},
|
||||
@@ -1651,20 +1579,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)
|
||||
@@ -1685,20 +1600,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)
|
||||
@@ -1710,20 +1612,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)
|
||||
@@ -1746,20 +1635,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)
|
||||
@@ -1796,20 +1672,12 @@ describe("SessionNs.getUsage", () => {
|
||||
})
|
||||
const result = SessionNs.getUsage({
|
||||
model,
|
||||
usage: {
|
||||
usage: usage({
|
||||
inputTokens: 650_000,
|
||||
outputTokens: 100_000,
|
||||
totalTokens: 750_000,
|
||||
inputTokenDetails: {
|
||||
noCacheTokens: undefined,
|
||||
cacheReadTokens: 100_000,
|
||||
cacheWriteTokens: undefined,
|
||||
},
|
||||
outputTokenDetails: {
|
||||
textTokens: undefined,
|
||||
reasoningTokens: undefined,
|
||||
},
|
||||
},
|
||||
cacheReadInputTokens: 100_000,
|
||||
}),
|
||||
})
|
||||
|
||||
expect(result.tokens.input).toBe(550_000)
|
||||
@@ -1841,20 +1709,7 @@ describe("SessionNs.getUsage", () => {
|
||||
})
|
||||
const result = SessionNs.getUsage({
|
||||
model,
|
||||
usage: {
|
||||
inputTokens: 300_000,
|
||||
outputTokens: 100_000,
|
||||
totalTokens: 400_000,
|
||||
inputTokenDetails: {
|
||||
noCacheTokens: undefined,
|
||||
cacheReadTokens: undefined,
|
||||
cacheWriteTokens: undefined,
|
||||
},
|
||||
outputTokenDetails: {
|
||||
textTokens: undefined,
|
||||
reasoningTokens: undefined,
|
||||
},
|
||||
},
|
||||
usage: usage({ inputTokens: 300_000, outputTokens: 100_000, totalTokens: 400_000 }),
|
||||
})
|
||||
|
||||
expect(result.cost).toBe(0.9 + 0.4)
|
||||
@@ -1865,24 +1720,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: {
|
||||
@@ -1903,7 +1750,7 @@ describe("SessionNs.getUsage", () => {
|
||||
|
||||
const result = SessionNs.getUsage({
|
||||
model,
|
||||
usage,
|
||||
usage: item,
|
||||
metadata: {
|
||||
anthropic: {
|
||||
cacheCreationInputTokens: 300,
|
||||
@@ -1924,20 +1771,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,
|
||||
|
||||
Reference in New Issue
Block a user