feat(core): discover Ollama models (#43021)

This commit is contained in:
Shoubhit Dash
2026-08-17 15:46:47 +05:30
committed by GitHub
parent 875b28658f
commit fa055143ea
4 changed files with 614 additions and 0 deletions
+2
View File
@@ -18,6 +18,7 @@ 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 { OllamaPlugin } from "./provider/ollama.js"
import { OpenAIPlugin } from "./provider/openai.js"
import { SnowflakeCortexPlugin } from "./provider/snowflake-cortex.js"
import { OpenAICompatiblePlugin } from "./provider/openai-compatible.js"
@@ -52,6 +53,7 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
LMStudioPlugin,
MistralPlugin,
NvidiaPlugin,
OllamaPlugin,
OpencodePlugin,
SnowflakeCortexPlugin,
OpenAICompatiblePlugin,
+233
View File
@@ -0,0 +1,233 @@
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 = "ollama"
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 Discovery = {
checked: number
apiKey?: string
models?: DiscoveredModel[]
shows: Map<string, { digest: string; info: typeof ShowResponse.Type }>
}
const discovery = new Map<string, Discovery>()
const discoveryLock = Semaphore.makeUnsafe(1)
export function make(origin = "http://127.0.0.1:11434", interval: Duration.Input = "30 seconds") {
return define({
id: "opencode.provider.ollama",
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 DiscoveredModel[], 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 = "Ollama"
provider.activation = "enabled"
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.model, (model) => {
model.modelID = Model.ID.make(item.model)
model.name = item.name || item.model
model.family = item.show.details?.family
? Model.Family.make(item.show.details.family)
: item.details.family
? Model.Family.make(item.details.family)
: undefined
model.capabilities = {
tools: item.show.capabilities?.includes("tools") ?? false,
input: ["text", ...(item.show.capabilities?.includes("vision") ? ["image"] : [])],
output: ["text"],
}
model.limit = {
context:
Object.entries(item.show.model_info ?? {}).flatMap(([key, value]) =>
key.endsWith(".context_length") && typeof value === "number" && value > 0 ? [value] : [],
)[0] ?? 0,
output: 0,
}
})
}
})
const discover = Effect.fn("OllamaPlugin.discover")(function* () {
const current = source.current
if (!current.tagsEndpoint || !current.showEndpoint) return undefined
return yield* discoveryLock.withPermit(
Effect.gen(function* () {
const cached = discovery.get(current.tagsEndpoint)
if (cached && cached.apiKey === current.apiKey && Date.now() - cached.checked < Duration.toMillis(interval))
return { source: current, models: cached.models }
const previous: Discovery =
cached && cached.apiKey === current.apiKey
? cached
: { checked: 0, apiKey: current.apiKey, shows: new Map() }
discovery.set(current.tagsEndpoint, { ...previous, checked: Date.now(), apiKey: current.apiKey })
const tagsRequest = current.apiKey
? HttpClientRequest.get(current.tagsEndpoint).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(current.apiKey),
)
: HttpClientRequest.get(current.tagsEndpoint).pipe(HttpClientRequest.acceptJson)
const response = yield* http
.execute(tagsRequest)
.pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(TagsResponse)), Effect.timeout("1 second"))
const summaries = response.models
.filter((model) => model.model.length > 0)
.toSorted((a, b) => a.model.localeCompare(b.model))
const shows = new Map<string, { digest: string; info: typeof ShowResponse.Type }>()
const models = yield* Effect.forEach(
summaries,
(model) =>
Effect.gen(function* () {
const saved = previous.shows.get(model.model)
const info =
saved?.digest === model.digest
? saved.info
: yield* HttpClientRequest.post(current.showEndpoint).pipe(
HttpClientRequest.acceptJson,
current.apiKey ? HttpClientRequest.bearerToken(current.apiKey) : (request) => request,
HttpClientRequest.schemaBodyJson(ShowRequest)({ model: model.model }),
Effect.flatMap(http.execute),
Effect.flatMap(HttpClientResponse.schemaBodyJson(ShowResponse)),
Effect.timeout("1 second"),
)
shows.set(model.model, { digest: model.digest, info })
return { ...model, show: info }
}).pipe(Effect.catch(() => Effect.succeed(undefined))),
{ concurrency: 4 },
)
const filtered = models.filter(
(model): model is DiscoveredModel =>
model !== undefined && (model.show.capabilities?.includes("completion") ?? false),
)
discovery.set(current.tagsEndpoint, {
checked: Date.now(),
apiKey: current.apiKey,
models: filtered,
shows,
})
return { source: current, models: filtered }
}),
)
})
const refresh = Effect.fn("OllamaPlugin.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("OllamaPlugin.reload")(function* () {
const next = configured(yield* config.entries(), origin)
if (
next.baseURL === source.current.baseURL &&
next.apiKey === source.current.apiKey &&
next.tagsEndpoint === source.current.tagsEndpoint
)
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 OllamaPlugin = 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<Provider.Settings | undefined>((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/tags`
url.search = ""
url.hash = ""
const tagsEndpoint = url.toString()
url.pathname = `${prefix}/api/show`
return { baseURL, apiKey, tagsEndpoint, showEndpoint: url.toString() }
}
@@ -0,0 +1,342 @@
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 { OllamaPlugin, make } from "@opencode-ai/core/plugin/provider/ollama"
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 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<A>(
effect: Effect.Effect<A>,
predicate: (value: A) => boolean,
remaining = 3000,
): Effect.Effect<A, Error> {
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("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 },
}
: show({ family: "nomic-bert", capabilities: ["embedding"], context: 8192 }),
)
},
}),
}
}),
({ requests, server }) =>
Effect.gen(function* () {
const catalog = yield* Catalog.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(
catalog.model.get(providerID, Model.ID.make("gemma3:4b")),
(item) => item !== undefined,
)
expect(yield* catalog.provider.get(providerID)).toEqual({
id: providerID,
name: "Ollama",
activation: "enabled",
package: "@opencode-ai/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: 0 },
})
expect(yield* catalog.model.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 catalog = yield* Catalog.Service
const providerID = Provider.ID.make("ollama")
const modelID = Model.ID.make("qwen3:8b")
yield* addPlugin(server.url.origin, "5 millis")
yield* eventually(catalog.model.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(catalog.model.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* catalog.model.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 catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const providerID = Provider.ID.make("ollama")
yield* integrations.transform((draft) => {
draft.update(Integration.ID.make("ollama"), (integration) => {
integration.name = "Ollama"
})
draft.method.update({
integrationID: Integration.ID.make("ollama"),
method: { type: "env", names: ["OLLAMA_API_KEY"] },
})
})
yield* catalog.transform((draft) => {
draft.provider.update(providerID, (provider) => {
provider.name = "Ollama"
provider.package = "aisdk:@ai-sdk/openai-compatible"
provider.integrationID = Integration.ID.make("ollama")
})
draft.model.update(providerID, Model.ID.make("static-model"), () => {})
})
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("ollama"))).toBeUndefined()
expect((yield* catalog.provider.get(providerID))?.activation).toBe("enabled")
expect(yield* catalog.model.get(providerID, Model.ID.make("static-model"))).toBeUndefined()
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("ollama"))).toBeDefined()
expect((yield* catalog.provider.get(providerID))?.activation).toBe("auto")
expect((yield* catalog.provider.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 catalog = yield* Catalog.Service
const config = yield* Config.Test
const providerID = Provider.ID.make("ollama")
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, apiKey: "old" }), configuration({ apiKey: "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", method: "GET", path: "/proxy/api/tags" })
expect(requests).toContainEqual({ authorization: "Bearer secret", method: "POST", path: "/proxy/api/show" })
expect(yield* catalog.model.get(providerID, Model.ID.make("initial-model"))).toBeUndefined()
expect((yield* catalog.provider.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(catalog.provider.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" })
}),
({ 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<string, string | null>) {
return new Document({
type: "document",
info: decode({ providers: { ollama: { settings } } }),
})
}
@@ -152,6 +152,43 @@ provider and model configuration. An unknown variant fails model resolution inst
### Local models
#### Ollama
OpenCode automatically discovers language models from an Ollama server listening on its default address,
`http://127.0.0.1:11434`. Discovered models use the `ollama` provider ID and Ollama's model name:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"model": "ollama/gemma3:4b",
}
```
OpenCode refreshes the inventory in the background and reads context, vision, and tool-use capabilities from Ollama.
Embedding-only models are excluded because they cannot drive a session. Disable discovery with
`"plugins": ["-opencode.provider.ollama"]`.
For a different host or port, configure Ollama's OpenAI-compatible base URL. Models are still discovered through the
native Ollama API at the same path prefix:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"providers": {
"ollama": {
"settings": {
"baseURL": "http://127.0.0.1:5678/v1",
"apiKey": "{env:OLLAMA_API_KEY}",
},
},
},
}
```
Omit `apiKey` when the Ollama endpoint does not require bearer authentication.
#### LM Studio
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: