From 27035673b887b4789bd4837a93bb4ed4ffc6ebee Mon Sep 17 00:00:00 2001 From: ivanmilevtues Date: Thu, 15 Oct 2026 04:23:45 +0200 Subject: [PATCH] feat(core): showcase shared local provider discovery Copy the six changed-file snapshots from anomalyco/opencode PR #53438 for an architecture-diff demo against this fork's dev. This is not a runnable backport: upstream plugin APIs and prerequisite provider registrations are absent here. Upstream source: https://github.com/anomalyco/opencode/pull/53438 Original author: Kit Langton Amp-Thread-ID: https://ampcode.com/threads/T-01a13d0d-fbe1-76fa-8fad-21c3940a2399 Co-authored-by: Amp --- packages/core/src/plugin/provider/lmstudio.ts | 49 +++ packages/core/src/plugin/provider/local.ts | 193 +++++++++ packages/core/src/plugin/provider/ollama.ts | 92 +++++ packages/core/src/plugin/provider/vllm.ts | 34 ++ .../test/plugin/provider-lmstudio.test.ts | 360 +++++++++++++++++ .../core/test/plugin/provider-ollama.test.ts | 367 ++++++++++++++++++ 6 files changed, 1095 insertions(+) create mode 100644 packages/core/src/plugin/provider/lmstudio.ts create mode 100644 packages/core/src/plugin/provider/local.ts create mode 100644 packages/core/src/plugin/provider/ollama.ts create mode 100644 packages/core/src/plugin/provider/vllm.ts create mode 100644 packages/core/test/plugin/provider-lmstudio.test.ts create mode 100644 packages/core/test/plugin/provider-ollama.test.ts diff --git a/packages/core/src/plugin/provider/lmstudio.ts b/packages/core/src/plugin/provider/lmstudio.ts new file mode 100644 index 000000000000..255672f38d0d --- /dev/null +++ b/packages/core/src/plugin/provider/lmstudio.ts @@ -0,0 +1,49 @@ +import { Effect, Schema } from "effect" +import { createLocalProviderPlugin } from "./local.js" + +const RemoteModel = Schema.Struct({ + type: Schema.Literals(["llm", "embedding"]), + key: Schema.String, + display_name: Schema.String, + architecture: Schema.NullOr(Schema.String).pipe(Schema.optional), + loaded_instances: Schema.Array( + Schema.Struct({ + config: Schema.Struct({ context_length: Schema.Int }), + }), + ), + max_context_length: Schema.Int, + capabilities: Schema.Struct({ + vision: Schema.Boolean, + trained_for_tool_use: Schema.Boolean, + }).pipe(Schema.optional), +}) + +const Response = Schema.Struct({ models: Schema.Array(RemoteModel) }) + +export const make = createLocalProviderPlugin({ + id: "opencode.provider.lmstudio", + providerID: "lmstudio", + name: "LM Studio", + origin: "http://127.0.0.1:1234", + stripPathSuffix: /\/(?:api\/)?v1$/, + discover: (client) => + Effect.gen(function* () { + const response = yield* client.getJson("/api/v1/models", Response) + return response.models + .filter((model) => model.type === "llm" && model.key.length > 0) + .toSorted((a, b) => a.key.localeCompare(b.key)) + }), + model: (item) => ({ + id: item.key, + name: item.display_name || item.key, + family: item.architecture, + tools: item.capabilities?.trained_for_tool_use, + vision: item.capabilities?.vision, + context: + item.loaded_instances.length === 0 + ? item.max_context_length + : Math.min(...item.loaded_instances.map((instance) => instance.config.context_length)), + }), +}) + +export const LMStudioPlugin = make() diff --git a/packages/core/src/plugin/provider/local.ts b/packages/core/src/plugin/provider/local.ts new file mode 100644 index 000000000000..5b77dd8f5d95 --- /dev/null +++ b/packages/core/src/plugin/provider/local.ts @@ -0,0 +1,193 @@ +import { define } from "@opencode/plugin/effect/plugin" +import type { Entry } from "@opencode/schema/config" +import { Duration, Effect, Schedule, type Schema, Semaphore, Stream } from "effect" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { Config } from "../../config.js" +import { Model } from "../../model.js" +import type { PluginInternal } from "../internal.js" +import { foldSettings } from "./configured.js" + +export type LocalModel = { + readonly id: string + readonly name?: string + readonly family?: string | null + readonly tools?: boolean + readonly vision?: boolean + readonly context?: number | null +} + +export type LocalDiscoveryClient = ReturnType + +export function createLocalProviderPlugin(input: { + readonly id: string + readonly providerID: string + readonly name: string + readonly origin: string + readonly stripPathSuffix?: RegExp + readonly discover: (client: LocalDiscoveryClient) => Effect.Effect + readonly model: (item: NoInfer) => LocalModel +}) { + const discovery = new Map() + const discoveryLock = Semaphore.makeUnsafe(1) + const suffix = input.stripPathSuffix ?? /\/v1$/ + + return function make(origin = input.origin, interval: Duration.Input = "30 seconds") { + return define({ + id: input.id, + effect: Effect.fn(function* (ctx) { + const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient) + const config = yield* Config.Service + const source = { current: configured(yield* config.entries(), input.providerID, origin, suffix) } + const loaded = { models: [] as readonly Discovered[], hash: "[]" } + + yield* ctx.integration.transform((integrations) => { + if (loaded.models.length === 0) return + integrations.remove(input.providerID) + }) + + yield* ctx.provider.transform((providers) => { + if (loaded.models.length === 0) return + for (const model of providers.get(input.providerID)?.models.values() ?? []) { + providers.models.remove(input.providerID, model.id) + } + providers.update(input.providerID, (provider) => { + provider.name = input.name + provider.activation = "enabled" + provider.package = "@opencode/ai/providers/openai-compatible" + provider.settings = { + baseURL: source.current.baseURL, + provider: input.providerID, + apiKey: source.current.apiKey ?? "", + } + provider.integrationID = undefined + }) + for (const item of loaded.models) { + const spec = input.model(item) + providers.models.update(input.providerID, spec.id, (model) => { + model.modelID = Model.ID.make(spec.id) + model.name = spec.name || spec.id + model.family = spec.family ? Model.Family.make(spec.family) : undefined + model.capabilities = { + tools: spec.tools ?? false, + input: ["text", ...(spec.vision ? ["image"] : [])], + output: ["text"], + } + if (typeof spec.context === "number" && spec.context > 0) model.limit.context = spec.context + }) + } + }) + + const discover = Effect.fn("LocalProviderPlugin.discover")(function* () { + const current = source.current + if (!current.endpoint) return undefined + return yield* discoveryLock.withPermit( + Effect.gen(function* () { + const cached = discovery.get(current.root) + if ( + cached && + cached.apiKey === current.apiKey && + Date.now() - cached.checked < Duration.toMillis(interval) + ) + return { source: current, models: cached.models } + discovery.set(current.root, { + checked: Date.now(), + apiKey: current.apiKey, + models: cached && cached.apiKey === current.apiKey ? cached.models : undefined, + }) + const models = yield* input.discover(createClient(http, current.root, current.apiKey, current.endpoint)) + discovery.set(current.root, { checked: Date.now(), apiKey: current.apiKey, models }) + return { source: current, models } + }), + ) + }) + + const refresh = Effect.fn("LocalProviderPlugin.refresh")(function* () { + const result = yield* discover() + if (!result?.models || result.source !== source.current) return + const hash = JSON.stringify(result.models) + if (hash === loaded.hash) return + loaded.models = result.models + loaded.hash = hash + yield* ctx.integration.reload() + yield* ctx.provider.reload() + }) + + // Keep the last successful inventory through transient outages instead of flickering model availability. + yield* refresh().pipe(Effect.ignore, Effect.repeat(Schedule.spaced(interval)), Effect.forkScoped) + const reload = Effect.fn("LocalProviderPlugin.reload")(function* () { + const next = configured(yield* config.entries(), input.providerID, origin, suffix) + if ( + next.baseURL === source.current.baseURL && + next.apiKey === source.current.apiKey && + next.root === source.current.root + ) + return + source.current = next + loaded.models = [] + loaded.hash = "[]" + yield* ctx.integration.reload() + yield* ctx.provider.reload() + yield* refresh().pipe(Effect.ignore) + }) + yield* ctx.event.subscribe().pipe( + Stream.filter((event) => event.type === "config.updated"), + Stream.runForEach(reload), + Effect.forkScoped({ startImmediately: true }), + ) + }), + } satisfies PluginInternal.InternalPlugin) + } +} + +function createClient( + http: HttpClient.HttpClient, + root: string, + apiKey: string | undefined, + endpoint: (path: string) => string, +) { + const auth = (request: HttpClientRequest.HttpClientRequest) => + apiKey + ? request.pipe(HttpClientRequest.acceptJson, HttpClientRequest.bearerToken(apiKey)) + : request.pipe(HttpClientRequest.acceptJson) + return { + root, + apiKey, + get: (path: string) => http.execute(auth(HttpClientRequest.get(endpoint(path)))).pipe(Effect.timeout("1 second")), + getJson: (path: string, schema: Schema.Codec) => + http + .execute(auth(HttpClientRequest.get(endpoint(path)))) + .pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)), Effect.timeout("1 second")), + postJson: ( + path: string, + bodySchema: Schema.Codec, + body: B, + responseSchema: Schema.Codec, + ) => + auth(HttpClientRequest.post(endpoint(path))).pipe( + HttpClientRequest.schemaBodyJson(bodySchema)(body), + Effect.flatMap(http.execute), + Effect.flatMap(HttpClientResponse.schemaBodyJson(responseSchema)), + Effect.timeout("1 second"), + ), + } +} + +function configured(entries: readonly Entry[], providerID: string, origin: string, suffix: RegExp) { + const settings = foldSettings(entries, providerID, undefined) + const baseURL = ( + typeof settings?.baseURL === "string" ? settings.baseURL : `${origin.replace(/\/+$/, "")}/v1` + ).replace(/\/+$/, "") + const apiKey = typeof settings?.apiKey === "string" ? settings.apiKey : undefined + if (!URL.canParse(baseURL)) return { baseURL, apiKey } + const url = new URL(baseURL) + if (url.protocol !== "http:" && url.protocol !== "https:") return { baseURL, apiKey } + const prefix = url.pathname.replace(/\/+$/, "").replace(suffix, "") + const endpoint = (path: string) => { + const next = new URL(url) + next.pathname = `${prefix}${path}` + next.search = "" + next.hash = "" + return next.toString() + } + return { baseURL, apiKey, root: endpoint(""), endpoint } +} diff --git a/packages/core/src/plugin/provider/ollama.ts b/packages/core/src/plugin/provider/ollama.ts new file mode 100644 index 000000000000..815edcecd7da --- /dev/null +++ b/packages/core/src/plugin/provider/ollama.ts @@ -0,0 +1,92 @@ +import { Effect, Schema } from "effect" +import { createLocalProviderPlugin } from "./local.js" + +const Details = Schema.Struct({ + parent_model: Schema.String.pipe(Schema.optional), + format: Schema.String, + family: Schema.String, + families: Schema.Array(Schema.String).pipe(Schema.optional), + parameter_size: Schema.String, + quantization_level: Schema.String, +}) + +const RemoteModel = Schema.Struct({ + name: Schema.String, + model: Schema.String, + remote_model: Schema.String.pipe(Schema.optional), + remote_host: Schema.String.pipe(Schema.optional), + modified_at: Schema.String, + size: Schema.Int, + digest: Schema.String, + details: Details, +}) + +const TagsResponse = Schema.Struct({ models: Schema.Array(RemoteModel) }) +const ShowRequest = Schema.Struct({ model: Schema.String }) +const ShowResponse = Schema.Struct({ + parameters: Schema.String.pipe(Schema.optional), + license: Schema.String.pipe(Schema.optional), + modified_at: Schema.String.pipe(Schema.optional), + details: Details.pipe(Schema.optional), + template: Schema.String.pipe(Schema.optional), + capabilities: Schema.Array(Schema.String).pipe(Schema.optional), + model_info: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), +}) + +type DiscoveredModel = typeof RemoteModel.Type & { show: typeof ShowResponse.Type } +type ShowCache = { + apiKey?: string + entries: Map +} + +const shows = new Map() + +export const make = createLocalProviderPlugin({ + id: "opencode.provider.ollama", + providerID: "ollama", + name: "Ollama", + origin: "http://127.0.0.1:11434", + stripPathSuffix: /\/(?:v1|api)$/, + discover: (client) => + Effect.gen(function* () { + const cached = shows.get(client.root) + const previous = cached && cached.apiKey === client.apiKey ? cached.entries : new Map() + shows.set(client.root, { apiKey: client.apiKey, entries: previous }) + const response = yield* client.getJson("/api/tags", TagsResponse) + const summaries = response.models + .filter((model) => model.model.length > 0) + .toSorted((a, b) => a.model.localeCompare(b.model)) + const entries = new Map() + const models = yield* Effect.forEach( + summaries, + (model) => + Effect.gen(function* () { + const saved = previous.get(model.model) + const info = + saved?.digest === model.digest + ? saved.info + : yield* client.postJson("/api/show", ShowRequest, { model: model.model }, ShowResponse) + entries.set(model.model, { digest: model.digest, info }) + return { ...model, show: info } + }).pipe(Effect.orElseSucceed(() => undefined)), + { concurrency: 4 }, + ) + shows.set(client.root, { apiKey: client.apiKey, entries }) + return models.filter( + (model): model is DiscoveredModel => + model !== undefined && (model.show.capabilities?.includes("completion") ?? false), + ) + }), + model: (item) => ({ + id: item.model, + name: item.name || item.model, + family: item.show.details?.family || item.details.family, + tools: item.show.capabilities?.includes("tools"), + vision: item.show.capabilities?.includes("vision"), + context: Object.entries(item.show.model_info ?? {}).flatMap(([key, value]) => + key.endsWith(".context_length") && typeof value === "number" && value > 0 ? [value] : [], + )[0], + }), +}) + +export const OllamaPlugin = make() diff --git a/packages/core/src/plugin/provider/vllm.ts b/packages/core/src/plugin/provider/vllm.ts new file mode 100644 index 000000000000..75a5ccfd941c --- /dev/null +++ b/packages/core/src/plugin/provider/vllm.ts @@ -0,0 +1,34 @@ +import { Effect, Schema } from "effect" +import { createLocalProviderPlugin } from "./local.js" + +const providerID = "vllm" + +const RemoteModel = Schema.Struct({ + id: Schema.String, + owned_by: Schema.String, + max_model_len: Schema.NullOr(Schema.Int), +}) + +const Response = Schema.Struct({ data: Schema.Array(RemoteModel) }) + +export const make = createLocalProviderPlugin({ + id: "opencode.provider.vllm", + providerID, + name: "vLLM", + origin: "http://127.0.0.1:8000", + discover: (client) => + Effect.gen(function* () { + yield* client.get("/health") + const response = yield* client.getJson("/v1/models", Response) + return response.data + .filter((model) => model.owned_by === providerID && model.id.length > 0) + .toSorted((a, b) => a.id.localeCompare(b.id)) + }), + // Tool calling depends on vLLM server flags and parsers that model discovery does not report. + model: (item) => ({ + id: item.id, + context: item.max_model_len, + }), +}) + +export const VLLMPlugin = make() diff --git a/packages/core/test/plugin/provider-lmstudio.test.ts b/packages/core/test/plugin/provider-lmstudio.test.ts new file mode 100644 index 000000000000..3a4af6e94f2b --- /dev/null +++ b/packages/core/test/plugin/provider-lmstudio.test.ts @@ -0,0 +1,360 @@ +import { Bus } from "@opencode/core/bus" +import { Config } from "@opencode/core/config" +import { Integration } from "@opencode/core/integration" +import { Model } from "@opencode/core/model" +import { Plugin } from "@opencode/core/plugin" +import { PluginHost } from "@opencode/core/plugin/host" +import { LMStudioPlugin, make } from "@opencode/core/plugin/provider/lmstudio" +import { ProviderPlugins } from "@opencode/core/plugin/provider" +import { Provider } from "@opencode/core/provider" +import { Document, Event, Info } from "@opencode/schema/config" +import { describe, expect, test } from "bun:test" +import { Duration, Effect, Layer, Schema } from "effect" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(Layer.merge(PluginTestLayer, Config.testLayer())) +const decode = Schema.decodeUnknownSync(Info) + +const addPlugin = Effect.fn(function* (origin: string, interval: Duration.Input = "1 hour") { + const plugin = yield* Plugin.Service + const host = yield* PluginHost.make(plugin) + yield* make(origin, interval).effect(host) +}) + +function eventually( + effect: Effect.Effect, + predicate: (value: A) => boolean, + remaining = 3000, +): Effect.Effect { + return Effect.gen(function* () { + const value = yield* effect + if (predicate(value)) return value + if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value")) + yield* Effect.promise(() => Bun.sleep(1)) + return yield* eventually(effect, predicate, remaining - 1) + }) +} + +describe("LMStudioPlugin", () => { + test("is registered as a built-in provider plugin", () => { + expect(LMStudioPlugin.id).toBe("opencode.provider.lmstudio") + expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.lmstudio") + }) + + it.live("discovers local language models with their capabilities and effective context", () => + Effect.acquireUseRelease( + Effect.sync(() => + Bun.serve({ + port: 0, + fetch: () => + Response.json({ + models: [ + { + type: "llm", + key: "google/gemma-4-26b-a4b", + display_name: "Gemma 4 26B A4B", + architecture: "gemma4", + loaded_instances: [{ config: { context_length: 32_768 } }, { config: { context_length: 16_384 } }], + max_context_length: 262_144, + capabilities: { vision: true, trained_for_tool_use: true }, + }, + { + type: "llm", + key: "deepseek-r1", + display_name: "DeepSeek R1", + architecture: "deepseek", + loaded_instances: [], + max_context_length: 131_072, + capabilities: { vision: false, trained_for_tool_use: false }, + }, + { + type: "llm", + key: "unknown-context", + display_name: "Unknown Context", + loaded_instances: [], + max_context_length: 0, + }, + { + type: "embedding", + key: "nomic-embed", + display_name: "Nomic Embed", + loaded_instances: [], + max_context_length: 2048, + }, + ], + }), + }), + ), + (server) => + Effect.gen(function* () { + const providers = yield* Provider.Service + const modelState = yield* Model.Service + yield* addPlugin(server.url.origin) + const providerID = Provider.ID.make("lmstudio") + const gemma = yield* eventually( + modelState.get(providerID, Model.ID.make("google/gemma-4-26b-a4b")), + (model) => model !== undefined, + ) + + expect(yield* providers.get(providerID)).toEqual({ + id: providerID, + name: "LM Studio", + activation: "enabled", + package: "@opencode/ai/providers/openai-compatible", + settings: { baseURL: `${server.url.origin}/v1`, provider: "lmstudio", apiKey: "" }, + }) + expect((yield* providers.available()).map((provider) => provider.id)).toContain(providerID) + expect(gemma).toMatchObject({ + family: "gemma4", + name: "Gemma 4 26B A4B", + capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, + limit: { context: 16_384, output: 32_000 }, + }) + expect(yield* modelState.get(providerID, Model.ID.make("deepseek-r1"))).toMatchObject({ + capabilities: { tools: false, input: ["text"], output: ["text"] }, + limit: { context: 131_072, output: 32_000 }, + }) + expect(yield* modelState.get(providerID, Model.ID.make("unknown-context"))).toMatchObject({ + limit: { context: 200_000, output: 32_000 }, + }) + expect(yield* modelState.get(providerID, Model.ID.make("nomic-embed"))).toBeUndefined() + }), + (server) => Effect.promise(() => server.stop(true)), + ), + ) + + it.live("refreshes the catalog when LM Studio models change", () => + Effect.acquireUseRelease( + Effect.sync(() => { + const models: Array> = [] + return { + models, + server: Bun.serve({ port: 0, fetch: () => Response.json({ models }) }), + } + }), + ({ models, server }) => + Effect.gen(function* () { + const providers = yield* Provider.Service + const modelState = yield* Model.Service + const providerID = Provider.ID.make("lmstudio") + yield* addPlugin(server.url.origin, "5 millis") + expect(yield* providers.get(providerID)).toBeUndefined() + + models.push({ + type: "llm", + key: "qwen/qwen3-coder", + display_name: "Qwen 3 Coder", + architecture: "qwen3", + loaded_instances: [], + max_context_length: 65_536, + capabilities: { vision: false, trained_for_tool_use: true }, + }) + expect( + yield* eventually( + modelState.get(providerID, Model.ID.make("qwen/qwen3-coder")), + (model) => model !== undefined, + ), + ).toMatchObject({ name: "Qwen 3 Coder" }) + + models.splice(0) + yield* eventually(providers.get(providerID), (provider) => provider === undefined) + }), + ({ server }) => Effect.promise(() => server.stop(true)), + ), + ) + + it.live( + "discovers from configured endpoints with bearer authentication", + () => + Effect.acquireUseRelease( + Effect.sync(() => { + const requests: Array<{ authorization: string | null; path: string }> = [] + const model = (key: string) => ({ + type: "llm", + key, + display_name: key, + loaded_instances: [], + max_context_length: 32_768, + }) + return { + requests, + initial: Bun.serve({ port: 0, fetch: () => Response.json({ models: [model("initial-model")] }) }), + configured: Bun.serve({ + port: 0, + fetch: (request) => { + requests.push({ + authorization: request.headers.get("authorization"), + path: new URL(request.url).pathname, + }) + return Response.json({ models: [model("configured-model")] }) + }, + }), + } + }), + ({ requests, initial, configured }) => + Effect.gen(function* () { + const bus = yield* Bus.Service + const providers = yield* Provider.Service + const modelState = yield* Model.Service + const config = yield* Config.Test + const providerID = Provider.ID.make("lmstudio") + yield* addPlugin(initial.url.origin) + yield* eventually( + modelState.get(providerID, Model.ID.make("initial-model")), + (model) => model !== undefined, + ) + + const baseURL = `${configured.url.origin}/proxy/v1` + yield* config.setEntries([configuration(baseURL, "secret")]) + yield* bus.publish(Event.Updated, {}) + yield* eventually( + modelState.get(providerID, Model.ID.make("configured-model")), + (model) => model !== undefined, + ) + + expect(requests).toContainEqual({ authorization: "Bearer secret", path: "/proxy/api/v1/models" }) + expect(yield* modelState.get(providerID, Model.ID.make("initial-model"))).toBeUndefined() + expect((yield* providers.get(providerID))?.settings).toEqual({ + baseURL, + provider: "lmstudio", + apiKey: "secret", + }) + + requests.splice(0) + yield* config.setEntries([configuration(baseURL, "secret"), configuration(baseURL, null)]) + yield* bus.publish(Event.Updated, {}) + yield* eventually(providers.get(providerID), (provider) => provider?.settings?.apiKey === "") + expect(requests).toContainEqual({ authorization: null, path: "/proxy/api/v1/models" }) + + requests.splice(0) + const apiBaseURL = `${configured.url.origin}/proxy/api/v1` + yield* config.setEntries([configuration(apiBaseURL, "api-secret")]) + yield* bus.publish(Event.Updated, {}) + yield* eventually(providers.get(providerID), (provider) => provider?.settings?.baseURL === apiBaseURL) + expect(requests).toContainEqual({ authorization: "Bearer api-secret", path: "/proxy/api/v1/models" }) + }), + ({ initial, configured }) => Effect.promise(() => Promise.all([initial.stop(true), configured.stop(true)])), + ), + 10_000, + ) + + it.live("shares discovery requests across plugin instances", () => + Effect.acquireUseRelease( + Effect.sync(() => { + const requests = { count: 0 } + return { + requests, + server: Bun.serve({ + port: 0, + fetch: () => { + requests.count++ + return Response.json({ + models: [ + { + type: "llm", + key: "shared-model", + display_name: "Shared Model", + loaded_instances: [], + max_context_length: 32_768, + }, + ], + }) + }, + }), + } + }), + ({ requests, server }) => + Effect.gen(function* () { + const modelState = yield* Model.Service + yield* addPlugin(server.url.origin) + yield* addPlugin(server.url.origin) + yield* eventually( + modelState.get(Provider.ID.make("lmstudio"), Model.ID.make("shared-model")), + (model) => model !== undefined, + ) + expect(requests.count).toBe(1) + }), + ({ server }) => Effect.promise(() => server.stop(true)), + ), + ) + + it.live("replaces the credential-gated Models.dev catalog when discovery succeeds", () => + Effect.acquireUseRelease( + Effect.sync(() => { + const models = [ + { + type: "llm", + key: "discovered-model", + display_name: "Discovered Model", + loaded_instances: [], + max_context_length: 32_768, + }, + ] + return { models, server: Bun.serve({ port: 0, fetch: () => Response.json({ models }) }) } + }), + ({ models, server }) => + Effect.gen(function* () { + const providers = yield* Provider.Service + const modelState = yield* Model.Service + const integrations = yield* Integration.Service + const providerID = Provider.ID.make("lmstudio") + yield* integrations.transform((editor) => { + editor.update(Integration.ID.make("lmstudio"), (integration) => { + integration.name = "LMStudio" + }) + editor.method.update({ + integrationID: Integration.ID.make("lmstudio"), + method: { type: "env", names: ["LMSTUDIO_API_KEY"] }, + }) + }) + yield* providers.transform((editor) => { + editor.update(providerID, (provider) => { + provider.name = "LMStudio" + provider.package = "@opencode/ai/providers/openai-compatible" + provider.integrationID = Integration.ID.make("lmstudio") + }) + editor.models.update(providerID, Model.ID.make("static-model"), () => {}) + }) + + expect((yield* providers.available()).map((provider) => provider.id)).not.toContain(providerID) + yield* addPlugin(server.url.origin, "5 millis") + yield* eventually( + modelState.get(providerID, Model.ID.make("discovered-model")), + (model) => model !== undefined, + ) + + expect(yield* integrations.get(Integration.ID.make("lmstudio"))).toBeUndefined() + expect((yield* providers.get(providerID))?.integrationID).toBeUndefined() + expect(yield* modelState.get(providerID, Model.ID.make("static-model"))).toBeUndefined() + expect((yield* providers.available()).map((provider) => provider.id)).toContain(providerID) + + yield* integrations.transform((editor) => { + editor.update(Integration.ID.make("lmstudio"), (integration) => { + integration.name = "Configured LM Studio" + }) + editor.method.update({ integrationID: Integration.ID.make("lmstudio"), method: { type: "key" } }) + }) + expect((yield* providers.available()).map((provider) => provider.id)).toContain(providerID) + + models.splice(0) + yield* eventually( + providers.snapshot(), + (snapshot) => snapshot.records.get(providerID)?.models.has(Model.ID.make("static-model")) === true, + ) + expect(yield* modelState.get(providerID, Model.ID.make("discovered-model"))).toBeUndefined() + expect(yield* modelState.get(providerID, Model.ID.make("static-model"))).toBeUndefined() + expect(yield* integrations.get(Integration.ID.make("lmstudio"))).toBeDefined() + expect((yield* providers.get(providerID))?.integrationID).toBe(Integration.ID.make("lmstudio")) + }), + ({ server }) => Effect.promise(() => server.stop(true)), + ), + ) +}) + +function configuration(baseURL: string, apiKey: string | null) { + return new Document({ + type: "document", + info: decode({ providers: { lmstudio: { settings: { baseURL, apiKey } } } }), + }) +} diff --git a/packages/core/test/plugin/provider-ollama.test.ts b/packages/core/test/plugin/provider-ollama.test.ts new file mode 100644 index 000000000000..ed4a8ced4db7 --- /dev/null +++ b/packages/core/test/plugin/provider-ollama.test.ts @@ -0,0 +1,367 @@ +import { Bus } from "@opencode/core/bus" +import { Config } from "@opencode/core/config" +import { Integration } from "@opencode/core/integration" +import { Model } from "@opencode/core/model" +import { Plugin } from "@opencode/core/plugin" +import { PluginHost } from "@opencode/core/plugin/host" +import { OllamaPlugin, make } from "@opencode/core/plugin/provider/ollama" +import { ProviderPlugins } from "@opencode/core/plugin/provider" +import { Provider } from "@opencode/core/provider" +import { Document, Event, Info } from "@opencode/schema/config" +import { describe, expect } from "bun:test" +import { Duration, Effect, Layer, Schema } from "effect" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(Layer.merge(PluginTestLayer, Config.testLayer())) +const decode = Schema.decodeUnknownSync(Info) +const decodeShowRequest = Schema.decodeUnknownSync(Schema.Struct({ model: Schema.String })) + +const addPlugin = Effect.fn(function* (origin: string, interval: Duration.Input = "1 hour") { + const plugin = yield* Plugin.Service + const host = yield* PluginHost.make(plugin) + yield* make(origin, interval).effect(host) +}) + +function eventually( + effect: Effect.Effect, + predicate: (value: A) => boolean, + remaining = 3000, +): Effect.Effect { + return Effect.gen(function* () { + const value = yield* effect + if (predicate(value)) return value + if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value")) + yield* Effect.promise(() => Bun.sleep(1)) + return yield* eventually(effect, predicate, remaining - 1) + }) +} + +describe("OllamaPlugin", () => { + it.live("discovers local completion models and native metadata", () => + Effect.acquireUseRelease( + Effect.sync(() => { + const requests: Array<{ method: string; path: string; model?: string }> = [] + return { + requests, + server: Bun.serve({ + port: 0, + fetch: async (request) => { + const path = new URL(request.url).pathname + if (request.method === "GET") { + requests.push({ method: request.method, path }) + return Response.json({ + models: [ + summary("gemma3:4b", "gemma-digest", "gemma3"), + summary("unknown-context", "unknown-digest"), + summary("nomic-embed", "embed-digest"), + summary("removed-model", "removed-digest"), + ], + }) + } + const body = decodeShowRequest(await request.json()) + requests.push({ method: request.method, path, model: body.model }) + if (body.model === "removed-model") return new Response("Not found", { status: 404 }) + return Response.json( + body.model === "gemma3:4b" + ? { + capabilities: ["completion", "tools", "vision"], + model_info: { "gemma3.context_length": 131_072 }, + } + : body.model === "unknown-context" + ? show({ family: "unknown", capabilities: ["completion"], context: 0 }) + : show({ family: "nomic-bert", capabilities: ["embedding"], context: 8192 }), + ) + }, + }), + } + }), + ({ requests, server }) => + Effect.gen(function* () { + const providers = yield* Provider.Service + const modelState = yield* Model.Service + const providerID = Provider.ID.make("ollama") + expect(OllamaPlugin.id).toBe("opencode.provider.ollama") + expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.ollama") + yield* addPlugin(server.url.origin) + const model = yield* eventually( + modelState.get(providerID, Model.ID.make("gemma3:4b")), + (item) => item !== undefined, + ) + + expect(yield* providers.get(providerID)).toEqual({ + id: providerID, + name: "Ollama", + activation: "enabled", + package: "@opencode/ai/providers/openai-compatible", + settings: { baseURL: `${server.url.origin}/v1`, provider: "ollama", apiKey: "" }, + }) + expect(model).toMatchObject({ + modelID: "gemma3:4b", + name: "gemma3:4b", + family: "gemma3", + capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, + limit: { context: 131_072, output: 32_000 }, + }) + expect(yield* modelState.get(providerID, Model.ID.make("unknown-context"))).toMatchObject({ + limit: { context: 200_000, output: 32_000 }, + }) + expect(yield* modelState.get(providerID, Model.ID.make("nomic-embed"))).toBeUndefined() + expect(requests).toContainEqual({ method: "GET", path: "/api/tags" }) + expect(requests).toContainEqual({ method: "POST", path: "/api/show", model: "gemma3:4b" }) + expect(requests).toContainEqual({ method: "POST", path: "/api/show", model: "nomic-embed" }) + expect(requests).toContainEqual({ method: "POST", path: "/api/show", model: "removed-model" }) + }), + ({ server }) => Effect.promise(() => server.stop(true)), + ), + ) + + it.live("refreshes changed digests and retains inventory through transient failures", () => + Effect.acquireUseRelease( + Effect.sync(() => { + const state = { digest: "digest-1", context: 32_768, fail: false } + const requests = { tags: 0, show: 0 } + return { + state, + requests, + server: Bun.serve({ + port: 0, + fetch: async (request) => { + if (request.method === "GET") { + requests.tags++ + if (state.fail) return new Response("unavailable", { status: 503 }) + return Response.json({ models: [summary("qwen3:8b", state.digest, "qwen3")] }) + } + decodeShowRequest(await request.json()) + requests.show++ + return Response.json( + show({ family: "qwen3", capabilities: ["completion", "tools"], context: state.context }), + ) + }, + }), + } + }), + ({ state, requests, server }) => + Effect.gen(function* () { + const modelState = yield* Model.Service + const providerID = Provider.ID.make("ollama") + const modelID = Model.ID.make("qwen3:8b") + yield* addPlugin(server.url.origin, "5 millis") + yield* eventually(modelState.get(providerID, modelID), (model) => model?.limit.context === 32_768) + yield* eventually( + Effect.sync(() => requests.tags), + (count) => count >= 2, + ) + expect(requests.show).toBe(1) + + state.digest = "digest-2" + state.context = 65_536 + yield* eventually(modelState.get(providerID, modelID), (model) => model?.limit.context === 65_536) + expect(requests.show).toBe(2) + + state.fail = true + yield* Effect.promise(() => Bun.sleep(30)) + expect((yield* modelState.get(providerID, modelID))?.limit.context).toBe(65_536) + }), + ({ server }) => Effect.promise(() => server.stop(true)), + ), + ) + + it.live("replaces and restores the same-ID Models.dev provider", () => + Effect.acquireUseRelease( + Effect.sync(() => { + const models = [summary("discovered-model", "digest")] + return { + models, + server: Bun.serve({ + port: 0, + fetch: async (request) => { + if (request.method === "GET") return Response.json({ models }) + decodeShowRequest(await request.json()) + return Response.json(show({ capabilities: ["completion"], context: 32_768 })) + }, + }), + } + }), + ({ models, server }) => + Effect.gen(function* () { + const providers = yield* Provider.Service + const modelState = yield* Model.Service + const integrations = yield* Integration.Service + const providerID = Provider.ID.make("ollama") + yield* integrations.transform((editor) => { + editor.update(Integration.ID.make("ollama"), (integration) => { + integration.name = "Ollama" + }) + editor.method.update({ + integrationID: Integration.ID.make("ollama"), + method: { type: "env", names: ["OLLAMA_API_KEY"] }, + }) + }) + yield* providers.transform((editor) => { + editor.update(providerID, (provider) => { + provider.name = "Ollama" + provider.package = "@opencode/ai/providers/openai-compatible" + provider.integrationID = Integration.ID.make("ollama") + }) + editor.models.update(providerID, Model.ID.make("static-model"), () => {}) + }) + + yield* addPlugin(server.url.origin, "5 millis") + yield* eventually( + modelState.get(providerID, Model.ID.make("discovered-model")), + (model) => model !== undefined, + ) + expect(yield* integrations.get(Integration.ID.make("ollama"))).toBeUndefined() + expect((yield* providers.get(providerID))?.activation).toBe("enabled") + expect(yield* modelState.get(providerID, Model.ID.make("static-model"))).toBeUndefined() + + models.splice(0) + yield* eventually( + providers.snapshot(), + (snapshot) => snapshot.records.get(providerID)?.models.has(Model.ID.make("static-model")) === true, + ) + expect(yield* modelState.get(providerID, Model.ID.make("discovered-model"))).toBeUndefined() + expect(yield* modelState.get(providerID, Model.ID.make("static-model"))).toBeUndefined() + expect(yield* integrations.get(Integration.ID.make("ollama"))).toBeDefined() + expect((yield* providers.get(providerID))?.activation).toBe("auto") + expect((yield* providers.get(providerID))?.integrationID).toBe(Integration.ID.make("ollama")) + }), + ({ server }) => Effect.promise(() => server.stop(true)), + ), + ) + + it.live( + "reloads layered endpoint and bearer authentication settings", + () => + Effect.acquireUseRelease( + Effect.sync(() => { + const requests: Array<{ authorization: string | null; method: string; path: string }> = [] + return { + requests, + initial: Bun.serve({ + port: 0, + fetch: async (request) => { + if (request.method === "GET") + return Response.json({ models: [summary("initial-model", "initial-digest")] }) + decodeShowRequest(await request.json()) + return Response.json(show({ capabilities: ["completion"], context: 4096 })) + }, + }), + configured: Bun.serve({ + port: 0, + fetch: async (request) => { + requests.push({ + authorization: request.headers.get("authorization"), + method: request.method, + path: new URL(request.url).pathname, + }) + if (request.method === "GET") + return Response.json({ models: [summary("configured-model", "configured-digest")] }) + decodeShowRequest(await request.json()) + return Response.json(show({ capabilities: ["completion", "vision"], context: 65_536 })) + }, + }), + } + }), + ({ requests, initial, configured }) => + Effect.gen(function* () { + const bus = yield* Bus.Service + const providers = yield* Provider.Service + const modelState = yield* Model.Service + const config = yield* Config.Test + const providerID = Provider.ID.make("ollama") + yield* addPlugin(initial.url.origin) + yield* eventually( + modelState.get(providerID, Model.ID.make("initial-model")), + (model) => model !== undefined, + ) + + const baseURL = `${configured.url.origin}/proxy/v1` + yield* config.setEntries([configuration({ baseURL, apiKey: "old" }), configuration({ apiKey: "secret" })]) + yield* bus.publish(Event.Updated, {}) + yield* eventually( + modelState.get(providerID, Model.ID.make("configured-model")), + (model) => model !== undefined, + ) + expect(requests).toContainEqual({ authorization: "Bearer secret", method: "GET", path: "/proxy/api/tags" }) + expect(requests).toContainEqual({ authorization: "Bearer secret", method: "POST", path: "/proxy/api/show" }) + expect(yield* modelState.get(providerID, Model.ID.make("initial-model"))).toBeUndefined() + expect((yield* providers.get(providerID))?.settings).toEqual({ + baseURL, + provider: "ollama", + apiKey: "secret", + }) + + requests.splice(0) + yield* config.setEntries([configuration({ baseURL, apiKey: "secret" }), configuration({ apiKey: null })]) + yield* bus.publish(Event.Updated, {}) + yield* eventually(providers.get(providerID), (provider) => provider?.settings?.apiKey === "") + expect(requests).toContainEqual({ authorization: null, method: "GET", path: "/proxy/api/tags" }) + expect(requests).toContainEqual({ authorization: null, method: "POST", path: "/proxy/api/show" }) + + requests.splice(0) + const apiBaseURL = `${configured.url.origin}/proxy/api` + yield* config.setEntries([configuration({ baseURL: apiBaseURL, apiKey: "api-secret" })]) + yield* bus.publish(Event.Updated, {}) + yield* eventually(providers.get(providerID), (provider) => provider?.settings?.baseURL === apiBaseURL) + expect(requests).toContainEqual({ + authorization: "Bearer api-secret", + method: "GET", + path: "/proxy/api/tags", + }) + expect(requests).toContainEqual({ + authorization: "Bearer api-secret", + method: "POST", + path: "/proxy/api/show", + }) + }), + ({ initial, configured }) => Effect.promise(() => Promise.all([initial.stop(true), configured.stop(true)])), + ), + 10_000, + ) +}) + +function summary(model: string, digest: string, family = "llama") { + return { + name: model, + model, + modified_at: "2026-01-01T00:00:00Z", + size: 1_000_000, + digest, + details: { + format: "gguf", + family, + families: [family], + parameter_size: "8B", + quantization_level: "Q4_K_M", + }, + } +} + +function show(input: { family?: string; capabilities: string[]; context: number }) { + const family = input.family ?? "llama" + return { + parameters: "temperature 0.7", + details: { + parent_model: "", + format: "gguf", + family, + families: [family], + parameter_size: "8B", + quantization_level: "Q4_K_M", + }, + capabilities: input.capabilities, + model_info: { + "general.architecture": family, + [`${family}.context_length`]: input.context, + }, + } +} + +function configuration(settings: Record) { + return new Document({ + type: "document", + info: decode({ providers: { ollama: { settings } } }), + }) +}