Preview native LLM runtime stack (#27114)

This commit is contained in:
Kit Langton
2026-05-18 14:41:36 -04:00
committed by GitHub
parent ff9d7cab5c
commit dbe36851bc
20 changed files with 2683 additions and 481 deletions
@@ -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
View 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"
@@ -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"
@@ -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"