231 lines
6.6 KiB
TypeScript
231 lines
6.6 KiB
TypeScript
import { FinishReason, LLMEvent, ProviderMetadata, ToolResultValue } from "@opencode-ai/llm"
|
|
import { Effect, Schema } from "effect"
|
|
import { type streamText } from "ai"
|
|
import { errorMessage } from "@/util/error"
|
|
|
|
type Result = Awaited<ReturnType<typeof streamText>>
|
|
type AISDKEvent = Result["fullStream"] extends AsyncIterable<infer T> ? T : never
|
|
|
|
export function adapterState() {
|
|
return {
|
|
step: 0,
|
|
text: 0,
|
|
reasoning: 0,
|
|
currentTextID: undefined as string | undefined,
|
|
currentReasoningID: undefined as string | undefined,
|
|
toolNames: {} as Record<string, string>,
|
|
}
|
|
}
|
|
|
|
function finishReason(value: string | undefined): FinishReason {
|
|
return Schema.is(FinishReason)(value) ? value : "unknown"
|
|
}
|
|
|
|
function providerMetadata(value: unknown): ProviderMetadata | undefined {
|
|
return Schema.is(ProviderMetadata)(value) ? value : undefined
|
|
}
|
|
|
|
function usage(value: unknown) {
|
|
if (!value || typeof value !== "object") return undefined
|
|
const item = value as {
|
|
inputTokens?: number
|
|
outputTokens?: number
|
|
totalTokens?: number
|
|
reasoningTokens?: number
|
|
cachedInputTokens?: number
|
|
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number }
|
|
outputTokenDetails?: { reasoningTokens?: number }
|
|
}
|
|
const result = Object.fromEntries(
|
|
Object.entries({
|
|
inputTokens: item.inputTokens,
|
|
outputTokens: item.outputTokens,
|
|
totalTokens: item.totalTokens,
|
|
reasoningTokens: item.outputTokenDetails?.reasoningTokens ?? item.reasoningTokens,
|
|
cacheReadInputTokens: item.inputTokenDetails?.cacheReadTokens ?? item.cachedInputTokens,
|
|
cacheWriteInputTokens: item.inputTokenDetails?.cacheWriteTokens,
|
|
}).filter((entry) => entry[1] !== undefined),
|
|
)
|
|
return result
|
|
}
|
|
|
|
function currentTextID(state: ReturnType<typeof adapterState>, id: string | undefined) {
|
|
state.currentTextID = id ?? state.currentTextID ?? `text-${state.text++}`
|
|
return state.currentTextID
|
|
}
|
|
|
|
function currentReasoningID(state: ReturnType<typeof adapterState>, id: string | undefined) {
|
|
state.currentReasoningID = id ?? state.currentReasoningID ?? `reasoning-${state.reasoning++}`
|
|
return state.currentReasoningID
|
|
}
|
|
|
|
export function toLLMEvents(
|
|
state: ReturnType<typeof adapterState>,
|
|
event: AISDKEvent,
|
|
): Effect.Effect<ReadonlyArray<LLMEvent>, unknown> {
|
|
switch (event.type) {
|
|
case "start":
|
|
return Effect.succeed([])
|
|
|
|
case "start-step":
|
|
return Effect.succeed([LLMEvent.stepStart({ index: state.step })])
|
|
|
|
case "finish-step":
|
|
return Effect.sync(() => [
|
|
LLMEvent.stepFinish({
|
|
index: state.step++,
|
|
reason: finishReason(event.finishReason),
|
|
usage: usage(event.usage),
|
|
providerMetadata: providerMetadata(event.providerMetadata),
|
|
}),
|
|
])
|
|
|
|
case "finish":
|
|
return Effect.sync(() => {
|
|
state.toolNames = {}
|
|
return [
|
|
LLMEvent.requestFinish({
|
|
reason: finishReason(event.finishReason),
|
|
usage: usage(event.totalUsage),
|
|
}),
|
|
]
|
|
})
|
|
|
|
case "text-start":
|
|
return Effect.sync(() => {
|
|
state.currentTextID = currentTextID(state, event.id)
|
|
return [
|
|
LLMEvent.textStart({
|
|
id: state.currentTextID,
|
|
providerMetadata: providerMetadata(event.providerMetadata),
|
|
}),
|
|
]
|
|
})
|
|
|
|
case "text-delta":
|
|
return Effect.succeed([
|
|
LLMEvent.textDelta({
|
|
id: currentTextID(state, event.id),
|
|
text: event.text,
|
|
}),
|
|
])
|
|
|
|
case "text-end":
|
|
return Effect.succeed([
|
|
LLMEvent.textEnd({
|
|
id: currentTextID(state, event.id),
|
|
providerMetadata: providerMetadata(event.providerMetadata),
|
|
}),
|
|
])
|
|
|
|
case "reasoning-start":
|
|
return Effect.sync(() => {
|
|
state.currentReasoningID = currentReasoningID(state, event.id)
|
|
return [
|
|
LLMEvent.reasoningStart({
|
|
id: state.currentReasoningID,
|
|
providerMetadata: providerMetadata(event.providerMetadata),
|
|
}),
|
|
]
|
|
})
|
|
|
|
case "reasoning-delta":
|
|
return Effect.succeed([
|
|
LLMEvent.reasoningDelta({
|
|
id: currentReasoningID(state, event.id),
|
|
text: event.text,
|
|
}),
|
|
])
|
|
|
|
case "reasoning-end":
|
|
return Effect.sync(() => {
|
|
const id = currentReasoningID(state, event.id)
|
|
state.currentReasoningID = undefined
|
|
return [
|
|
LLMEvent.reasoningEnd({
|
|
id,
|
|
providerMetadata: providerMetadata(event.providerMetadata),
|
|
}),
|
|
]
|
|
})
|
|
|
|
case "tool-input-start":
|
|
return Effect.sync(() => {
|
|
state.toolNames[event.id] = event.toolName
|
|
return [
|
|
LLMEvent.toolInputStart({
|
|
id: event.id,
|
|
name: event.toolName,
|
|
providerMetadata: providerMetadata(event.providerMetadata),
|
|
}),
|
|
]
|
|
})
|
|
|
|
case "tool-input-delta":
|
|
return Effect.succeed([
|
|
LLMEvent.toolInputDelta({
|
|
id: event.id,
|
|
name: state.toolNames[event.id] ?? "unknown",
|
|
text: event.delta ?? "",
|
|
}),
|
|
])
|
|
|
|
case "tool-input-end":
|
|
return Effect.succeed([
|
|
LLMEvent.toolInputEnd({
|
|
id: event.id,
|
|
name: state.toolNames[event.id] ?? "unknown",
|
|
}),
|
|
])
|
|
|
|
case "tool-call":
|
|
return Effect.sync(() => {
|
|
state.toolNames[event.toolCallId] = event.toolName
|
|
return [
|
|
LLMEvent.toolCall({
|
|
id: event.toolCallId,
|
|
name: event.toolName,
|
|
input: event.input,
|
|
providerExecuted: "providerExecuted" in event ? event.providerExecuted : undefined,
|
|
providerMetadata: providerMetadata(event.providerMetadata),
|
|
}),
|
|
]
|
|
})
|
|
|
|
case "tool-result":
|
|
return Effect.sync(() => {
|
|
const name = state.toolNames[event.toolCallId] ?? "unknown"
|
|
delete state.toolNames[event.toolCallId]
|
|
return [
|
|
LLMEvent.toolResult({
|
|
id: event.toolCallId,
|
|
name,
|
|
result: ToolResultValue.make(event.output),
|
|
providerExecuted: "providerExecuted" in event ? event.providerExecuted : undefined,
|
|
}),
|
|
]
|
|
})
|
|
|
|
case "tool-error":
|
|
return Effect.sync(() => {
|
|
const name = state.toolNames[event.toolCallId] ?? "unknown"
|
|
delete state.toolNames[event.toolCallId]
|
|
return [
|
|
LLMEvent.toolError({
|
|
id: event.toolCallId,
|
|
name,
|
|
message: errorMessage(event.error),
|
|
}),
|
|
]
|
|
})
|
|
|
|
case "error":
|
|
return Effect.fail(event.error)
|
|
|
|
default:
|
|
return Effect.succeed([])
|
|
}
|
|
}
|
|
|
|
export * as LLMAISDK from "./llm-ai-sdk"
|