mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-21 03:15:11 +00:00
refactor(llm): isolate native runtime preview
This commit is contained in:
115
packages/opencode/src/session/llm-native-runtime.ts
Normal file
115
packages/opencode/src/session/llm-native-runtime.ts
Normal file
@@ -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<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") 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<typeof asSchema>[0]).jsonSchema as JsonSchema
|
||||
}
|
||||
|
||||
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) }),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
export * as LLMNativeRuntime from "./llm-native-runtime"
|
||||
@@ -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<StreamInput, "tools" | "agent" | "permission"
|
||||
return Record.filter(input.tools, (_, k) => 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<typeof asSchema>[0]).jsonSchema as JsonSchema
|
||||
}
|
||||
|
||||
function nativeTools(tools: Record<string, Tool>, 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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user