diff --git a/packages/core/src/plugin/provider.ts b/packages/core/src/plugin/provider.ts index 6025e70ac8..bc65b772d1 100644 --- a/packages/core/src/plugin/provider.ts +++ b/packages/core/src/plugin/provider.ts @@ -15,6 +15,7 @@ import { GoogleVertexPlugin } from "./provider/google-vertex.js" import { GroqPlugin } from "./provider/groq.js" import { KiloPlugin } from "./provider/kilo.js" import { LLMGatewayPlugin } from "./provider/llmgateway.js" +import { LMStudioPlugin } from "./provider/lmstudio.js" import { MistralPlugin } from "./provider/mistral.js" import { NvidiaPlugin } from "./provider/nvidia.js" import { OpenAIPlugin } from "./provider/openai.js" @@ -48,6 +49,7 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [ GroqPlugin, KiloPlugin, LLMGatewayPlugin, + LMStudioPlugin, MistralPlugin, NvidiaPlugin, OpencodePlugin, diff --git a/packages/core/src/plugin/provider/lmstudio.ts b/packages/core/src/plugin/provider/lmstudio.ts new file mode 100644 index 0000000000..9e81151c55 --- /dev/null +++ b/packages/core/src/plugin/provider/lmstudio.ts @@ -0,0 +1,173 @@ +import { define } from "@opencode-ai/plugin/effect/plugin" +import { Document, type Entry } from "@opencode-ai/schema/config" +import { Duration, Effect, Schedule, Schema, Semaphore, Stream } from "effect" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { Config } from "../../config.js" +import { Model } from "../../model.js" +import { Provider } from "../../provider.js" +import type { PluginInternal } from "../internal.js" + +const providerID = "lmstudio" + +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) }) +const discovery = new Map() +const discoveryLock = Semaphore.makeUnsafe(1) + +export function make(origin = "http://127.0.0.1:1234", interval: Duration.Input = "30 seconds") { + return define({ + id: "opencode.provider.lmstudio", + effect: Effect.fn(function* (ctx) { + const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient) + const config = yield* Config.Service + const source = { current: configured(yield* config.entries(), origin) } + const loaded = { models: [] as (typeof RemoteModel.Type)[], hash: "[]" } + + yield* ctx.integration.transform((integrations) => { + if (loaded.models.length === 0) return + integrations.remove(providerID) + }) + + yield* ctx.catalog.transform((catalog) => { + if (loaded.models.length === 0) return + for (const model of catalog.provider.get(providerID)?.models.values() ?? []) { + catalog.model.remove(providerID, model.id) + } + catalog.provider.update(providerID, (provider) => { + provider.name = "LM Studio" + provider.package = "@opencode-ai/ai/providers/openai-compatible" + provider.settings = { + baseURL: source.current.baseURL, + provider: providerID, + apiKey: source.current.apiKey ?? "", + } + provider.integrationID = undefined + }) + for (const item of loaded.models) { + catalog.model.update(providerID, item.key, (model) => { + model.modelID = Model.ID.make(item.key) + model.name = item.display_name || item.key + model.family = item.architecture ? Model.Family.make(item.architecture) : undefined + model.capabilities = { + tools: item.capabilities?.trained_for_tool_use ?? false, + input: ["text", ...(item.capabilities?.vision ? ["image"] : [])], + output: ["text"], + } + model.limit = { + context: + item.loaded_instances.length === 0 + ? item.max_context_length + : Math.min(...item.loaded_instances.map((instance) => instance.config.context_length)), + output: 0, + } + }) + } + }) + + const discover = Effect.fn("LMStudioPlugin.discover")(function* () { + const current = source.current + if (!current.endpoint) return undefined + return yield* discoveryLock.withPermit( + Effect.gen(function* () { + const cached = discovery.get(current.endpoint) + if (cached && cached.apiKey === current.apiKey && Date.now() - cached.checked < Duration.toMillis(interval)) + return { source: current, models: cached.models } + discovery.set(current.endpoint, { + checked: Date.now(), + apiKey: current.apiKey, + models: cached && cached.apiKey === current.apiKey ? cached.models : undefined, + }) + const request = current.apiKey + ? HttpClientRequest.get(current.endpoint).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bearerToken(current.apiKey), + ) + : HttpClientRequest.get(current.endpoint).pipe(HttpClientRequest.acceptJson) + const response = yield* http + .execute(request) + .pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(Response)), Effect.timeout("1 second")) + const models = response.models + .filter((model) => model.type === "llm" && model.key.length > 0) + .toSorted((a, b) => a.key.localeCompare(b.key)) + discovery.set(current.endpoint, { checked: Date.now(), apiKey: current.apiKey, models }) + return { source: current, models } + }), + ) + }) + + const refresh = Effect.fn("LMStudioPlugin.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.catalog.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("LMStudioPlugin.reload")(function* () { + const next = configured(yield* config.entries(), origin) + if ( + next.baseURL === source.current.baseURL && + next.apiKey === source.current.apiKey && + next.endpoint === source.current.endpoint + ) + return + source.current = next + loaded.models = [] + loaded.hash = "[]" + yield* ctx.integration.reload() + yield* ctx.catalog.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) +} + +export const LMStudioPlugin = make() + +function configured(entries: readonly Entry[], origin: string) { + const settings = entries + .filter((entry): entry is Document => entry.type === "document") + .flatMap((entry) => { + const settings = entry.info.providers?.[providerID]?.settings + return settings ? [settings] : [] + }) + .reduce((result, item) => Provider.mergeOverlay(result, item), 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.endsWith("/v1") ? url.pathname.slice(0, -3) : url.pathname.replace(/\/+$/, "") + url.pathname = `${prefix}/api/v1/models` + url.search = "" + url.hash = "" + return { baseURL, apiKey, endpoint: url.toString() } +} 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 0000000000..f21f65142c --- /dev/null +++ b/packages/core/test/plugin/provider-lmstudio.test.ts @@ -0,0 +1,338 @@ +import { Bus } from "@opencode-ai/core/bus" +import { Catalog } from "@opencode-ai/core/catalog" +import { Config } from "@opencode-ai/core/config" +import { Integration } from "@opencode-ai/core/integration" +import { Model } from "@opencode-ai/core/model" +import { Plugin } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" +import { LMStudioPlugin, make } from "@opencode-ai/core/plugin/provider/lmstudio" +import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" +import { Provider } from "@opencode-ai/core/provider" +import { Document, Event, Info } from "@opencode-ai/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 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", () => { + it.effect("is registered as a built-in provider plugin", () => + Effect.sync(() => { + 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: "embedding", + key: "nomic-embed", + display_name: "Nomic Embed", + loaded_instances: [], + max_context_length: 2048, + }, + ], + }), + }), + ), + (server) => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + yield* addPlugin(server.url.origin) + const providerID = Provider.ID.make("lmstudio") + const gemma = yield* eventually( + catalog.model.get(providerID, Model.ID.make("google/gemma-4-26b-a4b")), + (model) => model !== undefined, + ) + + expect(yield* catalog.provider.get(providerID)).toEqual({ + id: providerID, + name: "LM Studio", + package: "@opencode-ai/ai/providers/openai-compatible", + settings: { baseURL: `${server.url.origin}/v1`, provider: "lmstudio", apiKey: "" }, + }) + expect((yield* catalog.provider.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: 0 }, + }) + expect(yield* catalog.model.get(providerID, Model.ID.make("deepseek-r1"))).toMatchObject({ + capabilities: { tools: false, input: ["text"], output: ["text"] }, + limit: { context: 131_072, output: 0 }, + }) + expect(yield* catalog.model.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 catalog = yield* Catalog.Service + const providerID = Provider.ID.make("lmstudio") + yield* addPlugin(server.url.origin, "5 millis") + expect(yield* catalog.provider.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( + catalog.model.get(providerID, Model.ID.make("qwen/qwen3-coder")), + (model) => model !== undefined, + ), + ).toMatchObject({ name: "Qwen 3 Coder" }) + + models.splice(0) + yield* eventually(catalog.provider.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 catalog = yield* Catalog.Service + const config = yield* Config.Test + const providerID = Provider.ID.make("lmstudio") + yield* addPlugin(initial.url.origin) + yield* eventually( + catalog.model.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( + catalog.model.get(providerID, Model.ID.make("configured-model")), + (model) => model !== undefined, + ) + + expect(requests).toContainEqual({ authorization: "Bearer secret", path: "/proxy/api/v1/models" }) + expect(yield* catalog.model.get(providerID, Model.ID.make("initial-model"))).toBeUndefined() + expect((yield* catalog.provider.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(catalog.provider.get(providerID), (provider) => provider?.settings?.apiKey === "") + expect(requests).toContainEqual({ authorization: null, 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 catalog = yield* Catalog.Service + yield* addPlugin(server.url.origin) + yield* addPlugin(server.url.origin) + yield* eventually( + catalog.model.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 catalog = yield* Catalog.Service + const integrations = yield* Integration.Service + const providerID = Provider.ID.make("lmstudio") + yield* integrations.transform((draft) => { + draft.update(Integration.ID.make("lmstudio"), (integration) => { + integration.name = "LMStudio" + }) + draft.method.update({ + integrationID: Integration.ID.make("lmstudio"), + method: { type: "env", names: ["LMSTUDIO_API_KEY"] }, + }) + }) + yield* catalog.transform((draft) => { + draft.provider.update(providerID, (provider) => { + provider.name = "LMStudio" + provider.package = "aisdk:@ai-sdk/openai-compatible" + provider.integrationID = Integration.ID.make("lmstudio") + }) + draft.model.update(providerID, Model.ID.make("static-model"), () => {}) + }) + + expect((yield* catalog.provider.available()).map((provider) => provider.id)).not.toContain(providerID) + yield* addPlugin(server.url.origin, "5 millis") + yield* eventually( + catalog.model.get(providerID, Model.ID.make("discovered-model")), + (model) => model !== undefined, + ) + + expect(yield* integrations.get(Integration.ID.make("lmstudio"))).toBeUndefined() + expect((yield* catalog.provider.get(providerID))?.integrationID).toBeUndefined() + expect(yield* catalog.model.get(providerID, Model.ID.make("static-model"))).toBeUndefined() + expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID) + + yield* integrations.transform((draft) => { + draft.update(Integration.ID.make("lmstudio"), (integration) => { + integration.name = "Configured LM Studio" + }) + draft.method.update({ integrationID: Integration.ID.make("lmstudio"), method: { type: "key" } }) + }) + expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID) + + models.splice(0) + yield* eventually( + catalog.model.get(providerID, Model.ID.make("static-model")), + (model) => model !== undefined, + ) + expect(yield* catalog.model.get(providerID, Model.ID.make("discovered-model"))).toBeUndefined() + expect(yield* integrations.get(Integration.ID.make("lmstudio"))).toBeDefined() + expect((yield* catalog.provider.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/www/content/docs/(Configure)/models.mdx b/packages/www/content/docs/(Configure)/models.mdx index 105efa0c52..2d5e0e3d90 100644 --- a/packages/www/content/docs/(Configure)/models.mdx +++ b/packages/www/content/docs/(Configure)/models.mdx @@ -152,6 +152,38 @@ provider and model configuration. An unknown variant fails model resolution inst ### Local models +OpenCode automatically discovers language models from an unauthenticated LM Studio server listening on its default +address, `http://127.0.0.1:1234`. Discovered models use the `lmstudio` provider ID and LM Studio's model key: + +```jsonc title="opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "model": "lmstudio/google/gemma-4-26b-a4b", +} +``` + +OpenCode refreshes the inventory in the background and reads context, vision, and tool-use capabilities from LM +Studio. Embedding models are excluded because they cannot drive a session. Disable discovery with +`"plugins": ["-opencode.provider.lmstudio"]`. + +For a different host or port, configure the OpenAI-compatible base URL. Models are still discovered automatically: + +```jsonc title="opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "providers": { + "lmstudio": { + "settings": { + "baseURL": "http://127.0.0.1:5678/v1", + "apiKey": "{env:LMSTUDIO_API_KEY}", + }, + }, + }, +} +``` + +Omit `apiKey` when LM Studio authentication is disabled. + For an OpenAI-compatible server, define a provider package, endpoint, and at least one model: ```jsonc title="opencode.jsonc" @@ -161,7 +193,7 @@ For an OpenAI-compatible server, define a provider package, endpoint, and at lea "providers": { "local": { "name": "Local server", - "package": "aisdk:@ai-sdk/openai-compatible", + "package": "@opencode-ai/ai/providers/openai-compatible", "settings": { "baseURL": "http://127.0.0.1:1234/v1", },