diff --git a/packages/opencode/src/session/llm-native-runtime.ts b/packages/opencode/src/session/llm-native-runtime.ts new file mode 100644 index 0000000000..ba2f1a648e --- /dev/null +++ b/packages/opencode/src/session/llm-native-runtime.ts @@ -0,0 +1,115 @@ +import type { Auth } from "@/auth" +import type { Provider } from "@/provider/provider" +import { ProviderTransform } from "@/provider/transform" +import { errorMessage } from "@/util/error" +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 "./llm-native" + +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 } + | { 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 + readonly toolChoice?: "auto" | "required" | "none" + readonly temperature?: number + readonly topP?: number + readonly topK?: number + readonly maxOutputTokens?: number + readonly providerOptions?: Record + readonly headers: Record + readonly abort: AbortSignal +} + +export function status(input: Pick): RuntimeStatus { + if (input.model.providerID !== "openai") return { type: "unsupported", reason: "provider is not openai" } + 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: "OpenAI OAuth 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: input.headers, + }), + tools: nativeTools(input.tools, input), + }), + } +} + +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[0]).jsonSchema as JsonSchema +} + +function nativeTools(tools: Record, input: Pick) { + 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) }), + }), + }), + ]), + ) +} + +export * as LLMNativeRuntime from "./llm-native-runtime" diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 29de530112..a6aea4ffbc 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -2,8 +2,8 @@ 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 as aiTool, jsonSchema, asSchema } from "ai" -import { tool as nativeTool, ToolFailure, type JsonSchema, type LLMEvent } from "@opencode-ai/llm" +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" @@ -28,7 +28,7 @@ import { EffectBridge } from "@/effect/bridge" import * as Option from "effect/Option" import * as OtelTracer from "@effect/opentelemetry/Tracer" import { LLMAISDK } from "./llm-ai-sdk" -import { LLMNative } from "./llm-native" +import { LLMNativeRuntime } from "./llm-native-runtime" const log = Log.create({ service: "llm" }) export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX @@ -358,31 +358,31 @@ const live: Layer.Layer< } if (runtime() === "native") { - if (input.model.providerID !== "openai" || input.model.api.npm !== "@ai-sdk/openai") { - return yield* Effect.fail(new Error("Native LLM runtime currently only supports OpenAI models")) - } - const apiKey = - info?.type === "api" ? info.key : typeof item.options.apiKey === "string" ? item.options.apiKey : undefined - if (!apiKey) return yield* Effect.fail(new Error("Native LLM runtime requires API key auth for OpenAI")) - const baseURL = typeof item.options.baseURL === "string" ? item.options.baseURL : undefined - const request = LLMNative.request({ + const native = LLMNativeRuntime.stream({ model: input.model, - apiKey, - baseURL, - system: isOpenaiOauth ? system : [], - messages: ProviderTransform.message(messages, input.model, options), + 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: ProviderTransform.providerOptions(input.model, params.options), + providerOptions: params.options, headers: requestHeaders, + abort: input.abort, }) - return { - type: "native" as const, - stream: llmClient.stream({ request, tools: nativeTools(sortedTools, input) }), + if (native.type === "supported") { + return { + type: "native" as const, + stream: native.stream, + } } + l.info("native runtime unavailable; falling back to ai-sdk", { reason: native.reason }) } return { @@ -502,37 +502,6 @@ function resolveTools(input: Pick input.user.tools?.[k] !== false && !disabled.has(k)) } -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[0]).jsonSchema as JsonSchema -} - -function nativeTools(tools: Record, input: StreamRequest) { - return Object.fromEntries( - Object.entries(tools).map(([name, item]) => [ - name, - nativeTool({ - description: item.description ?? "", - jsonSchema: nativeSchema(item.inputSchema), - execute: (args: unknown, ctx?: { readonly id: string; readonly name: string }) => - 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) }), - }), - }), - ]), - ) -} - // Check if messages contain any tool-call content // Used to determine if a dummy tool should be added for LiteLLM proxy compatibility export function hasToolCalls(messages: ModelMessage[]): boolean { diff --git a/packages/opencode/test/session/llm-native.test.ts b/packages/opencode/test/session/llm-native.test.ts index 40aa71df4d..21564f14ff 100644 --- a/packages/opencode/test/session/llm-native.test.ts +++ b/packages/opencode/test/session/llm-native.test.ts @@ -3,6 +3,7 @@ import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route" import { jsonSchema, tool, type ModelMessage } from "ai" import { Effect } from "effect" import { LLMNative } from "@/session/llm-native" +import { LLMNativeRuntime } from "@/session/llm-native-runtime" import type { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "@/provider/schema" @@ -57,6 +58,15 @@ const baseModel: Provider.Model = { release_date: "2026-01-01", } +const providerInfo: Provider.Info = { + id: ProviderID.make("openai"), + name: "OpenAI", + source: "config", + env: ["OPENAI_API_KEY"], + options: { apiKey: "test-openai-key" }, + models: {}, +} + describe("session.llm-native.request", () => { test("maps normalized stream inputs to a native LLM request", () => { const messages: ModelMessage[] = [ @@ -234,6 +244,27 @@ describe("session.llm-native.request", () => { ).toThrow("Native LLM request adapter does not support provider package unknown-provider") }) + test("only enables native runtime for supported OpenAI API-key models", () => { + expect(LLMNativeRuntime.status({ model: baseModel, provider: providerInfo, auth: undefined })).toMatchObject({ + type: "supported", + apiKey: "test-openai-key", + }) + expect( + LLMNativeRuntime.status({ + model: { ...baseModel, providerID: ProviderID.make("anthropic") }, + provider: { ...providerInfo, id: ProviderID.make("anthropic") }, + auth: undefined, + }), + ).toEqual({ type: "unsupported", reason: "provider is not openai" }) + expect( + LLMNativeRuntime.status({ + model: baseModel, + provider: providerInfo, + auth: { type: "oauth", refresh: "refresh", access: "access", expires: 1 }, + }), + ).toEqual({ type: "unsupported", reason: "OpenAI OAuth is not supported" }) + }) + test("compiles through the native OpenAI Responses route", async () => { const prepared = await Effect.runPromise( LLMClient.prepare(