|
| 1 | +import { define } from "@opencode/plugin/effect/plugin" |
| 2 | +import type { Entry } from "@opencode/schema/config" |
| 3 | +import { Duration, Effect, Schedule, type Schema, Semaphore, Stream } from "effect" |
| 4 | +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" |
| 5 | +import { Config } from "../../config.js" |
| 6 | +import { Model } from "../../model.js" |
| 7 | +import type { PluginInternal } from "../internal.js" |
| 8 | +import { foldSettings } from "./configured.js" |
| 9 | + |
| 10 | +export type LocalModel = { |
| 11 | + readonly id: string |
| 12 | + readonly name?: string |
| 13 | + readonly family?: string | null |
| 14 | + readonly tools?: boolean |
| 15 | + readonly vision?: boolean |
| 16 | + readonly context?: number | null |
| 17 | +} |
| 18 | + |
| 19 | +export type LocalDiscoveryClient = ReturnType<typeof createClient> |
| 20 | + |
| 21 | +export function createLocalProviderPlugin<Discovered>(input: { |
| 22 | + readonly id: string |
| 23 | + readonly providerID: string |
| 24 | + readonly name: string |
| 25 | + readonly origin: string |
| 26 | + readonly stripPathSuffix?: RegExp |
| 27 | + readonly discover: (client: LocalDiscoveryClient) => Effect.Effect<readonly Discovered[], unknown> |
| 28 | + readonly model: (item: NoInfer<Discovered>) => LocalModel |
| 29 | +}) { |
| 30 | + const discovery = new Map<string, { checked: number; apiKey?: string; models?: readonly Discovered[] }>() |
| 31 | + const discoveryLock = Semaphore.makeUnsafe(1) |
| 32 | + const suffix = input.stripPathSuffix ?? /\/v1$/ |
| 33 | + |
| 34 | + return function make(origin = input.origin, interval: Duration.Input = "30 seconds") { |
| 35 | + return define({ |
| 36 | + id: input.id, |
| 37 | + effect: Effect.fn(function* (ctx) { |
| 38 | + const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient) |
| 39 | + const config = yield* Config.Service |
| 40 | + const source = { current: configured(yield* config.entries(), input.providerID, origin, suffix) } |
| 41 | + const loaded = { models: [] as readonly Discovered[], hash: "[]" } |
| 42 | + |
| 43 | + yield* ctx.integration.transform((integrations) => { |
| 44 | + if (loaded.models.length === 0) return |
| 45 | + integrations.remove(input.providerID) |
| 46 | + }) |
| 47 | + |
| 48 | + yield* ctx.provider.transform((providers) => { |
| 49 | + if (loaded.models.length === 0) return |
| 50 | + for (const model of providers.get(input.providerID)?.models.values() ?? []) { |
| 51 | + providers.models.remove(input.providerID, model.id) |
| 52 | + } |
| 53 | + providers.update(input.providerID, (provider) => { |
| 54 | + provider.name = input.name |
| 55 | + provider.activation = "enabled" |
| 56 | + provider.package = "@opencode/ai/providers/openai-compatible" |
| 57 | + provider.settings = { |
| 58 | + baseURL: source.current.baseURL, |
| 59 | + provider: input.providerID, |
| 60 | + apiKey: source.current.apiKey ?? "", |
| 61 | + } |
| 62 | + provider.integrationID = undefined |
| 63 | + }) |
| 64 | + for (const item of loaded.models) { |
| 65 | + const spec = input.model(item) |
| 66 | + providers.models.update(input.providerID, spec.id, (model) => { |
| 67 | + model.modelID = Model.ID.make(spec.id) |
| 68 | + model.name = spec.name || spec.id |
| 69 | + model.family = spec.family ? Model.Family.make(spec.family) : undefined |
| 70 | + model.capabilities = { |
| 71 | + tools: spec.tools ?? false, |
| 72 | + input: ["text", ...(spec.vision ? ["image"] : [])], |
| 73 | + output: ["text"], |
| 74 | + } |
| 75 | + if (typeof spec.context === "number" && spec.context > 0) model.limit.context = spec.context |
| 76 | + }) |
| 77 | + } |
| 78 | + }) |
| 79 | + |
| 80 | + const discover = Effect.fn("LocalProviderPlugin.discover")(function* () { |
| 81 | + const current = source.current |
| 82 | + if (!current.endpoint) return undefined |
| 83 | + return yield* discoveryLock.withPermit( |
| 84 | + Effect.gen(function* () { |
| 85 | + const cached = discovery.get(current.root) |
| 86 | + if ( |
| 87 | + cached && |
| 88 | + cached.apiKey === current.apiKey && |
| 89 | + Date.now() - cached.checked < Duration.toMillis(interval) |
| 90 | + ) |
| 91 | + return { source: current, models: cached.models } |
| 92 | + discovery.set(current.root, { |
| 93 | + checked: Date.now(), |
| 94 | + apiKey: current.apiKey, |
| 95 | + models: cached && cached.apiKey === current.apiKey ? cached.models : undefined, |
| 96 | + }) |
| 97 | + const models = yield* input.discover(createClient(http, current.root, current.apiKey, current.endpoint)) |
| 98 | + discovery.set(current.root, { checked: Date.now(), apiKey: current.apiKey, models }) |
| 99 | + return { source: current, models } |
| 100 | + }), |
| 101 | + ) |
| 102 | + }) |
| 103 | + |
| 104 | + const refresh = Effect.fn("LocalProviderPlugin.refresh")(function* () { |
| 105 | + const result = yield* discover() |
| 106 | + if (!result?.models || result.source !== source.current) return |
| 107 | + const hash = JSON.stringify(result.models) |
| 108 | + if (hash === loaded.hash) return |
| 109 | + loaded.models = result.models |
| 110 | + loaded.hash = hash |
| 111 | + yield* ctx.integration.reload() |
| 112 | + yield* ctx.provider.reload() |
| 113 | + }) |
| 114 | + |
| 115 | + // Keep the last successful inventory through transient outages instead of flickering model availability. |
| 116 | + yield* refresh().pipe(Effect.ignore, Effect.repeat(Schedule.spaced(interval)), Effect.forkScoped) |
| 117 | + const reload = Effect.fn("LocalProviderPlugin.reload")(function* () { |
| 118 | + const next = configured(yield* config.entries(), input.providerID, origin, suffix) |
| 119 | + if ( |
| 120 | + next.baseURL === source.current.baseURL && |
| 121 | + next.apiKey === source.current.apiKey && |
| 122 | + next.root === source.current.root |
| 123 | + ) |
| 124 | + return |
| 125 | + source.current = next |
| 126 | + loaded.models = [] |
| 127 | + loaded.hash = "[]" |
| 128 | + yield* ctx.integration.reload() |
| 129 | + yield* ctx.provider.reload() |
| 130 | + yield* refresh().pipe(Effect.ignore) |
| 131 | + }) |
| 132 | + yield* ctx.event.subscribe().pipe( |
| 133 | + Stream.filter((event) => event.type === "config.updated"), |
| 134 | + Stream.runForEach(reload), |
| 135 | + Effect.forkScoped({ startImmediately: true }), |
| 136 | + ) |
| 137 | + }), |
| 138 | + } satisfies PluginInternal.InternalPlugin) |
| 139 | + } |
| 140 | +} |
| 141 | + |
| 142 | +function createClient( |
| 143 | + http: HttpClient.HttpClient, |
| 144 | + root: string, |
| 145 | + apiKey: string | undefined, |
| 146 | + endpoint: (path: string) => string, |
| 147 | +) { |
| 148 | + const auth = (request: HttpClientRequest.HttpClientRequest) => |
| 149 | + apiKey |
| 150 | + ? request.pipe(HttpClientRequest.acceptJson, HttpClientRequest.bearerToken(apiKey)) |
| 151 | + : request.pipe(HttpClientRequest.acceptJson) |
| 152 | + return { |
| 153 | + root, |
| 154 | + apiKey, |
| 155 | + get: (path: string) => http.execute(auth(HttpClientRequest.get(endpoint(path)))).pipe(Effect.timeout("1 second")), |
| 156 | + getJson: <A, I>(path: string, schema: Schema.Codec<A, I>) => |
| 157 | + http |
| 158 | + .execute(auth(HttpClientRequest.get(endpoint(path)))) |
| 159 | + .pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)), Effect.timeout("1 second")), |
| 160 | + postJson: <B, BI, A, AI>( |
| 161 | + path: string, |
| 162 | + bodySchema: Schema.Codec<B, BI>, |
| 163 | + body: B, |
| 164 | + responseSchema: Schema.Codec<A, AI>, |
| 165 | + ) => |
| 166 | + auth(HttpClientRequest.post(endpoint(path))).pipe( |
| 167 | + HttpClientRequest.schemaBodyJson(bodySchema)(body), |
| 168 | + Effect.flatMap(http.execute), |
| 169 | + Effect.flatMap(HttpClientResponse.schemaBodyJson(responseSchema)), |
| 170 | + Effect.timeout("1 second"), |
| 171 | + ), |
| 172 | + } |
| 173 | +} |
| 174 | + |
| 175 | +function configured(entries: readonly Entry[], providerID: string, origin: string, suffix: RegExp) { |
| 176 | + const settings = foldSettings(entries, providerID, undefined) |
| 177 | + const baseURL = ( |
| 178 | + typeof settings?.baseURL === "string" ? settings.baseURL : `${origin.replace(/\/+$/, "")}/v1` |
| 179 | + ).replace(/\/+$/, "") |
| 180 | + const apiKey = typeof settings?.apiKey === "string" ? settings.apiKey : undefined |
| 181 | + if (!URL.canParse(baseURL)) return { baseURL, apiKey } |
| 182 | + const url = new URL(baseURL) |
| 183 | + if (url.protocol !== "http:" && url.protocol !== "https:") return { baseURL, apiKey } |
| 184 | + const prefix = url.pathname.replace(/\/+$/, "").replace(suffix, "") |
| 185 | + const endpoint = (path: string) => { |
| 186 | + const next = new URL(url) |
| 187 | + next.pathname = `${prefix}${path}` |
| 188 | + next.search = "" |
| 189 | + next.hash = "" |
| 190 | + return next.toString() |
| 191 | + } |
| 192 | + return { baseURL, apiKey, root: endpoint(""), endpoint } |
| 193 | +} |
0 commit comments