Refactor LLM route-first provider API (#28523)
This commit is contained in:
@@ -4,7 +4,7 @@ import { Context, Effect, Layer, Record } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
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 { LLMClient, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route"
|
||||
import type { LLMClientService } from "@opencode-ai/llm/route"
|
||||
import { mergeDeep } from "remeda"
|
||||
import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
|
||||
@@ -349,6 +349,8 @@ const live: Layer.Layer<
|
||||
...headers,
|
||||
}
|
||||
|
||||
// Runtime seam: native is an opt-in adapter over @opencode-ai/llm. It
|
||||
// either returns a ready LLMEvent stream or a concrete fallback reason.
|
||||
if (flags.experimentalNativeLlm) {
|
||||
const native = LLMNativeRuntime.stream({
|
||||
model: input.model,
|
||||
@@ -399,6 +401,8 @@ const live: Layer.Layer<
|
||||
"llm.model": input.model.id,
|
||||
}),
|
||||
)
|
||||
// Default runtime path: AI SDK owns provider execution and tool dispatch;
|
||||
// LLMAISDK.toLLMEvents below normalizes fullStream parts for the processor.
|
||||
return {
|
||||
type: "ai-sdk" as const,
|
||||
result: streamText({
|
||||
@@ -481,6 +485,8 @@ const live: Layer.Layer<
|
||||
|
||||
if (result.type === "native") return result.stream
|
||||
|
||||
// Adapter seam: both runtimes expose the same LLMEvent stream. Native
|
||||
// already returns one; AI SDK streams are converted here.
|
||||
const state = LLMAISDK.adapterState()
|
||||
return Stream.fromAsyncIterable(result.result.fullStream, (e) =>
|
||||
e instanceof Error ? e : new Error(String(e)),
|
||||
@@ -504,7 +510,9 @@ 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(
|
||||
LLMClient.layer.pipe(Layer.provide(Layer.mergeAll(RequestExecutor.defaultLayer, WebSocketExecutor.layer))),
|
||||
),
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
`../llm.ts` is the opencode session LLM service. It owns opencode concerns: auth, config, model/provider resolution, plugins, permissions, telemetry headers, and runtime selection. It is the only file in this area that should know about the full session request shape.
|
||||
|
||||
This folder contains adapters behind that service boundary:
|
||||
|
||||
@@ -8,6 +8,29 @@ This folder contains adapters behind that service boundary:
|
||||
- `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`.
|
||||
|
||||
## File Structure
|
||||
|
||||
```txt
|
||||
src/session/
|
||||
llm.ts session-owned orchestration and runtime selection
|
||||
llm/
|
||||
AGENTS.md boundary notes for the adapter layer
|
||||
ai-sdk.ts AI SDK fullStream -> @opencode-ai/llm LLMEvent adapter
|
||||
native-request.ts opencode/AI SDK-shaped input -> @opencode-ai/llm LLMRequest
|
||||
native-runtime.ts native runtime gate, tool bridge, and LLMClient handoff
|
||||
```
|
||||
|
||||
Integration points:
|
||||
|
||||
- `../llm.ts` imports `LLMClient` from `@opencode-ai/llm/route`; native execution is the only path that calls it directly.
|
||||
- `../llm.ts` imports `LLMAISDK` from `./llm/ai-sdk`; the AI SDK path still calls `streamText(...)` locally, then adapts `result.fullStream` into shared `LLMEvent`s.
|
||||
- `../llm.ts` imports `LLMNativeRuntime` from `./llm/native-runtime`; this is the runtime-selection seam. Unsupported native requests return a reason and fall back to AI SDK.
|
||||
- `native-runtime.ts` imports `LLMNative` from `./native-request`; this keeps request lowering separate from transport and tool execution.
|
||||
- `native-request.ts` is the only adapter file that should construct `LLM.request(...)`, `LLM.model(...)`, `Message.*`, `SystemPart`, `ToolCallPart`, `ToolResultPart`, or `ToolDefinition` values from `@opencode-ai/llm`.
|
||||
- `ai-sdk.ts` and `native-runtime.ts` both emit `@opencode-ai/llm` `LLMEvent`s so downstream session processing does not care which runtime handled the request.
|
||||
|
||||
Keep new integration code on one of these seams. Avoid importing session services into `native-request.ts`; pass normalized data through `RequestInput` instead.
|
||||
|
||||
## 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.
|
||||
@@ -63,5 +86,5 @@ 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.
|
||||
- Native execution currently supports OpenAI, opencode-managed OpenAI-compatible, and Anthropic API-key paths backed by `@ai-sdk/openai`, `@ai-sdk/openai-compatible`, or `@ai-sdk/anthropic` catalog entries.
|
||||
- Unsupported providers, OpenAI OAuth, and missing API-key cases fall back to AI SDK.
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
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 {
|
||||
AmazonBedrock,
|
||||
Anthropic,
|
||||
Azure,
|
||||
Google,
|
||||
OpenAI,
|
||||
OpenAICompatible,
|
||||
OpenRouter,
|
||||
} from "@opencode-ai/llm/providers"
|
||||
import type { ModelMessage } from "ai"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { isRecord } from "@/util/record"
|
||||
@@ -26,24 +34,6 @@ export type RequestInput = {
|
||||
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(
|
||||
@@ -147,33 +137,46 @@ const generation = (input: RequestInput) => {
|
||||
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
|
||||
const baseURL = (input: Provider.Model | RequestInput) =>
|
||||
"model" in input ? (input.baseURL ?? (input.model.api.url || undefined)) : input.api.url || undefined
|
||||
|
||||
const requireBaseURL = (model: Provider.Model, url: string | undefined) => {
|
||||
if (url) return url
|
||||
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,
|
||||
const url = baseURL(input)
|
||||
const options = {
|
||||
...("model" in input && input.apiKey ? { apiKey: input.apiKey } : {}),
|
||||
...(url ? { baseURL: url } : {}),
|
||||
headers: Object.keys({ ...model.headers, ...headers }).length === 0 ? undefined : { ...model.headers, ...headers },
|
||||
limits: {
|
||||
context: model.limit.context,
|
||||
output: model.limit.output,
|
||||
},
|
||||
})
|
||||
}
|
||||
if (model.api.npm === "@ai-sdk/openai") return OpenAI.configure(options).responses(model.api.id)
|
||||
if (model.api.npm === "@ai-sdk/azure")
|
||||
return Azure.configure({ ...options, baseURL: requireBaseURL(model, url) }).responses(model.api.id)
|
||||
if (model.api.npm === "@ai-sdk/anthropic") return Anthropic.configure(options).model(model.api.id)
|
||||
if (model.api.npm === "@ai-sdk/google") return Google.configure(options).model(model.api.id)
|
||||
if (model.api.npm === "@ai-sdk/amazon-bedrock") return AmazonBedrock.configure(options).model(model.api.id)
|
||||
if (model.api.npm === "@ai-sdk/openai-compatible")
|
||||
return OpenAICompatible.configure({
|
||||
...options,
|
||||
provider: String(model.providerID),
|
||||
baseURL: requireBaseURL(model, url),
|
||||
}).model(model.api.id)
|
||||
if (model.api.npm === "@openrouter/ai-sdk-provider") return OpenRouter.configure(options).model(model.api.id)
|
||||
throw new Error(`Native LLM request adapter does not support provider package ${model.api.npm}`)
|
||||
}
|
||||
|
||||
export const request = (input: RequestInput) => {
|
||||
const converted = messages(input.messages)
|
||||
// This is the only native adapter boundary that should construct canonical
|
||||
// @opencode-ai/llm request objects from opencode's session/AI SDK-shaped data.
|
||||
return LLM.request({
|
||||
model: model(input, input.headers),
|
||||
system: [...(input.system ?? []).map(SystemPart.make), ...converted.system],
|
||||
|
||||
@@ -41,8 +41,8 @@ export function status(input: Pick<StreamInput, "model" | "provider" | "auth">):
|
||||
if (providerID !== "openai" && providerID !== "anthropic" && !providerID.startsWith("opencode"))
|
||||
return { type: "unsupported", reason: "provider is not openai, opencode, or anthropic" }
|
||||
const npm = input.model.api.npm
|
||||
if (npm !== "@ai-sdk/openai" && npm !== "@ai-sdk/anthropic")
|
||||
return { type: "unsupported", reason: "provider package is not OpenAI or Anthropic" }
|
||||
if (npm !== "@ai-sdk/openai" && npm !== "@ai-sdk/openai-compatible" && npm !== "@ai-sdk/anthropic")
|
||||
return { type: "unsupported", reason: "provider package is not OpenAI, OpenAI-compatible, or Anthropic" }
|
||||
if (input.auth?.type === "oauth") return { type: "unsupported", reason: "OAuth auth is not supported" }
|
||||
|
||||
const apiKey = typeof input.provider.options.apiKey === "string" ? input.provider.options.apiKey : input.provider.key
|
||||
@@ -59,6 +59,8 @@ export function stream(input: StreamInput): StreamResult {
|
||||
const current = status(input)
|
||||
if (current.type === "unsupported") return current
|
||||
|
||||
// Integration point with @opencode-ai/llm: native-request lowers session data
|
||||
// into an LLMRequest, then LLMClient handles route selection and transport.
|
||||
return {
|
||||
...current,
|
||||
stream: input.llmClient.stream({
|
||||
@@ -99,6 +101,8 @@ export function nativeTools(tools: Record<string, Tool>, input: Pick<StreamInput
|
||||
return Object.fromEntries(
|
||||
Object.entries(tools).map(([name, item]) => [
|
||||
name,
|
||||
// Tool execution remains opencode-owned. The native runtime only adapts
|
||||
// the @opencode-ai/llm tool call back into the AI SDK Tool.execute shape.
|
||||
nativeTool({
|
||||
description: item.description ?? "",
|
||||
jsonSchema: nativeSchema(item.inputSchema),
|
||||
|
||||
@@ -278,9 +278,11 @@ export const layer = Layer.effect(
|
||||
return { call: ctx.toolcalls[input.id], part }
|
||||
})
|
||||
|
||||
const isFilePart = Schema.is(MessageV2.FilePart)
|
||||
const isFilePart = (value: unknown): value is MessageV2.FilePart => Schema.is(MessageV2.FilePart)(value)
|
||||
|
||||
const toolResultOutput = (value: Extract<StreamEvent, { type: "tool-result" }>) => {
|
||||
const toolResultOutput = (
|
||||
value: Extract<StreamEvent, { type: "tool-result" }>,
|
||||
): { title: string; metadata: Record<string, any>; output: string; attachments?: MessageV2.FilePart[] } => {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user