mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-26 14:24:46 +00:00
173 lines
5.6 KiB
TypeScript
173 lines
5.6 KiB
TypeScript
export * as AISDK from "./aisdk"
|
|
|
|
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
|
import { Cause, Context, Effect, Layer, Schema } from "effect"
|
|
import { ModelV2 } from "./model"
|
|
import { PluginV2 } from "./plugin"
|
|
import { ProviderV2 } from "./provider"
|
|
|
|
type SDK = any
|
|
|
|
function wrapSSE(res: Response, ms: number, ctl: AbortController) {
|
|
if (typeof ms !== "number" || ms <= 0) return res
|
|
if (!res.body) return res
|
|
if (!res.headers.get("content-type")?.includes("text/event-stream")) return res
|
|
|
|
const reader = res.body.getReader()
|
|
const body = new ReadableStream<Uint8Array>({
|
|
async pull(ctrl) {
|
|
const part = await new Promise<Awaited<ReturnType<typeof reader.read>>>((resolve, reject) => {
|
|
const id = setTimeout(() => {
|
|
const err = new Error("SSE read timed out")
|
|
ctl.abort(err)
|
|
void reader.cancel(err)
|
|
reject(err)
|
|
}, ms)
|
|
|
|
reader.read().then(
|
|
(part) => {
|
|
clearTimeout(id)
|
|
resolve(part)
|
|
},
|
|
(err) => {
|
|
clearTimeout(id)
|
|
reject(err)
|
|
},
|
|
)
|
|
})
|
|
|
|
if (part.done) {
|
|
ctrl.close()
|
|
return
|
|
}
|
|
|
|
ctrl.enqueue(part.value)
|
|
},
|
|
async cancel(reason) {
|
|
ctl.abort(reason)
|
|
await reader.cancel(reason)
|
|
},
|
|
})
|
|
|
|
return new Response(body, {
|
|
headers: new Headers(res.headers),
|
|
status: res.status,
|
|
statusText: res.statusText,
|
|
})
|
|
}
|
|
|
|
function prepareOptions(model: ModelV2.Info, pkg: string) {
|
|
const options: Record<string, any> = { name: model.providerID, ...model.options.aisdk.provider }
|
|
if (model.endpoint.type === "aisdk" && model.endpoint.url) options.baseURL = model.endpoint.url
|
|
|
|
const customFetch = options.fetch
|
|
const chunkTimeout = options.chunkTimeout
|
|
delete options.chunkTimeout
|
|
options.fetch = async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
|
const opts = { ...(init ?? {}) }
|
|
const signals = [
|
|
opts.signal,
|
|
typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined,
|
|
options.timeout !== undefined && options.timeout !== null && options.timeout !== false
|
|
? AbortSignal.timeout(options.timeout)
|
|
: undefined,
|
|
].filter((item): item is AbortSignal | AbortController => Boolean(item))
|
|
const chunkAbortCtl = signals.find((item): item is AbortController => item instanceof AbortController)
|
|
const abortSignals = signals.map((item) => (item instanceof AbortController ? item.signal : item))
|
|
if (abortSignals.length === 1) opts.signal = abortSignals[0]
|
|
if (abortSignals.length > 1) opts.signal = AbortSignal.any(abortSignals)
|
|
|
|
if ((pkg === "@ai-sdk/openai" || pkg === "@ai-sdk/azure") && opts.body && opts.method === "POST") {
|
|
const body = JSON.parse(opts.body as string)
|
|
if (body.store !== true && Array.isArray(body.input)) {
|
|
for (const item of body.input) {
|
|
if ("id" in item) delete item.id
|
|
}
|
|
opts.body = JSON.stringify(body)
|
|
}
|
|
}
|
|
|
|
const res = await (typeof customFetch === "function" ? customFetch : fetch)(input, {
|
|
...opts,
|
|
timeout: false,
|
|
})
|
|
if (!chunkAbortCtl || typeof chunkTimeout !== "number") return res
|
|
return wrapSSE(res, chunkTimeout, chunkAbortCtl)
|
|
}
|
|
|
|
return options
|
|
}
|
|
|
|
export class InitError extends Schema.TaggedErrorClass<InitError>()("AISDK.InitError", {
|
|
providerID: ProviderV2.ID,
|
|
cause: Schema.Defect,
|
|
}) {}
|
|
|
|
function initError(providerID: ProviderV2.ID) {
|
|
return Effect.catchCause((cause) => Effect.fail(new InitError({ providerID, cause: Cause.squash(cause) })))
|
|
}
|
|
|
|
export interface Interface {
|
|
readonly language: (model: ModelV2.Info) => Effect.Effect<LanguageModelV3, InitError>
|
|
}
|
|
|
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/AISDK") {}
|
|
|
|
export const layer = Layer.effect(
|
|
Service,
|
|
Effect.gen(function* () {
|
|
const plugin = yield* PluginV2.Service
|
|
const languages = new Map<string, LanguageModelV3>()
|
|
const sdks = new Map<string, SDK>()
|
|
|
|
return Service.of({
|
|
language: Effect.fn("AISDK.language")(function* (model) {
|
|
const key = `${model.providerID}/${model.id}/${model.options.variant ?? "default"}`
|
|
const existing = languages.get(key)
|
|
if (existing) return existing
|
|
if (model.endpoint.type !== "aisdk")
|
|
return yield* new InitError({
|
|
providerID: model.providerID,
|
|
cause: new Error(`Unsupported endpoint ${model.endpoint.type}`),
|
|
})
|
|
|
|
const options = prepareOptions(model, model.endpoint.package)
|
|
const sdkKey = JSON.stringify({
|
|
providerID: model.providerID,
|
|
endpoint: model.endpoint,
|
|
options,
|
|
})
|
|
const sdk =
|
|
sdks.get(sdkKey) ??
|
|
(yield* plugin
|
|
.trigger("aisdk.sdk", { model, package: model.endpoint.package, options }, {})
|
|
.pipe(initError(model.providerID))).sdk
|
|
if (!sdk)
|
|
return yield* new InitError({
|
|
providerID: model.providerID,
|
|
cause: new Error("No AISDK provider plugin returned an SDK"),
|
|
})
|
|
sdks.set(sdkKey, sdk)
|
|
const result = yield* plugin
|
|
.trigger(
|
|
"aisdk.language",
|
|
{
|
|
model,
|
|
sdk,
|
|
options,
|
|
},
|
|
{},
|
|
)
|
|
.pipe(initError(model.providerID))
|
|
const language = yield* Effect.sync(() => result.language ?? sdk.languageModel(model.apiID)).pipe(
|
|
initError(model.providerID),
|
|
)
|
|
languages.set(key, language)
|
|
return language
|
|
}),
|
|
})
|
|
}),
|
|
)
|
|
|
|
export const defaultLayer = layer.pipe(Layer.provide(PluginV2.defaultLayer))
|