Repository navigation
feat(core): showcase shared discovery for Ollama, LM Studio and vLLM #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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"]), | ||
| 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, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When any model in the LM Studio response has an unknown maximum context length, the documented 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() | ||
| 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 } | ||
| } |
| 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), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When any installed Ollama model has 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)), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an existing Ollama model receives a new digest and its 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() | ||
| 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), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a configured vLLM endpoint returns a valid model card without 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() | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When LM Studio has a decision model installed, decoding the complete
/api/v1/modelsresponse fails before the latertype === "llm"filter can discard it, so no LLM inventory is refreshed. LM Studio documentsdecisionas 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 👍 / 👎.