Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions packages/core/src/plugin/provider/lmstudio.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Effect, Schema } from "effect"
import { createLocalProviderPlugin } from "./local.js"

const RemoteModel = Schema.Struct({
type: Schema.Literals(["llm", "embedding"]),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept decision models before filtering the catalog

When LM Studio has a decision model installed, decoding the complete /api/v1/models response fails before the later type === "llm" filter can discard it, so no LLM inventory is refreshed. LM Studio documents decision as a valid model type returned by this endpoint; include it in the decoding union while continuing to filter it from the provider catalog. See the LM Studio model-list response fields.

Useful? React with 👍 / 👎.

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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Decode nullable LM Studio context limits

When any model in the LM Studio response has an unknown maximum context length, the documented null value is rejected by this required integer schema and the entire discovery request fails, including otherwise valid LLMs. Decode max_context_length as nullable and let the existing optional context handling retain the default limit. See the LM Studio model-list response fields.

Useful? React with 👍 / 👎.

}).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()
193 changes: 193 additions & 0 deletions packages/core/src/plugin/provider/local.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createClient>

export function createLocalProviderPlugin<Discovered>(input: {
readonly id: string
readonly providerID: string
readonly name: string
readonly origin: string
readonly stripPathSuffix?: RegExp
readonly discover: (client: LocalDiscoveryClient) => Effect.Effect<readonly Discovered[], unknown>
readonly model: (item: NoInfer<Discovered>) => LocalModel
}) {
const discovery = new Map<string, { checked: number; apiKey?: string; models?: readonly Discovered[] }>()
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: <A, I>(path: string, schema: Schema.Codec<A, I>) =>
http
.execute(auth(HttpClientRequest.get(endpoint(path))))
.pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)), Effect.timeout("1 second")),
postJson: <B, BI, A, AI>(
path: string,
bodySchema: Schema.Codec<B, BI>,
body: B,
responseSchema: Schema.Codec<A, AI>,
) =>
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 }
}
92 changes: 92 additions & 0 deletions packages/core/src/plugin/provider/ollama.ts
Original file line number Diff line number Diff line change
@@ -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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept null Ollama family lists

When any installed Ollama model has details.families: null, this schema rejects the complete /api/tags response, so discovery loses every otherwise valid completion model; the same schema also rejects that model's /api/show response. Ollama's API can serialize an unset family slice as null, as shown in responses reported in the official Ollama repository, so this field must accept both an array and null.

Useful? React with 👍 / 👎.

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<string, { digest: string; info: typeof ShowResponse.Type }>
}

const shows = new Map<string, ShowCache>()

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<string, { digest: string; info: typeof ShowResponse.Type }>()
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)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve cached models when a show request transiently fails

When an existing Ollama model receives a new digest and its /api/show request temporarily times out or returns an error, this handler converts the failure to undefined. The discovery pass therefore succeeds with that model omitted, and refresh replaces the previously loaded inventory and removes the model until a later poll succeeds, contradicting the intended behavior of retaining inventory through transient outages. Preserve the previous entry for transient per-model failures while still allowing permanent failures such as 404s to remove it.

Useful? React with 👍 / 👎.

{ 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()
34 changes: 34 additions & 0 deletions packages/core/src/plugin/provider/vllm.ts
Original file line number Diff line number Diff line change
@@ -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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow vLLM model cards to omit the context limit

When a configured vLLM endpoint returns a valid model card without max_model_len, this required property makes the entire /v1/models response fail schema decoding even though the mapper can safely retain its default context limit. The upstream model-card field has a default and vLLM's production-stack router can omit it from discovery responses, as documented in the vLLM production-stack report; make the property optional as well as nullable.

Useful? React with 👍 / 👎.

})

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()
Loading
Loading