2026-05-18 18:41:36 +00:00
|
|
|
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"
|
2026-05-21 03:29:25 +00:00
|
|
|
import { FetchHttpClient } from "effect/unstable/http"
|
2026-05-18 18:41:36 +00:00
|
|
|
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 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 {
|
2026-05-21 03:29:25 +00:00
|
|
|
return statusWithFetch(input, providerFetch(input))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function statusWithFetch(
|
|
|
|
|
input: Pick<StreamInput, "model" | "provider" | "auth">,
|
|
|
|
|
fetch: typeof globalThis.fetch | undefined,
|
|
|
|
|
): RuntimeStatus {
|
2026-05-19 12:20:27 +00:00
|
|
|
const providerID = input.model.providerID
|
|
|
|
|
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
|
2026-05-21 00:15:52 +00:00
|
|
|
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" }
|
2026-05-21 03:29:25 +00:00
|
|
|
if (input.auth?.type === "oauth" && !(input.provider.id === "openai" && fetch)) {
|
|
|
|
|
return { type: "unsupported", reason: "OAuth auth requires a provider fetch override" }
|
|
|
|
|
}
|
2026-05-18 18:41:36 +00:00
|
|
|
|
2026-05-18 21:23:57 +00:00
|
|
|
const apiKey = typeof input.provider.options.apiKey === "string" ? input.provider.options.apiKey : input.provider.key
|
2026-05-19 12:20:27 +00:00
|
|
|
if (!apiKey) return { type: "unsupported", reason: "API key is not configured" }
|
2026-05-18 18:41:36 +00:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
type: "supported",
|
|
|
|
|
apiKey,
|
|
|
|
|
baseURL: typeof input.provider.options.baseURL === "string" ? input.provider.options.baseURL : undefined,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function stream(input: StreamInput): StreamResult {
|
2026-05-21 03:29:25 +00:00
|
|
|
const fetch = providerFetch(input)
|
|
|
|
|
const current = statusWithFetch(input, fetch)
|
2026-05-18 18:41:36 +00:00
|
|
|
if (current.type === "unsupported") return current
|
|
|
|
|
|
2026-05-21 00:15:52 +00:00
|
|
|
// Integration point with @opencode-ai/llm: native-request lowers session data
|
|
|
|
|
// into an LLMRequest, then LLMClient handles route selection and transport.
|
2026-05-24 01:06:45 +00:00
|
|
|
//
|
|
|
|
|
// ProviderTransform.providerOptions builds AI-SDK-shaped options for the
|
|
|
|
|
// selected SDK key (e.g. "openai") and the native LLM SDK reads the same
|
|
|
|
|
// keys via OpenAIOptions.* (store, reasoningEffort, reasoningSummary,
|
|
|
|
|
// include, textVerbosity, promptCacheKey). Both sides intentionally use
|
|
|
|
|
// OpenAI's official wire field names, so this is identity, not translation
|
|
|
|
|
// — if a field ever needs to differ between the two surfaces, the
|
|
|
|
|
// translation belongs here, not split across both packages.
|
2026-05-21 03:29:25 +00:00
|
|
|
const stream = input.llmClient.stream({
|
|
|
|
|
request: LLMNative.request({
|
|
|
|
|
model: input.model,
|
|
|
|
|
apiKey: current.apiKey,
|
|
|
|
|
baseURL: current.baseURL,
|
|
|
|
|
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),
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-18 18:41:36 +00:00
|
|
|
return {
|
|
|
|
|
...current,
|
2026-05-21 03:29:25 +00:00
|
|
|
stream: fetch ? stream.pipe(Stream.provideService(FetchHttpClient.Fetch, fetch)) : stream,
|
2026-05-18 18:41:36 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 03:29:25 +00:00
|
|
|
function providerFetch(input: Pick<StreamInput, "provider" | "auth">): typeof globalThis.fetch | undefined {
|
|
|
|
|
if (input.provider.id !== "openai" || input.auth?.type !== "oauth") return undefined
|
|
|
|
|
const value: unknown = input.provider.options.fetch
|
|
|
|
|
if (typeof value !== "function") return undefined
|
|
|
|
|
return value as typeof globalThis.fetch
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 18:41:36 +00:00
|
|
|
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,
|
2026-05-21 00:15:52 +00:00
|
|
|
// Tool execution remains opencode-owned. The native runtime only adapts
|
|
|
|
|
// the @opencode-ai/llm tool call back into the AI SDK Tool.execute shape.
|
2026-05-18 18:41:36 +00:00
|
|
|
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"
|