Skip to content

Commit 2703567

Browse files
feat(core): showcase shared local provider discovery
Copy the six changed-file snapshots from anomalyco/opencode PR anomalyco#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: anomalyco#53438 Original author: Kit Langton Amp-Thread-ID: https://ampcode.com/threads/T-01a13d0d-fbe1-76fa-8fad-21c3940a2399 Co-authored-by: Amp <amp@ampcode.com>
1 parent d340117 commit 2703567

6 files changed

Lines changed: 1095 additions & 0 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { Effect, Schema } from "effect"
2+
import { createLocalProviderPlugin } from "./local.js"
3+
4+
const RemoteModel = Schema.Struct({
5+
type: Schema.Literals(["llm", "embedding"]),
6+
key: Schema.String,
7+
display_name: Schema.String,
8+
architecture: Schema.NullOr(Schema.String).pipe(Schema.optional),
9+
loaded_instances: Schema.Array(
10+
Schema.Struct({
11+
config: Schema.Struct({ context_length: Schema.Int }),
12+
}),
13+
),
14+
max_context_length: Schema.Int,
15+
capabilities: Schema.Struct({
16+
vision: Schema.Boolean,
17+
trained_for_tool_use: Schema.Boolean,
18+
}).pipe(Schema.optional),
19+
})
20+
21+
const Response = Schema.Struct({ models: Schema.Array(RemoteModel) })
22+
23+
export const make = createLocalProviderPlugin({
24+
id: "opencode.provider.lmstudio",
25+
providerID: "lmstudio",
26+
name: "LM Studio",
27+
origin: "http://127.0.0.1:1234",
28+
stripPathSuffix: /\/(?:api\/)?v1$/,
29+
discover: (client) =>
30+
Effect.gen(function* () {
31+
const response = yield* client.getJson("/api/v1/models", Response)
32+
return response.models
33+
.filter((model) => model.type === "llm" && model.key.length > 0)
34+
.toSorted((a, b) => a.key.localeCompare(b.key))
35+
}),
36+
model: (item) => ({
37+
id: item.key,
38+
name: item.display_name || item.key,
39+
family: item.architecture,
40+
tools: item.capabilities?.trained_for_tool_use,
41+
vision: item.capabilities?.vision,
42+
context:
43+
item.loaded_instances.length === 0
44+
? item.max_context_length
45+
: Math.min(...item.loaded_instances.map((instance) => instance.config.context_length)),
46+
}),
47+
})
48+
49+
export const LMStudioPlugin = make()
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import { define } from "@opencode/plugin/effect/plugin"
2+
import type { Entry } from "@opencode/schema/config"
3+
import { Duration, Effect, Schedule, type Schema, Semaphore, Stream } from "effect"
4+
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
5+
import { Config } from "../../config.js"
6+
import { Model } from "../../model.js"
7+
import type { PluginInternal } from "../internal.js"
8+
import { foldSettings } from "./configured.js"
9+
10+
export type LocalModel = {
11+
readonly id: string
12+
readonly name?: string
13+
readonly family?: string | null
14+
readonly tools?: boolean
15+
readonly vision?: boolean
16+
readonly context?: number | null
17+
}
18+
19+
export type LocalDiscoveryClient = ReturnType<typeof createClient>
20+
21+
export function createLocalProviderPlugin<Discovered>(input: {
22+
readonly id: string
23+
readonly providerID: string
24+
readonly name: string
25+
readonly origin: string
26+
readonly stripPathSuffix?: RegExp
27+
readonly discover: (client: LocalDiscoveryClient) => Effect.Effect<readonly Discovered[], unknown>
28+
readonly model: (item: NoInfer<Discovered>) => LocalModel
29+
}) {
30+
const discovery = new Map<string, { checked: number; apiKey?: string; models?: readonly Discovered[] }>()
31+
const discoveryLock = Semaphore.makeUnsafe(1)
32+
const suffix = input.stripPathSuffix ?? /\/v1$/
33+
34+
return function make(origin = input.origin, interval: Duration.Input = "30 seconds") {
35+
return define({
36+
id: input.id,
37+
effect: Effect.fn(function* (ctx) {
38+
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
39+
const config = yield* Config.Service
40+
const source = { current: configured(yield* config.entries(), input.providerID, origin, suffix) }
41+
const loaded = { models: [] as readonly Discovered[], hash: "[]" }
42+
43+
yield* ctx.integration.transform((integrations) => {
44+
if (loaded.models.length === 0) return
45+
integrations.remove(input.providerID)
46+
})
47+
48+
yield* ctx.provider.transform((providers) => {
49+
if (loaded.models.length === 0) return
50+
for (const model of providers.get(input.providerID)?.models.values() ?? []) {
51+
providers.models.remove(input.providerID, model.id)
52+
}
53+
providers.update(input.providerID, (provider) => {
54+
provider.name = input.name
55+
provider.activation = "enabled"
56+
provider.package = "@opencode/ai/providers/openai-compatible"
57+
provider.settings = {
58+
baseURL: source.current.baseURL,
59+
provider: input.providerID,
60+
apiKey: source.current.apiKey ?? "",
61+
}
62+
provider.integrationID = undefined
63+
})
64+
for (const item of loaded.models) {
65+
const spec = input.model(item)
66+
providers.models.update(input.providerID, spec.id, (model) => {
67+
model.modelID = Model.ID.make(spec.id)
68+
model.name = spec.name || spec.id
69+
model.family = spec.family ? Model.Family.make(spec.family) : undefined
70+
model.capabilities = {
71+
tools: spec.tools ?? false,
72+
input: ["text", ...(spec.vision ? ["image"] : [])],
73+
output: ["text"],
74+
}
75+
if (typeof spec.context === "number" && spec.context > 0) model.limit.context = spec.context
76+
})
77+
}
78+
})
79+
80+
const discover = Effect.fn("LocalProviderPlugin.discover")(function* () {
81+
const current = source.current
82+
if (!current.endpoint) return undefined
83+
return yield* discoveryLock.withPermit(
84+
Effect.gen(function* () {
85+
const cached = discovery.get(current.root)
86+
if (
87+
cached &&
88+
cached.apiKey === current.apiKey &&
89+
Date.now() - cached.checked < Duration.toMillis(interval)
90+
)
91+
return { source: current, models: cached.models }
92+
discovery.set(current.root, {
93+
checked: Date.now(),
94+
apiKey: current.apiKey,
95+
models: cached && cached.apiKey === current.apiKey ? cached.models : undefined,
96+
})
97+
const models = yield* input.discover(createClient(http, current.root, current.apiKey, current.endpoint))
98+
discovery.set(current.root, { checked: Date.now(), apiKey: current.apiKey, models })
99+
return { source: current, models }
100+
}),
101+
)
102+
})
103+
104+
const refresh = Effect.fn("LocalProviderPlugin.refresh")(function* () {
105+
const result = yield* discover()
106+
if (!result?.models || result.source !== source.current) return
107+
const hash = JSON.stringify(result.models)
108+
if (hash === loaded.hash) return
109+
loaded.models = result.models
110+
loaded.hash = hash
111+
yield* ctx.integration.reload()
112+
yield* ctx.provider.reload()
113+
})
114+
115+
// Keep the last successful inventory through transient outages instead of flickering model availability.
116+
yield* refresh().pipe(Effect.ignore, Effect.repeat(Schedule.spaced(interval)), Effect.forkScoped)
117+
const reload = Effect.fn("LocalProviderPlugin.reload")(function* () {
118+
const next = configured(yield* config.entries(), input.providerID, origin, suffix)
119+
if (
120+
next.baseURL === source.current.baseURL &&
121+
next.apiKey === source.current.apiKey &&
122+
next.root === source.current.root
123+
)
124+
return
125+
source.current = next
126+
loaded.models = []
127+
loaded.hash = "[]"
128+
yield* ctx.integration.reload()
129+
yield* ctx.provider.reload()
130+
yield* refresh().pipe(Effect.ignore)
131+
})
132+
yield* ctx.event.subscribe().pipe(
133+
Stream.filter((event) => event.type === "config.updated"),
134+
Stream.runForEach(reload),
135+
Effect.forkScoped({ startImmediately: true }),
136+
)
137+
}),
138+
} satisfies PluginInternal.InternalPlugin)
139+
}
140+
}
141+
142+
function createClient(
143+
http: HttpClient.HttpClient,
144+
root: string,
145+
apiKey: string | undefined,
146+
endpoint: (path: string) => string,
147+
) {
148+
const auth = (request: HttpClientRequest.HttpClientRequest) =>
149+
apiKey
150+
? request.pipe(HttpClientRequest.acceptJson, HttpClientRequest.bearerToken(apiKey))
151+
: request.pipe(HttpClientRequest.acceptJson)
152+
return {
153+
root,
154+
apiKey,
155+
get: (path: string) => http.execute(auth(HttpClientRequest.get(endpoint(path)))).pipe(Effect.timeout("1 second")),
156+
getJson: <A, I>(path: string, schema: Schema.Codec<A, I>) =>
157+
http
158+
.execute(auth(HttpClientRequest.get(endpoint(path))))
159+
.pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)), Effect.timeout("1 second")),
160+
postJson: <B, BI, A, AI>(
161+
path: string,
162+
bodySchema: Schema.Codec<B, BI>,
163+
body: B,
164+
responseSchema: Schema.Codec<A, AI>,
165+
) =>
166+
auth(HttpClientRequest.post(endpoint(path))).pipe(
167+
HttpClientRequest.schemaBodyJson(bodySchema)(body),
168+
Effect.flatMap(http.execute),
169+
Effect.flatMap(HttpClientResponse.schemaBodyJson(responseSchema)),
170+
Effect.timeout("1 second"),
171+
),
172+
}
173+
}
174+
175+
function configured(entries: readonly Entry[], providerID: string, origin: string, suffix: RegExp) {
176+
const settings = foldSettings(entries, providerID, undefined)
177+
const baseURL = (
178+
typeof settings?.baseURL === "string" ? settings.baseURL : `${origin.replace(/\/+$/, "")}/v1`
179+
).replace(/\/+$/, "")
180+
const apiKey = typeof settings?.apiKey === "string" ? settings.apiKey : undefined
181+
if (!URL.canParse(baseURL)) return { baseURL, apiKey }
182+
const url = new URL(baseURL)
183+
if (url.protocol !== "http:" && url.protocol !== "https:") return { baseURL, apiKey }
184+
const prefix = url.pathname.replace(/\/+$/, "").replace(suffix, "")
185+
const endpoint = (path: string) => {
186+
const next = new URL(url)
187+
next.pathname = `${prefix}${path}`
188+
next.search = ""
189+
next.hash = ""
190+
return next.toString()
191+
}
192+
return { baseURL, apiKey, root: endpoint(""), endpoint }
193+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { Effect, Schema } from "effect"
2+
import { createLocalProviderPlugin } from "./local.js"
3+
4+
const Details = Schema.Struct({
5+
parent_model: Schema.String.pipe(Schema.optional),
6+
format: Schema.String,
7+
family: Schema.String,
8+
families: Schema.Array(Schema.String).pipe(Schema.optional),
9+
parameter_size: Schema.String,
10+
quantization_level: Schema.String,
11+
})
12+
13+
const RemoteModel = Schema.Struct({
14+
name: Schema.String,
15+
model: Schema.String,
16+
remote_model: Schema.String.pipe(Schema.optional),
17+
remote_host: Schema.String.pipe(Schema.optional),
18+
modified_at: Schema.String,
19+
size: Schema.Int,
20+
digest: Schema.String,
21+
details: Details,
22+
})
23+
24+
const TagsResponse = Schema.Struct({ models: Schema.Array(RemoteModel) })
25+
const ShowRequest = Schema.Struct({ model: Schema.String })
26+
const ShowResponse = Schema.Struct({
27+
parameters: Schema.String.pipe(Schema.optional),
28+
license: Schema.String.pipe(Schema.optional),
29+
modified_at: Schema.String.pipe(Schema.optional),
30+
details: Details.pipe(Schema.optional),
31+
template: Schema.String.pipe(Schema.optional),
32+
capabilities: Schema.Array(Schema.String).pipe(Schema.optional),
33+
model_info: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
34+
})
35+
36+
type DiscoveredModel = typeof RemoteModel.Type & { show: typeof ShowResponse.Type }
37+
type ShowCache = {
38+
apiKey?: string
39+
entries: Map<string, { digest: string; info: typeof ShowResponse.Type }>
40+
}
41+
42+
const shows = new Map<string, ShowCache>()
43+
44+
export const make = createLocalProviderPlugin({
45+
id: "opencode.provider.ollama",
46+
providerID: "ollama",
47+
name: "Ollama",
48+
origin: "http://127.0.0.1:11434",
49+
stripPathSuffix: /\/(?:v1|api)$/,
50+
discover: (client) =>
51+
Effect.gen(function* () {
52+
const cached = shows.get(client.root)
53+
const previous = cached && cached.apiKey === client.apiKey ? cached.entries : new Map()
54+
shows.set(client.root, { apiKey: client.apiKey, entries: previous })
55+
const response = yield* client.getJson("/api/tags", TagsResponse)
56+
const summaries = response.models
57+
.filter((model) => model.model.length > 0)
58+
.toSorted((a, b) => a.model.localeCompare(b.model))
59+
const entries = new Map<string, { digest: string; info: typeof ShowResponse.Type }>()
60+
const models = yield* Effect.forEach(
61+
summaries,
62+
(model) =>
63+
Effect.gen(function* () {
64+
const saved = previous.get(model.model)
65+
const info =
66+
saved?.digest === model.digest
67+
? saved.info
68+
: yield* client.postJson("/api/show", ShowRequest, { model: model.model }, ShowResponse)
69+
entries.set(model.model, { digest: model.digest, info })
70+
return { ...model, show: info }
71+
}).pipe(Effect.orElseSucceed(() => undefined)),
72+
{ concurrency: 4 },
73+
)
74+
shows.set(client.root, { apiKey: client.apiKey, entries })
75+
return models.filter(
76+
(model): model is DiscoveredModel =>
77+
model !== undefined && (model.show.capabilities?.includes("completion") ?? false),
78+
)
79+
}),
80+
model: (item) => ({
81+
id: item.model,
82+
name: item.name || item.model,
83+
family: item.show.details?.family || item.details.family,
84+
tools: item.show.capabilities?.includes("tools"),
85+
vision: item.show.capabilities?.includes("vision"),
86+
context: Object.entries(item.show.model_info ?? {}).flatMap(([key, value]) =>
87+
key.endsWith(".context_length") && typeof value === "number" && value > 0 ? [value] : [],
88+
)[0],
89+
}),
90+
})
91+
92+
export const OllamaPlugin = make()
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { Effect, Schema } from "effect"
2+
import { createLocalProviderPlugin } from "./local.js"
3+
4+
const providerID = "vllm"
5+
6+
const RemoteModel = Schema.Struct({
7+
id: Schema.String,
8+
owned_by: Schema.String,
9+
max_model_len: Schema.NullOr(Schema.Int),
10+
})
11+
12+
const Response = Schema.Struct({ data: Schema.Array(RemoteModel) })
13+
14+
export const make = createLocalProviderPlugin({
15+
id: "opencode.provider.vllm",
16+
providerID,
17+
name: "vLLM",
18+
origin: "http://127.0.0.1:8000",
19+
discover: (client) =>
20+
Effect.gen(function* () {
21+
yield* client.get("/health")
22+
const response = yield* client.getJson("/v1/models", Response)
23+
return response.data
24+
.filter((model) => model.owned_by === providerID && model.id.length > 0)
25+
.toSorted((a, b) => a.id.localeCompare(b.id))
26+
}),
27+
// Tool calling depends on vLLM server flags and parsers that model discovery does not report.
28+
model: (item) => ({
29+
id: item.id,
30+
context: item.max_model_len,
31+
}),
32+
})
33+
34+
export const VLLMPlugin = make()

0 commit comments

Comments
 (0)