Preview native LLM runtime stack (#27114)
This commit is contained in:
@@ -2,7 +2,10 @@ import { Provider } from "@/provider/provider"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Context, Effect, Layer, Record } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai"
|
||||
import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool as aiTool, jsonSchema } from "ai"
|
||||
import type { LLMEvent } from "@opencode-ai/llm"
|
||||
import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
|
||||
import type { LLMClientService } from "@opencode-ai/llm/route"
|
||||
import { mergeDeep } from "remeda"
|
||||
import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
@@ -23,10 +26,11 @@ import { EffectBridge } from "@/effect/bridge"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import * as Option from "effect/Option"
|
||||
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
||||
import { LLMAISDK } from "./llm/ai-sdk"
|
||||
import { LLMNativeRuntime } from "./llm/native-runtime"
|
||||
|
||||
const log = Log.create({ service: "llm" })
|
||||
export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
|
||||
type Result = Awaited<ReturnType<typeof streamText>>
|
||||
|
||||
// Avoid re-instantiating remeda's deep merge types in this hot LLM path; the runtime behavior is still mergeDeep.
|
||||
const mergeOptions = (target: Record<string, any>, source: Record<string, any> | undefined): Record<string, any> =>
|
||||
@@ -51,10 +55,8 @@ export type StreamRequest = StreamInput & {
|
||||
abort: AbortSignal
|
||||
}
|
||||
|
||||
export type Event = Result["fullStream"] extends AsyncIterable<infer T> ? T : never
|
||||
|
||||
export interface Interface {
|
||||
readonly stream: (input: StreamInput) => Stream.Stream<Event, unknown>
|
||||
readonly stream: (input: StreamInput) => Stream.Stream<LLMEvent, unknown>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM") {}
|
||||
@@ -62,7 +64,13 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/LL
|
||||
const live: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
Auth.Service | Config.Service | Provider.Service | Plugin.Service | Permission.Service | RuntimeFlags.Service
|
||||
| Auth.Service
|
||||
| Config.Service
|
||||
| Provider.Service
|
||||
| Plugin.Service
|
||||
| Permission.Service
|
||||
| LLMClientService
|
||||
| RuntimeFlags.Service
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -71,6 +79,7 @@ const live: Layer.Layer<
|
||||
const provider = yield* Provider.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
const perm = yield* Permission.Service
|
||||
const llmClient = yield* LLMClient.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
|
||||
const run = Effect.fn("LLM.run")(function* (input: StreamRequest) {
|
||||
@@ -202,7 +211,7 @@ const live: Layer.Layer<
|
||||
Object.keys(tools).length === 0 &&
|
||||
hasToolCalls(input.messages)
|
||||
) {
|
||||
tools["_noop"] = tool({
|
||||
tools["_noop"] = aiTool({
|
||||
description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
|
||||
inputSchema: jsonSchema({
|
||||
type: "object",
|
||||
@@ -322,86 +331,141 @@ const live: Layer.Layer<
|
||||
? (yield* InstanceState.context).project.id
|
||||
: undefined
|
||||
|
||||
return streamText({
|
||||
onError(error) {
|
||||
l.error("stream error", {
|
||||
error,
|
||||
})
|
||||
},
|
||||
async experimental_repairToolCall(failed) {
|
||||
const lower = failed.toolCall.toolName.toLowerCase()
|
||||
if (lower !== failed.toolCall.toolName && sortedTools[lower]) {
|
||||
l.info("repairing tool call", {
|
||||
tool: failed.toolCall.toolName,
|
||||
repaired: lower,
|
||||
const requestHeaders = {
|
||||
...(input.model.providerID.startsWith("opencode")
|
||||
? {
|
||||
...(opencodeProjectID ? { "x-opencode-project": opencodeProjectID } : {}),
|
||||
"x-opencode-session": input.sessionID,
|
||||
"x-opencode-request": input.user.id,
|
||||
"x-opencode-client": flags.client,
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
}
|
||||
: {
|
||||
"x-session-affinity": input.sessionID,
|
||||
...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
}),
|
||||
...input.model.headers,
|
||||
...headers,
|
||||
}
|
||||
|
||||
if (flags.experimentalNativeLlm) {
|
||||
const native = LLMNativeRuntime.stream({
|
||||
model: input.model,
|
||||
provider: item,
|
||||
auth: info,
|
||||
llmClient,
|
||||
isOpenaiOauth,
|
||||
system,
|
||||
messages,
|
||||
tools: sortedTools,
|
||||
toolChoice: input.toolChoice,
|
||||
temperature: params.temperature,
|
||||
topP: params.topP,
|
||||
topK: params.topK,
|
||||
maxOutputTokens: params.maxOutputTokens,
|
||||
providerOptions: params.options,
|
||||
headers: requestHeaders,
|
||||
abort: input.abort,
|
||||
})
|
||||
if (native.type === "supported") {
|
||||
yield* Effect.logInfo("llm runtime selected").pipe(
|
||||
Effect.annotateLogs({
|
||||
"llm.runtime": "native",
|
||||
"llm.provider": input.model.providerID,
|
||||
"llm.model": input.model.id,
|
||||
}),
|
||||
)
|
||||
return {
|
||||
type: "native" as const,
|
||||
stream: native.stream,
|
||||
}
|
||||
}
|
||||
yield* Effect.logInfo("llm runtime selected").pipe(
|
||||
Effect.annotateLogs({
|
||||
"llm.runtime": "ai-sdk",
|
||||
"llm.provider": input.model.providerID,
|
||||
"llm.model": input.model.id,
|
||||
"llm.native_unsupported_reason": native.reason,
|
||||
}),
|
||||
)
|
||||
l.info("native runtime unavailable; falling back to ai-sdk", { reason: native.reason })
|
||||
}
|
||||
|
||||
yield* Effect.logInfo("llm runtime selected").pipe(
|
||||
Effect.annotateLogs({
|
||||
"llm.runtime": "ai-sdk",
|
||||
"llm.provider": input.model.providerID,
|
||||
"llm.model": input.model.id,
|
||||
}),
|
||||
)
|
||||
return {
|
||||
type: "ai-sdk" as const,
|
||||
result: streamText({
|
||||
onError(error) {
|
||||
l.error("stream error", {
|
||||
error,
|
||||
})
|
||||
},
|
||||
async experimental_repairToolCall(failed) {
|
||||
const lower = failed.toolCall.toolName.toLowerCase()
|
||||
if (lower !== failed.toolCall.toolName && sortedTools[lower]) {
|
||||
l.info("repairing tool call", {
|
||||
tool: failed.toolCall.toolName,
|
||||
repaired: lower,
|
||||
})
|
||||
return {
|
||||
...failed.toolCall,
|
||||
toolName: lower,
|
||||
}
|
||||
}
|
||||
return {
|
||||
...failed.toolCall,
|
||||
toolName: lower,
|
||||
}
|
||||
}
|
||||
return {
|
||||
...failed.toolCall,
|
||||
input: JSON.stringify({
|
||||
tool: failed.toolCall.toolName,
|
||||
error: failed.error.message,
|
||||
}),
|
||||
toolName: "invalid",
|
||||
}
|
||||
},
|
||||
temperature: params.temperature,
|
||||
topP: params.topP,
|
||||
topK: params.topK,
|
||||
providerOptions: ProviderTransform.providerOptions(input.model, params.options),
|
||||
activeTools: Object.keys(sortedTools).filter((x) => x !== "invalid"),
|
||||
tools: sortedTools,
|
||||
toolChoice: input.toolChoice,
|
||||
maxOutputTokens: params.maxOutputTokens,
|
||||
abortSignal: input.abort,
|
||||
headers: {
|
||||
...(input.model.providerID.startsWith("opencode")
|
||||
? {
|
||||
"x-opencode-project": opencodeProjectID,
|
||||
"x-opencode-session": input.sessionID,
|
||||
"x-opencode-request": input.user.id,
|
||||
"x-opencode-client": flags.client,
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
}
|
||||
: {
|
||||
"x-session-affinity": input.sessionID,
|
||||
...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
input: JSON.stringify({
|
||||
tool: failed.toolCall.toolName,
|
||||
error: failed.error.message,
|
||||
}),
|
||||
...input.model.headers,
|
||||
...headers,
|
||||
},
|
||||
maxRetries: input.retries ?? 0,
|
||||
messages,
|
||||
model: wrapLanguageModel({
|
||||
model: language,
|
||||
middleware: [
|
||||
{
|
||||
specificationVersion: "v3" as const,
|
||||
async transformParams(args) {
|
||||
if (args.type === "stream") {
|
||||
// @ts-expect-error
|
||||
args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
|
||||
}
|
||||
return args.params
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
experimental_telemetry: {
|
||||
isEnabled: cfg.experimental?.openTelemetry,
|
||||
functionId: "session.llm",
|
||||
tracer: telemetryTracer,
|
||||
metadata: {
|
||||
userId: cfg.username ?? "unknown",
|
||||
sessionId: input.sessionID,
|
||||
toolName: "invalid",
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
temperature: params.temperature,
|
||||
topP: params.topP,
|
||||
topK: params.topK,
|
||||
providerOptions: ProviderTransform.providerOptions(input.model, params.options),
|
||||
activeTools: Object.keys(sortedTools).filter((x) => x !== "invalid"),
|
||||
tools: sortedTools,
|
||||
toolChoice: input.toolChoice,
|
||||
maxOutputTokens: params.maxOutputTokens,
|
||||
abortSignal: input.abort,
|
||||
headers: requestHeaders,
|
||||
maxRetries: input.retries ?? 0,
|
||||
messages,
|
||||
model: wrapLanguageModel({
|
||||
model: language,
|
||||
middleware: [
|
||||
{
|
||||
specificationVersion: "v3" as const,
|
||||
async transformParams(args) {
|
||||
if (args.type === "stream") {
|
||||
// @ts-expect-error
|
||||
args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
|
||||
}
|
||||
return args.params
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
experimental_telemetry: {
|
||||
isEnabled: cfg.experimental?.openTelemetry,
|
||||
functionId: "session.llm",
|
||||
tracer: telemetryTracer,
|
||||
metadata: {
|
||||
userId: cfg.username ?? "unknown",
|
||||
sessionId: input.sessionID,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const stream: Interface["stream"] = (input) =>
|
||||
@@ -415,7 +479,15 @@ const live: Layer.Layer<
|
||||
|
||||
const result = yield* run({ ...input, abort: ctrl.signal })
|
||||
|
||||
return Stream.fromAsyncIterable(result.fullStream, (e) => (e instanceof Error ? e : new Error(String(e))))
|
||||
if (result.type === "native") return result.stream
|
||||
|
||||
const state = LLMAISDK.adapterState()
|
||||
return Stream.fromAsyncIterable(result.result.fullStream, (e) =>
|
||||
e instanceof Error ? e : new Error(String(e)),
|
||||
).pipe(
|
||||
Stream.mapEffect((event) => LLMAISDK.toLLMEvents(state, event)),
|
||||
Stream.flatMap((events) => Stream.fromIterable(events)),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -432,6 +504,7 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer))),
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
),
|
||||
)
|
||||
|
||||
67
packages/opencode/src/session/llm/AGENTS.md
Normal file
67
packages/opencode/src/session/llm/AGENTS.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Session LLM Runtime Boundaries
|
||||
|
||||
`../llm.ts` is the opencode session LLM service. It owns opencode concerns: auth, config, model/provider resolution, plugins, permissions, telemetry headers, and runtime selection.
|
||||
|
||||
This folder contains adapters behind that service boundary:
|
||||
|
||||
- `ai-sdk.ts` converts AI SDK `fullStream` parts into `@opencode-ai/llm` `LLMEvent`s. This is the default runtime path.
|
||||
- `native-request.ts` converts opencode's normalized session input into a native `@opencode-ai/llm` `LLMRequest`. It does not execute requests.
|
||||
- `native-runtime.ts` is the opt-in native runtime adapter. It decides whether a selected model is supported, builds the native request, bridges opencode tools into native executable tools, and delegates transport to `LLMClient` / `RequestExecutor`.
|
||||
|
||||
## Runtime selection
|
||||
|
||||
Both runtimes converge on the same `LLMEvent` stream consumed by the session processor. The gate is per-request: a single session can route some calls through native and fall back for others.
|
||||
|
||||
```txt
|
||||
╭───────────────────╮
|
||||
╭───────────────────────────▶│ session processor │
|
||||
│ ╰─────────┬─────────╯
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ ▼
|
||||
│ ╭─────────────────────────╮
|
||||
│ │ LLM.Service (../llm.ts) │
|
||||
│ ╰────────────┬────────────╯
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ ▼
|
||||
│ ╭───────────╮
|
||||
│ ╭─╯ ╰─╮
|
||||
│ │ native gate │
|
||||
│ ╰─╮ ╭─╯
|
||||
│ ╰─────┬─────╯
|
||||
│ │
|
||||
│ ╭────── no ──────┴─────── yes ────────╮
|
||||
│ │ │
|
||||
│ ▼ ▼
|
||||
│ ╭───────────────────────────╮ ╭───────────────────╮
|
||||
│ │ AI SDK │ │ native-runtime.ts │
|
||||
│ │ streamText / generateText │ ╰────────┬──────────╯
|
||||
│ ╰─────────────┬─────────────╯ │
|
||||
│ │ │
|
||||
│ ╭───╯ │
|
||||
│ │ │
|
||||
│ ▼ ▼
|
||||
│ ╭───────────────────────╮ ╭────────────────────────────╮
|
||||
│ │ ai-sdk.ts │ │ native-request.ts │
|
||||
│ │ fullStream → LLMEvent │ │ session input → LLMRequest │
|
||||
│ ╰──────────┬────────────╯ ╰──────────────┬─────────────╯
|
||||
│ │ │
|
||||
│ │ ╭───╯
|
||||
│ │ │
|
||||
│ ▼ ▼
|
||||
│ ╭─────────────────╮ ╭─────────────────────────────╮
|
||||
╰───────┤ LLMEvent stream │◀────────────┤ LLMClient · RequestExecutor │
|
||||
╰─────────────────╯ ╰─────────────────────────────╯
|
||||
```
|
||||
|
||||
`native-runtime.ts` evaluates the gate and either bridges into `@opencode-ai/llm` or returns control so `llm.ts` can take the AI SDK path. Tool execution stays opencode-owned in both branches; only request lowering and transport differ.
|
||||
|
||||
Safety boundary:
|
||||
|
||||
- AI SDK remains the default.
|
||||
- `OPENCODE_EXPERIMENTAL_NATIVE_LLM=true` or the umbrella `OPENCODE_EXPERIMENTAL=true` opts in. Native is not a global replacement.
|
||||
- Native execution currently runs only for OpenAI-compatible Responses models exposed through `@ai-sdk/openai`: direct `openai` API-key auth and console-managed `opencode`/Zen API-key config.
|
||||
- Unsupported providers, OpenAI OAuth, and missing API-key cases fall back to AI SDK.
|
||||
254
packages/opencode/src/session/llm/ai-sdk.ts
Normal file
254
packages/opencode/src/session/llm/ai-sdk.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
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 {
|
||||
if (value == null) return 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 entries = 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 entries.length === 0 ? undefined : Object.fromEntries(entries)
|
||||
}
|
||||
|
||||
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(() => {
|
||||
const events = [
|
||||
LLMEvent.finish({
|
||||
reason: finishReason(event.finishReason),
|
||||
usage: usage(event.totalUsage),
|
||||
providerMetadata: "providerMetadata" in event ? providerMetadata(event.providerMetadata) : undefined,
|
||||
}),
|
||||
]
|
||||
// Reset so the adapter can be reused for a follow-up stream without leaking
|
||||
// counters or block IDs. adapterState() is the single source of truth for shape.
|
||||
Object.assign(state, adapterState())
|
||||
return events
|
||||
})
|
||||
|
||||
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,
|
||||
providerMetadata: providerMetadata(event.providerMetadata),
|
||||
}),
|
||||
])
|
||||
|
||||
case "text-end":
|
||||
return Effect.sync(() => {
|
||||
const id = currentTextID(state, event.id)
|
||||
state.currentTextID = undefined
|
||||
return [
|
||||
LLMEvent.textEnd({
|
||||
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,
|
||||
providerMetadata: providerMetadata(event.providerMetadata),
|
||||
}),
|
||||
])
|
||||
|
||||
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",
|
||||
providerMetadata: providerMetadata(event.providerMetadata),
|
||||
}),
|
||||
])
|
||||
|
||||
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,
|
||||
providerMetadata: providerMetadata(event.providerMetadata),
|
||||
}),
|
||||
]
|
||||
})
|
||||
|
||||
case "tool-error":
|
||||
return Effect.sync(() => {
|
||||
const name = state.toolNames[event.toolCallId] ?? ("toolName" in event ? event.toolName : "unknown")
|
||||
delete state.toolNames[event.toolCallId]
|
||||
return [
|
||||
LLMEvent.toolError({
|
||||
id: event.toolCallId,
|
||||
name,
|
||||
message: errorMessage(event.error),
|
||||
error: event.error,
|
||||
providerMetadata: providerMetadata(event.providerMetadata),
|
||||
}),
|
||||
]
|
||||
})
|
||||
|
||||
case "error":
|
||||
return Effect.fail(event.error)
|
||||
|
||||
case "abort":
|
||||
case "source":
|
||||
case "file":
|
||||
case "raw":
|
||||
case "tool-output-denied":
|
||||
case "tool-approval-request":
|
||||
return Effect.succeed([])
|
||||
|
||||
default: {
|
||||
const _exhaustive: never = event
|
||||
void _exhaustive
|
||||
return Effect.succeed([])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export * as LLMAISDK from "./ai-sdk"
|
||||
188
packages/opencode/src/session/llm/native-request.ts
Normal file
188
packages/opencode/src/session/llm/native-request.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import type { JsonSchema, LLMRequest, ProviderMetadata } from "@opencode-ai/llm"
|
||||
import { LLM, Message, SystemPart, ToolCallPart, ToolDefinition, ToolResultPart } from "@opencode-ai/llm"
|
||||
import "@opencode-ai/llm/providers"
|
||||
import type { ModelMessage } from "ai"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { isRecord } from "@/util/record"
|
||||
|
||||
type ToolInput = {
|
||||
readonly description?: string
|
||||
readonly inputSchema?: unknown
|
||||
}
|
||||
|
||||
export type RequestInput = {
|
||||
readonly model: Provider.Model
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly system?: readonly string[]
|
||||
readonly messages: readonly ModelMessage[]
|
||||
readonly tools?: Record<string, ToolInput>
|
||||
readonly toolChoice?: "auto" | "required" | "none"
|
||||
readonly temperature?: number
|
||||
readonly topP?: number
|
||||
readonly topK?: number
|
||||
readonly maxOutputTokens?: number
|
||||
readonly providerOptions?: LLMRequest["providerOptions"]
|
||||
readonly headers?: Record<string, string>
|
||||
}
|
||||
|
||||
const DEFAULT_BASE_URL: Record<string, string> = {
|
||||
"@ai-sdk/openai": "https://api.openai.com/v1",
|
||||
"@ai-sdk/anthropic": "https://api.anthropic.com/v1",
|
||||
"@ai-sdk/google": "https://generativelanguage.googleapis.com/v1beta",
|
||||
"@ai-sdk/amazon-bedrock": "https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||
"@openrouter/ai-sdk-provider": "https://openrouter.ai/api/v1",
|
||||
}
|
||||
|
||||
const ROUTE: Record<string, string> = {
|
||||
"@ai-sdk/openai": "openai-responses",
|
||||
"@ai-sdk/azure": "azure-openai-responses",
|
||||
"@ai-sdk/anthropic": "anthropic-messages",
|
||||
"@ai-sdk/google": "gemini",
|
||||
"@ai-sdk/amazon-bedrock": "bedrock-converse",
|
||||
"@ai-sdk/openai-compatible": "openai-compatible-chat",
|
||||
"@openrouter/ai-sdk-provider": "openrouter",
|
||||
}
|
||||
|
||||
const providerMetadata = (value: unknown): ProviderMetadata | undefined => {
|
||||
if (!isRecord(value)) return undefined
|
||||
const result = Object.fromEntries(
|
||||
Object.entries(value).filter((entry): entry is [string, Record<string, unknown>] => isRecord(entry[1])),
|
||||
)
|
||||
return Object.keys(result).length === 0 ? undefined : result
|
||||
}
|
||||
|
||||
const textPart = (part: Record<string, unknown>) => ({
|
||||
type: "text" as const,
|
||||
text: typeof part.text === "string" ? part.text : "",
|
||||
providerMetadata: providerMetadata(part.providerOptions),
|
||||
})
|
||||
|
||||
const mediaPart = (part: Record<string, unknown>) => {
|
||||
if (typeof part.data !== "string" && !(part.data instanceof Uint8Array))
|
||||
throw new Error("Native LLM request adapter only supports file parts with string or Uint8Array data")
|
||||
return {
|
||||
type: "media" as const,
|
||||
mediaType: typeof part.mediaType === "string" ? part.mediaType : "application/octet-stream",
|
||||
data: part.data,
|
||||
filename: typeof part.filename === "string" ? part.filename : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const toolResult = (part: Record<string, unknown>) => {
|
||||
const output = isRecord(part.output) ? part.output : { type: "json", value: part.output }
|
||||
const type = output.type === "text" ? "text" : output.type === "error-text" ? "error" : "json"
|
||||
return ToolResultPart.make({
|
||||
id: typeof part.toolCallId === "string" ? part.toolCallId : "",
|
||||
name: typeof part.toolName === "string" ? part.toolName : "",
|
||||
result: "value" in output ? output.value : output,
|
||||
resultType: type,
|
||||
providerExecuted: typeof part.providerExecuted === "boolean" ? part.providerExecuted : undefined,
|
||||
providerMetadata: providerMetadata(part.providerOptions),
|
||||
})
|
||||
}
|
||||
|
||||
const contentPart = (part: unknown) => {
|
||||
if (!isRecord(part)) throw new Error("Native LLM request adapter only supports object content parts")
|
||||
if (part.type === "text") return textPart(part)
|
||||
if (part.type === "file") return mediaPart(part)
|
||||
if (part.type === "reasoning")
|
||||
return {
|
||||
type: "reasoning" as const,
|
||||
text: typeof part.text === "string" ? part.text : "",
|
||||
providerMetadata: providerMetadata(part.providerOptions),
|
||||
}
|
||||
if (part.type === "tool-call")
|
||||
return ToolCallPart.make({
|
||||
id: typeof part.toolCallId === "string" ? part.toolCallId : "",
|
||||
name: typeof part.toolName === "string" ? part.toolName : "",
|
||||
input: part.input,
|
||||
providerExecuted: typeof part.providerExecuted === "boolean" ? part.providerExecuted : undefined,
|
||||
providerMetadata: providerMetadata(part.providerOptions),
|
||||
})
|
||||
if (part.type === "tool-result") return toolResult(part)
|
||||
throw new Error(`Native LLM request adapter does not support ${String(part.type)} content parts`)
|
||||
}
|
||||
|
||||
const content = (value: ModelMessage["content"]) =>
|
||||
typeof value === "string" ? [{ type: "text" as const, text: value }] : value.map(contentPart)
|
||||
|
||||
const messages = (input: readonly ModelMessage[]) => {
|
||||
const system = input.flatMap((message) => (message.role === "system" ? [SystemPart.make(message.content)] : []))
|
||||
const messages = input.flatMap((message) => {
|
||||
if (message.role === "system") return []
|
||||
return [
|
||||
Message.make({
|
||||
role: message.role,
|
||||
content: content(message.content),
|
||||
native: isRecord(message.providerOptions) ? { providerOptions: message.providerOptions } : undefined,
|
||||
}),
|
||||
]
|
||||
})
|
||||
return { system, messages }
|
||||
}
|
||||
|
||||
const schema = (value: unknown): JsonSchema => {
|
||||
if (!isRecord(value)) return { type: "object", properties: {} }
|
||||
if (isRecord(value.jsonSchema)) return value.jsonSchema
|
||||
return value
|
||||
}
|
||||
|
||||
const tools = (input: Record<string, ToolInput> | undefined): ToolDefinition[] =>
|
||||
Object.entries(input ?? {}).map(([name, item]) =>
|
||||
ToolDefinition.make({
|
||||
name,
|
||||
description: item.description ?? "",
|
||||
inputSchema: schema(item.inputSchema),
|
||||
}),
|
||||
)
|
||||
|
||||
const generation = (input: RequestInput) => {
|
||||
const result = {
|
||||
temperature: input.temperature,
|
||||
topP: input.topP,
|
||||
topK: input.topK,
|
||||
maxTokens: input.maxOutputTokens,
|
||||
}
|
||||
return Object.values(result).some((value) => value !== undefined) ? result : undefined
|
||||
}
|
||||
|
||||
const baseURL = (model: Provider.Model) => {
|
||||
if (model.api.url) return model.api.url
|
||||
const fallback = DEFAULT_BASE_URL[model.api.npm]
|
||||
if (fallback) return fallback
|
||||
throw new Error(`Native LLM request adapter requires a base URL for ${model.providerID}/${model.id}`)
|
||||
}
|
||||
|
||||
export const model = (input: Provider.Model | RequestInput, headers?: Record<string, string>) => {
|
||||
const model = "model" in input ? input.model : input
|
||||
const route = ROUTE[model.api.npm]
|
||||
if (!route) throw new Error(`Native LLM request adapter does not support provider package ${model.api.npm}`)
|
||||
return LLM.model({
|
||||
id: model.api.id,
|
||||
provider: model.providerID,
|
||||
route,
|
||||
baseURL: "model" in input && input.baseURL ? input.baseURL : baseURL(model),
|
||||
apiKey: "model" in input ? input.apiKey : undefined,
|
||||
headers: Object.keys({ ...model.headers, ...headers }).length === 0 ? undefined : { ...model.headers, ...headers },
|
||||
limits: {
|
||||
context: model.limit.context,
|
||||
output: model.limit.output,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const request = (input: RequestInput) => {
|
||||
const converted = messages(input.messages)
|
||||
return LLM.request({
|
||||
model: model(input, input.headers),
|
||||
system: [...(input.system ?? []).map(SystemPart.make), ...converted.system],
|
||||
messages: converted.messages,
|
||||
tools: tools(input.tools),
|
||||
toolChoice: input.toolChoice,
|
||||
generation: generation(input),
|
||||
providerOptions: input.providerOptions,
|
||||
})
|
||||
}
|
||||
|
||||
export * as LLMNative from "./native-request"
|
||||
124
packages/opencode/src/session/llm/native-runtime.ts
Normal file
124
packages/opencode/src/session/llm/native-runtime.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type { Auth } from "@/auth"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { asSchema, type ModelMessage, type Tool } from "ai"
|
||||
import { Effect } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { tool as nativeTool, ToolFailure, type JsonSchema, type LLMEvent } from "@opencode-ai/llm"
|
||||
import type { LLMClientShape } from "@opencode-ai/llm/route"
|
||||
import { LLMNative } from "./native-request"
|
||||
|
||||
export type RuntimeStatus =
|
||||
| { readonly type: "supported"; readonly apiKey: string; readonly baseURL?: string }
|
||||
| { readonly type: "unsupported"; readonly reason: string }
|
||||
export type StreamResult =
|
||||
| { readonly type: "supported"; readonly stream: Stream.Stream<LLMEvent, unknown> }
|
||||
| { readonly type: "unsupported"; readonly reason: string }
|
||||
|
||||
type StreamInput = {
|
||||
readonly model: Provider.Model
|
||||
readonly provider: Provider.Info
|
||||
readonly auth: Auth.Info | undefined
|
||||
readonly llmClient: LLMClientShape
|
||||
readonly isOpenaiOauth: boolean
|
||||
readonly system: string[]
|
||||
readonly messages: ModelMessage[]
|
||||
readonly tools: Record<string, Tool>
|
||||
readonly toolChoice?: "auto" | "required" | "none"
|
||||
readonly temperature?: number
|
||||
readonly topP?: number
|
||||
readonly topK?: number
|
||||
readonly maxOutputTokens?: number
|
||||
readonly providerOptions?: Record<string, any>
|
||||
readonly headers: Record<string, string>
|
||||
readonly abort: AbortSignal
|
||||
}
|
||||
|
||||
export function status(input: Pick<StreamInput, "model" | "provider" | "auth">): RuntimeStatus {
|
||||
if (input.model.providerID !== "openai" && !input.model.providerID.startsWith("opencode"))
|
||||
return { type: "unsupported", reason: "provider is not openai or opencode" }
|
||||
if (input.model.api.npm !== "@ai-sdk/openai") return { type: "unsupported", reason: "provider package is not OpenAI" }
|
||||
if (input.auth?.type === "oauth") return { type: "unsupported", reason: "OAuth auth is not supported" }
|
||||
|
||||
const apiKey =
|
||||
input.auth?.type === "api"
|
||||
? input.auth.key
|
||||
: typeof input.provider.options.apiKey === "string"
|
||||
? input.provider.options.apiKey
|
||||
: undefined
|
||||
if (!apiKey) return { type: "unsupported", reason: "OpenAI API key is not configured" }
|
||||
|
||||
return {
|
||||
type: "supported",
|
||||
apiKey,
|
||||
baseURL: typeof input.provider.options.baseURL === "string" ? input.provider.options.baseURL : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function stream(input: StreamInput): StreamResult {
|
||||
const current = status(input)
|
||||
if (current.type === "unsupported") return current
|
||||
|
||||
return {
|
||||
...current,
|
||||
stream: input.llmClient.stream({
|
||||
request: LLMNative.request({
|
||||
model: input.model,
|
||||
apiKey: current.apiKey,
|
||||
baseURL: current.baseURL,
|
||||
system: input.isOpenaiOauth ? input.system : [],
|
||||
messages: ProviderTransform.message(input.messages, input.model, input.providerOptions ?? {}),
|
||||
toolChoice: input.toolChoice,
|
||||
temperature: input.temperature,
|
||||
topP: input.topP,
|
||||
topK: input.topK,
|
||||
maxOutputTokens: input.maxOutputTokens,
|
||||
providerOptions: ProviderTransform.providerOptions(input.model, input.providerOptions ?? {}),
|
||||
headers: { ...providerHeaders(input.provider.options.headers), ...input.headers },
|
||||
}),
|
||||
tools: nativeTools(input.tools, input),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function providerHeaders(value: unknown): Record<string, string> | undefined {
|
||||
if (!isRecord(value)) return undefined
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
||||
)
|
||||
}
|
||||
|
||||
function nativeSchema(value: unknown): JsonSchema {
|
||||
if (!value || typeof value !== "object") return { type: "object", properties: {} }
|
||||
if ("jsonSchema" in value && value.jsonSchema && typeof value.jsonSchema === "object")
|
||||
return value.jsonSchema as JsonSchema
|
||||
return asSchema(value as Parameters<typeof asSchema>[0]).jsonSchema as JsonSchema
|
||||
}
|
||||
|
||||
export function nativeTools(tools: Record<string, Tool>, input: Pick<StreamInput, "messages" | "abort">) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(tools).map(([name, item]) => [
|
||||
name,
|
||||
nativeTool({
|
||||
description: item.description ?? "",
|
||||
jsonSchema: nativeSchema(item.inputSchema),
|
||||
execute: (args: unknown, ctx) =>
|
||||
Effect.tryPromise({
|
||||
try: () => {
|
||||
if (!item.execute) throw new Error(`Tool has no execute handler: ${name}`)
|
||||
return item.execute(args, {
|
||||
toolCallId: ctx?.id ?? name,
|
||||
messages: input.messages,
|
||||
abortSignal: input.abort,
|
||||
})
|
||||
},
|
||||
catch: (error) => new ToolFailure({ message: errorMessage(error), error }),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
export * as LLMNativeRuntime from "./native-runtime"
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Cause, Deferred, Effect, Exit, Layer, Context, Scope } from "effect"
|
||||
import { Image } from "@/image/image"
|
||||
import { Cause, Deferred, Effect, Exit, Layer, Context, Scope, Schema } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Bus } from "@/bus"
|
||||
@@ -9,7 +10,6 @@ import { Snapshot } from "@/snapshot"
|
||||
import * as Session from "./session"
|
||||
import { LLM } from "./llm"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { Image } from "@/image/image"
|
||||
import { isOverflow } from "./overflow"
|
||||
import { PartID } from "./schema"
|
||||
import type { SessionID } from "./schema"
|
||||
@@ -28,14 +28,13 @@ import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { Usage, type LLMEvent } from "@opencode-ai/llm"
|
||||
|
||||
const DOOM_LOOP_THRESHOLD = 3
|
||||
const log = Log.create({ service: "session.processor" })
|
||||
|
||||
export type Result = "compact" | "stop" | "continue"
|
||||
|
||||
export type Event = LLM.Event
|
||||
|
||||
export interface Handle {
|
||||
readonly message: MessageV2.Assistant
|
||||
readonly updateToolCall: (
|
||||
@@ -69,6 +68,7 @@ type ToolCall = {
|
||||
messageID: MessageV2.ToolPart["messageID"]
|
||||
sessionID: MessageV2.ToolPart["sessionID"]
|
||||
done: Deferred.Deferred<void>
|
||||
inputEnded: boolean
|
||||
}
|
||||
|
||||
interface ProcessorContext extends Input {
|
||||
@@ -81,7 +81,7 @@ interface ProcessorContext extends Input {
|
||||
reasoningMap: Record<string, MessageV2.ReasoningPart>
|
||||
}
|
||||
|
||||
type StreamEvent = Event
|
||||
type StreamEvent = LLMEvent
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionProcessor") {}
|
||||
|
||||
@@ -137,7 +137,7 @@ export const layer = Layer.effect(
|
||||
|
||||
const readToolCall = Effect.fn("SessionProcessor.readToolCall")(function* (toolCallID: string) {
|
||||
const call = ctx.toolcalls[toolCallID]
|
||||
if (!call) return
|
||||
if (!call) return undefined
|
||||
const part = yield* session.getPart({
|
||||
partID: call.partID,
|
||||
messageID: call.messageID,
|
||||
@@ -145,7 +145,7 @@ export const layer = Layer.effect(
|
||||
})
|
||||
if (!part || part.type !== "tool") {
|
||||
delete ctx.toolcalls[toolCallID]
|
||||
return
|
||||
return undefined
|
||||
}
|
||||
return { call, part }
|
||||
})
|
||||
@@ -155,7 +155,7 @@ export const layer = Layer.effect(
|
||||
update: (part: MessageV2.ToolPart) => MessageV2.ToolPart,
|
||||
) {
|
||||
const match = yield* readToolCall(toolCallID)
|
||||
if (!match) return
|
||||
if (!match) return undefined
|
||||
const part = yield* session.updatePart(update(match.part))
|
||||
ctx.toolcalls[toolCallID] = {
|
||||
...match.call,
|
||||
@@ -211,12 +211,98 @@ export const layer = Layer.effect(
|
||||
return true
|
||||
})
|
||||
|
||||
const finishReasoning = Effect.fn("SessionProcessor.finishReasoning")(function* (reasoningID: string) {
|
||||
if (!(reasoningID in ctx.reasoningMap)) return
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Reasoning.Ended, {
|
||||
sessionID: ctx.sessionID,
|
||||
reasoningID,
|
||||
text: ctx.reasoningMap[reasoningID].text,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
// oxlint-disable-next-line no-self-assign -- reactivity trigger
|
||||
ctx.reasoningMap[reasoningID].text = ctx.reasoningMap[reasoningID].text
|
||||
ctx.reasoningMap[reasoningID].time = { ...ctx.reasoningMap[reasoningID].time, end: Date.now() }
|
||||
yield* session.updatePart(ctx.reasoningMap[reasoningID])
|
||||
delete ctx.reasoningMap[reasoningID]
|
||||
})
|
||||
|
||||
const ensureToolCall = Effect.fn("SessionProcessor.ensureToolCall")(function* (input: {
|
||||
id: string
|
||||
name: string
|
||||
providerExecuted?: boolean
|
||||
}) {
|
||||
const existing = yield* readToolCall(input.id)
|
||||
if (existing) {
|
||||
if (!input.providerExecuted || existing.part.metadata?.providerExecuted) return existing
|
||||
const part = yield* session.updatePart({
|
||||
...existing.part,
|
||||
metadata: { ...existing.part.metadata, providerExecuted: true },
|
||||
})
|
||||
ctx.toolcalls[input.id] = {
|
||||
...existing.call,
|
||||
partID: part.id,
|
||||
messageID: part.messageID,
|
||||
sessionID: part.sessionID,
|
||||
}
|
||||
return { call: ctx.toolcalls[input.id], part }
|
||||
}
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Tool.Input.Started, {
|
||||
sessionID: ctx.sessionID,
|
||||
callID: input.id,
|
||||
name: input.name,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
const part = yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: ctx.assistantMessage.id,
|
||||
sessionID: ctx.assistantMessage.sessionID,
|
||||
type: "tool",
|
||||
tool: input.name,
|
||||
callID: input.id,
|
||||
state: { status: "pending", input: {}, raw: "" },
|
||||
metadata: input.providerExecuted ? { providerExecuted: true } : undefined,
|
||||
} satisfies MessageV2.ToolPart)
|
||||
ctx.toolcalls[input.id] = {
|
||||
done: yield* Deferred.make<void>(),
|
||||
partID: part.id,
|
||||
messageID: part.messageID,
|
||||
sessionID: part.sessionID,
|
||||
inputEnded: false,
|
||||
}
|
||||
return { call: ctx.toolcalls[input.id], part }
|
||||
})
|
||||
|
||||
const isFilePart = Schema.is(MessageV2.FilePart)
|
||||
|
||||
const toolResultOutput = (value: Extract<StreamEvent, { type: "tool-result" }>) => {
|
||||
if (isRecord(value.result.value) && typeof value.result.value.output === "string") {
|
||||
return {
|
||||
title: typeof value.result.value.title === "string" ? value.result.value.title : value.name,
|
||||
metadata: isRecord(value.result.value.metadata) ? value.result.value.metadata : {},
|
||||
output: value.result.value.output,
|
||||
attachments: Array.isArray(value.result.value.attachments)
|
||||
? value.result.value.attachments.filter(isFilePart)
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: value.name,
|
||||
metadata: value.result.type === "json" && isRecord(value.result.value) ? value.result.value : {},
|
||||
output:
|
||||
typeof value.result.value === "string" ? value.result.value : (JSON.stringify(value.result.value) ?? ""),
|
||||
}
|
||||
}
|
||||
|
||||
const toolInput = (value: unknown): Record<string, any> => (isRecord(value) ? value : { value })
|
||||
|
||||
const handleEvent = Effect.fnUntraced(function* (value: StreamEvent) {
|
||||
switch (value.type) {
|
||||
case "start":
|
||||
yield* status.set(ctx.sessionID, { type: "busy" })
|
||||
return
|
||||
|
||||
case "reasoning-start":
|
||||
if (value.id in ctx.reasoningMap) return
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
@@ -240,6 +326,7 @@ export const layer = Layer.effect(
|
||||
return
|
||||
|
||||
case "reasoning-delta":
|
||||
// Match dev: silently drop orphan deltas (no preceding reasoning-start).
|
||||
if (!(value.id in ctx.reasoningMap)) return
|
||||
ctx.reasoningMap[value.id].text += value.text
|
||||
if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
|
||||
@@ -253,59 +340,26 @@ export const layer = Layer.effect(
|
||||
return
|
||||
|
||||
case "reasoning-end":
|
||||
if (!(value.id in ctx.reasoningMap)) return
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Reasoning.Ended, {
|
||||
sessionID: ctx.sessionID,
|
||||
reasoningID: value.id,
|
||||
text: ctx.reasoningMap[value.id].text,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
if (value.providerMetadata && value.id in ctx.reasoningMap) {
|
||||
ctx.reasoningMap[value.id].metadata = value.providerMetadata
|
||||
}
|
||||
// oxlint-disable-next-line no-self-assign -- reactivity trigger
|
||||
ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text
|
||||
ctx.reasoningMap[value.id].time = { ...ctx.reasoningMap[value.id].time, end: Date.now() }
|
||||
if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
|
||||
yield* session.updatePart(ctx.reasoningMap[value.id])
|
||||
delete ctx.reasoningMap[value.id]
|
||||
yield* finishReasoning(value.id)
|
||||
return
|
||||
|
||||
case "tool-input-start":
|
||||
if (ctx.assistantMessage.summary) {
|
||||
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
|
||||
}
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Tool.Input.Started, {
|
||||
sessionID: ctx.sessionID,
|
||||
callID: value.id,
|
||||
name: value.toolName,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
const part = yield* session.updatePart({
|
||||
id: ctx.toolcalls[value.id]?.partID ?? PartID.ascending(),
|
||||
messageID: ctx.assistantMessage.id,
|
||||
sessionID: ctx.assistantMessage.sessionID,
|
||||
type: "tool",
|
||||
tool: value.toolName,
|
||||
callID: value.id,
|
||||
state: { status: "pending", input: {}, raw: "" },
|
||||
metadata: value.providerExecuted ? { providerExecuted: true } : undefined,
|
||||
} satisfies MessageV2.ToolPart)
|
||||
ctx.toolcalls[value.id] = {
|
||||
done: yield* Deferred.make<void>(),
|
||||
partID: part.id,
|
||||
messageID: part.messageID,
|
||||
sessionID: part.sessionID,
|
||||
throw new Error(`Tool call not allowed while generating summary: ${value.name}`)
|
||||
}
|
||||
yield* ensureToolCall(value)
|
||||
return
|
||||
|
||||
case "tool-input-delta":
|
||||
// AI SDK emits a final `tool-call` with the parsed `input`; accumulating
|
||||
// delta fragments into `state.raw` is redundant work for no current consumer.
|
||||
return
|
||||
|
||||
case "tool-input-end": {
|
||||
const toolCall = yield* ensureToolCall(value)
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Tool.Input.Ended, {
|
||||
@@ -315,37 +369,52 @@ export const layer = Layer.effect(
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
ctx.toolcalls[value.id] = { ...toolCall.call, inputEnded: true }
|
||||
return
|
||||
}
|
||||
|
||||
case "tool-call": {
|
||||
if (ctx.assistantMessage.summary) {
|
||||
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
|
||||
throw new Error(`Tool call not allowed while generating summary: ${value.name}`)
|
||||
}
|
||||
const toolCall = yield* ensureToolCall(value)
|
||||
const input = toolInput(value.input)
|
||||
if (!toolCall.call.inputEnded) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Tool.Input.Ended, {
|
||||
sessionID: ctx.sessionID,
|
||||
callID: value.id,
|
||||
text: "",
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
}
|
||||
const toolCall = yield* readToolCall(value.toolCallId)
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Tool.Called, {
|
||||
sessionID: ctx.sessionID,
|
||||
callID: value.toolCallId,
|
||||
tool: value.toolName,
|
||||
input: value.input,
|
||||
callID: value.id,
|
||||
tool: value.name,
|
||||
input,
|
||||
provider: {
|
||||
executed: toolCall?.part.metadata?.providerExecuted === true,
|
||||
executed: toolCall.part.metadata?.providerExecuted === true,
|
||||
...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),
|
||||
},
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
yield* updateToolCall(value.toolCallId, (match) => ({
|
||||
yield* updateToolCall(value.id, (match) => ({
|
||||
...match,
|
||||
tool: value.toolName,
|
||||
state: {
|
||||
...match.state,
|
||||
status: "running",
|
||||
input: value.input,
|
||||
time: { start: Date.now() },
|
||||
},
|
||||
tool: value.name,
|
||||
state:
|
||||
match.state.status === "running"
|
||||
? { ...match.state, input }
|
||||
: {
|
||||
status: "running",
|
||||
input,
|
||||
time: { start: Date.now() },
|
||||
},
|
||||
metadata: match.metadata?.providerExecuted
|
||||
? { ...value.providerMetadata, providerExecuted: true }
|
||||
: value.providerMetadata,
|
||||
@@ -359,9 +428,9 @@ export const layer = Layer.effect(
|
||||
!recentParts.every(
|
||||
(part) =>
|
||||
part.type === "tool" &&
|
||||
part.tool === value.toolName &&
|
||||
part.tool === value.name &&
|
||||
part.state.status !== "pending" &&
|
||||
JSON.stringify(part.state.input) === JSON.stringify(value.input),
|
||||
JSON.stringify(part.state.input) === JSON.stringify(input),
|
||||
)
|
||||
) {
|
||||
return
|
||||
@@ -370,27 +439,19 @@ export const layer = Layer.effect(
|
||||
const agent = yield* agents.get(ctx.assistantMessage.agent)
|
||||
yield* permission.ask({
|
||||
permission: "doom_loop",
|
||||
patterns: [value.toolName],
|
||||
patterns: [value.name],
|
||||
sessionID: ctx.assistantMessage.sessionID,
|
||||
metadata: { tool: value.toolName, input: value.input },
|
||||
always: [value.toolName],
|
||||
metadata: { tool: value.name, input },
|
||||
always: [value.name],
|
||||
ruleset: agent.permission,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
case "tool-result": {
|
||||
const toolCall = yield* readToolCall(value.toolCallId)
|
||||
const toolAttachments: MessageV2.FilePart[] = (
|
||||
Array.isArray(value.output.attachments) ? value.output.attachments : []
|
||||
).filter(
|
||||
(attachment: unknown): attachment is MessageV2.FilePart =>
|
||||
isRecord(attachment) &&
|
||||
attachment.type === "file" &&
|
||||
typeof attachment.mime === "string" &&
|
||||
typeof attachment.url === "string",
|
||||
)
|
||||
const normalized = yield* Effect.forEach(toolAttachments, (attachment) =>
|
||||
const toolCall = yield* readToolCall(value.id)
|
||||
const rawOutput = toolResultOutput(value)
|
||||
const normalized = yield* Effect.forEach(rawOutput.attachments ?? [], (attachment) =>
|
||||
attachment.mime.startsWith("image/")
|
||||
? image.normalize(attachment).pipe(
|
||||
Effect.catchIf(
|
||||
@@ -404,18 +465,18 @@ export const layer = Layer.effect(
|
||||
const omitted = normalized.filter(Exit.isFailure).length
|
||||
const attachments = normalized.filter(Exit.isSuccess).map((item) => item.value)
|
||||
const output = {
|
||||
...value.output,
|
||||
...rawOutput,
|
||||
output:
|
||||
omitted === 0
|
||||
? value.output.output
|
||||
: `${value.output.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the image size limit.]`,
|
||||
attachments: attachments?.length ? attachments : undefined,
|
||||
? rawOutput.output
|
||||
: `${rawOutput.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the image size limit.]`,
|
||||
attachments: attachments.length ? attachments : undefined,
|
||||
}
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Tool.Success, {
|
||||
sessionID: ctx.sessionID,
|
||||
callID: value.toolCallId,
|
||||
callID: value.id,
|
||||
structured: output.metadata,
|
||||
content: [
|
||||
{
|
||||
@@ -423,32 +484,32 @@ export const layer = Layer.effect(
|
||||
text: output.output,
|
||||
},
|
||||
...(output.attachments?.map((item: MessageV2.FilePart) => ({
|
||||
type: "file",
|
||||
type: "file" as const,
|
||||
uri: item.url,
|
||||
mime: item.mime,
|
||||
name: item.filename,
|
||||
})) ?? []),
|
||||
],
|
||||
provider: {
|
||||
executed: toolCall?.part.metadata?.providerExecuted === true,
|
||||
executed: value.providerExecuted === true || toolCall?.part.metadata?.providerExecuted === true,
|
||||
},
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
yield* completeToolCall(value.toolCallId, output)
|
||||
yield* completeToolCall(value.id, output)
|
||||
return
|
||||
}
|
||||
|
||||
case "tool-error": {
|
||||
const toolCall = yield* readToolCall(value.toolCallId)
|
||||
const toolCall = yield* readToolCall(value.id)
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: ctx.sessionID,
|
||||
callID: value.toolCallId,
|
||||
callID: value.id,
|
||||
error: {
|
||||
type: "unknown",
|
||||
message: errorMessage(value.error),
|
||||
message: value.message,
|
||||
},
|
||||
provider: {
|
||||
executed: toolCall?.part.metadata?.providerExecuted === true,
|
||||
@@ -456,14 +517,14 @@ export const layer = Layer.effect(
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
})
|
||||
}
|
||||
yield* failToolCall(value.toolCallId, value.error)
|
||||
yield* failToolCall(value.id, value.error ?? new Error(value.message))
|
||||
return
|
||||
}
|
||||
|
||||
case "error":
|
||||
throw value.error
|
||||
case "provider-error":
|
||||
throw new Error(value.message)
|
||||
|
||||
case "start-step":
|
||||
case "step-start":
|
||||
if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track()
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||
@@ -490,11 +551,12 @@ export const layer = Layer.effect(
|
||||
})
|
||||
return
|
||||
|
||||
case "finish-step": {
|
||||
case "step-finish": {
|
||||
const completedSnapshot = yield* snapshot.track()
|
||||
yield* Effect.forEach(Object.keys(ctx.reasoningMap), finishReasoning)
|
||||
const usage = Session.getUsage({
|
||||
model: ctx.model,
|
||||
usage: value.usage,
|
||||
usage: value.usage ?? new Usage({}),
|
||||
metadata: value.providerMetadata,
|
||||
})
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
@@ -502,7 +564,7 @@ export const layer = Layer.effect(
|
||||
if (flags.experimentalEventSystem) {
|
||||
yield* events.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: ctx.sessionID,
|
||||
finish: value.finishReason,
|
||||
finish: value.reason,
|
||||
cost: usage.cost,
|
||||
tokens: usage.tokens,
|
||||
snapshot: completedSnapshot,
|
||||
@@ -510,12 +572,12 @@ export const layer = Layer.effect(
|
||||
})
|
||||
}
|
||||
}
|
||||
ctx.assistantMessage.finish = value.finishReason
|
||||
ctx.assistantMessage.finish = value.reason
|
||||
ctx.assistantMessage.cost += usage.cost
|
||||
ctx.assistantMessage.tokens = usage.tokens
|
||||
yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
reason: value.finishReason,
|
||||
reason: value.reason,
|
||||
snapshot: completedSnapshot,
|
||||
messageID: ctx.assistantMessage.id,
|
||||
sessionID: ctx.assistantMessage.sessionID,
|
||||
@@ -622,10 +684,6 @@ export const layer = Layer.effect(
|
||||
|
||||
case "finish":
|
||||
return
|
||||
|
||||
default:
|
||||
slog.info("unhandled", { event: value.type, value })
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
@@ -727,6 +785,7 @@ export const layer = Layer.effect(
|
||||
yield* Effect.gen(function* () {
|
||||
ctx.currentText = undefined
|
||||
ctx.reasoningMap = {}
|
||||
yield* status.set(ctx.sessionID, { type: "busy" })
|
||||
const stream = llm.stream(streamInput)
|
||||
|
||||
yield* stream.pipe(
|
||||
|
||||
@@ -63,6 +63,7 @@ import * as Database from "@/storage/db"
|
||||
import { SessionTable } from "./session.sql"
|
||||
import { referencePromptMetadata, referenceTextPart } from "./prompt/reference"
|
||||
import { SessionReminders } from "./reminders"
|
||||
import { LLMEvent } from "@opencode-ai/llm"
|
||||
|
||||
// @ts-ignore
|
||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||
@@ -283,7 +284,7 @@ export const layer = Layer.effect(
|
||||
messages: [{ role: "user", content: "Generate a title for this conversation:\n" }, ...msgs],
|
||||
})
|
||||
.pipe(
|
||||
Stream.filter((e): e is Extract<LLM.Event, { type: "text-delta" }> => e.type === "text-delta"),
|
||||
Stream.filter(LLMEvent.is.textDelta),
|
||||
Stream.map((e) => e.text),
|
||||
Stream.mkString,
|
||||
Effect.orDie,
|
||||
|
||||
@@ -4,7 +4,8 @@ import { BackgroundJob } from "@/background/job"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { Decimal } from "decimal.js"
|
||||
import { type ProviderMetadata, type LanguageModelUsage } from "ai"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import type { ProviderMetadata, Usage } from "@opencode-ai/llm"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
|
||||
import { Database } from "@/storage/db"
|
||||
@@ -374,21 +375,19 @@ export function plan(input: { slug: string; time: { created: number } }, instanc
|
||||
return path.join(base, [input.time.created, input.slug].join("-") + ".md")
|
||||
}
|
||||
|
||||
export const getUsage = (input: { model: Provider.Model; usage: LanguageModelUsage; metadata?: ProviderMetadata }) => {
|
||||
export const getUsage = (input: { model: Provider.Model; usage: Usage; metadata?: ProviderMetadata }) => {
|
||||
const safe = (value: number) => {
|
||||
if (!Number.isFinite(value)) return 0
|
||||
return Math.max(0, value)
|
||||
}
|
||||
const inputTokens = safe(input.usage.inputTokens ?? 0)
|
||||
const outputTokens = safe(input.usage.outputTokens ?? 0)
|
||||
const reasoningTokens = safe(input.usage.outputTokenDetails?.reasoningTokens ?? input.usage.reasoningTokens ?? 0)
|
||||
const reasoningTokens = safe(input.usage.reasoningTokens ?? 0)
|
||||
|
||||
const cacheReadInputTokens = safe(
|
||||
input.usage.inputTokenDetails?.cacheReadTokens ?? input.usage.cachedInputTokens ?? 0,
|
||||
)
|
||||
const cacheReadInputTokens = safe(input.usage.cacheReadInputTokens ?? 0)
|
||||
const cacheWriteInputTokens = safe(
|
||||
Number(
|
||||
input.usage.inputTokenDetails?.cacheWriteTokens ??
|
||||
input.usage.cacheWriteInputTokens ??
|
||||
input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
|
||||
// google-vertex-anthropic returns metadata under "vertex" key
|
||||
// (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages')
|
||||
|
||||
Reference in New Issue
Block a user