mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-22 00:03:40 +00:00
feat(core): discover vLLM models (#43022)
This commit is contained in:
parent
fa055143ea
commit
cc53db4406
4 changed files with 490 additions and 0 deletions
|
|
@ -29,6 +29,7 @@ import { SapAICorePlugin } from "./provider/sap-ai-core.js"
|
|||
import { TogetherAIPlugin } from "./provider/togetherai.js"
|
||||
import { VercelPlugin } from "./provider/vercel.js"
|
||||
import { VenicePlugin } from "./provider/venice.js"
|
||||
import { VLLMPlugin } from "./provider/vllm.js"
|
||||
import { XAIPlugin } from "./provider/xai.js"
|
||||
import { ZenmuxPlugin } from "./provider/zenmux.js"
|
||||
import type { PluginInternal } from "./internal.js"
|
||||
|
|
@ -64,6 +65,7 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
|
|||
TogetherAIPlugin,
|
||||
VercelPlugin,
|
||||
VenicePlugin,
|
||||
VLLMPlugin,
|
||||
XAIPlugin,
|
||||
ZenmuxPlugin,
|
||||
DynamicProviderPlugin,
|
||||
|
|
|
|||
162
packages/core/src/plugin/provider/vllm.ts
Normal file
162
packages/core/src/plugin/provider/vllm.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
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 = "vllm"
|
||||
|
||||
const RemoteModel = Schema.Struct({
|
||||
id: Schema.String,
|
||||
owned_by: Schema.String,
|
||||
max_model_len: Schema.NullOr(Schema.Int),
|
||||
})
|
||||
|
||||
const Response = Schema.Struct({ data: Schema.Array(RemoteModel) })
|
||||
const discovery = new Map<string, { checked: number; apiKey?: string; models?: (typeof RemoteModel.Type)[] }>()
|
||||
const discoveryLock = Semaphore.makeUnsafe(1)
|
||||
|
||||
export function make(origin = "http://127.0.0.1:8000", interval: Duration.Input = "30 seconds") {
|
||||
return define({
|
||||
id: "opencode.provider.vllm",
|
||||
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 = "vLLM"
|
||||
provider.package = "@opencode-ai/ai/providers/openai-compatible"
|
||||
provider.settings = {
|
||||
baseURL: source.current.baseURL,
|
||||
provider: providerID,
|
||||
apiKey: source.current.apiKey ?? "",
|
||||
}
|
||||
provider.integrationID = undefined
|
||||
provider.activation = "enabled"
|
||||
})
|
||||
for (const item of loaded.models) {
|
||||
catalog.model.update(providerID, item.id, (model) => {
|
||||
model.modelID = Model.ID.make(item.id)
|
||||
model.name = item.id
|
||||
// Tool calling depends on vLLM server flags and parsers that model discovery does not report.
|
||||
model.capabilities = { tools: false, input: ["text"], output: ["text"] }
|
||||
model.limit = { context: item.max_model_len ?? 0, output: 0 }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const discover = Effect.fn("VLLMPlugin.discover")(function* () {
|
||||
const current = source.current
|
||||
if (!current.healthEndpoint || !current.modelsEndpoint) return undefined
|
||||
return yield* discoveryLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const endpoint = `${current.healthEndpoint}\n${current.modelsEndpoint}`
|
||||
const cached = discovery.get(endpoint)
|
||||
if (cached && cached.apiKey === current.apiKey && Date.now() - cached.checked < Duration.toMillis(interval))
|
||||
return { source: current, models: cached.models }
|
||||
discovery.set(endpoint, {
|
||||
checked: Date.now(),
|
||||
apiKey: current.apiKey,
|
||||
models: cached && cached.apiKey === current.apiKey ? cached.models : undefined,
|
||||
})
|
||||
const request = (endpoint: string) =>
|
||||
current.apiKey
|
||||
? HttpClientRequest.get(endpoint).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(current.apiKey),
|
||||
)
|
||||
: HttpClientRequest.get(endpoint).pipe(HttpClientRequest.acceptJson)
|
||||
yield* http.execute(request(current.healthEndpoint)).pipe(Effect.timeout("1 second"))
|
||||
const response = yield* http
|
||||
.execute(request(current.modelsEndpoint))
|
||||
.pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(Response)), Effect.timeout("1 second"))
|
||||
const models = response.data
|
||||
.filter((model) => model.owned_by === providerID && model.id.length > 0)
|
||||
.toSorted((a, b) => a.id.localeCompare(b.id))
|
||||
discovery.set(endpoint, { checked: Date.now(), apiKey: current.apiKey, models })
|
||||
return { source: current, models }
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const refresh = Effect.fn("VLLMPlugin.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("VLLMPlugin.reload")(function* () {
|
||||
const next = configured(yield* config.entries(), origin)
|
||||
if (
|
||||
next.baseURL === source.current.baseURL &&
|
||||
next.apiKey === source.current.apiKey &&
|
||||
next.healthEndpoint === source.current.healthEndpoint &&
|
||||
next.modelsEndpoint === source.current.modelsEndpoint
|
||||
)
|
||||
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 VLLMPlugin = 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 models = new URL(baseURL)
|
||||
if (models.protocol !== "http:" && models.protocol !== "https:") return { baseURL, apiKey }
|
||||
models.pathname = `${models.pathname.replace(/\/+$/, "")}/models`
|
||||
models.search = ""
|
||||
models.hash = ""
|
||||
const health = new URL(baseURL)
|
||||
const path = health.pathname.replace(/\/+$/, "")
|
||||
const prefix = path.endsWith("/v1") ? path.slice(0, -3) : path
|
||||
health.pathname = `${prefix}/health`
|
||||
health.search = ""
|
||||
health.hash = ""
|
||||
return { baseURL, apiKey, healthEndpoint: health.toString(), modelsEndpoint: models.toString() }
|
||||
}
|
||||
289
packages/core/test/plugin/provider-vllm.test.ts
Normal file
289
packages/core/test/plugin/provider-vllm.test.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
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 { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { make, VLLMPlugin } from "@opencode-ai/core/plugin/provider/vllm"
|
||||
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<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)
|
||||
})
|
||||
}
|
||||
|
||||
const remoteModel = (id: string, max_model_len = 32_768, owned_by = "vllm") => ({
|
||||
id,
|
||||
object: "model",
|
||||
created: 1,
|
||||
owned_by,
|
||||
root: id,
|
||||
parent: null,
|
||||
max_model_len,
|
||||
permission: [],
|
||||
})
|
||||
|
||||
describe("VLLMPlugin", () => {
|
||||
it.live("waits for readiness and discovers official vLLM model metadata", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const state = { healthy: false, models: 0 }
|
||||
return {
|
||||
state,
|
||||
server: Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) => {
|
||||
const path = new URL(request.url).pathname
|
||||
if (path === "/health") return new Response(null, { status: state.healthy ? 200 : 503 })
|
||||
state.models++
|
||||
return Response.json({
|
||||
object: "list",
|
||||
data: [remoteModel("Qwen/Qwen3-Coder", 65_536), remoteModel("foreign-model", 4096, "other")],
|
||||
})
|
||||
},
|
||||
}),
|
||||
}
|
||||
}),
|
||||
({ state, server }) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = Provider.ID.make("vllm")
|
||||
expect(VLLMPlugin.id).toBe("opencode.provider.vllm")
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.vllm")
|
||||
yield* addPlugin(server.url.origin, "5 millis")
|
||||
yield* Effect.promise(() => Bun.sleep(20))
|
||||
expect(yield* catalog.provider.get(providerID)).toBeUndefined()
|
||||
expect(state.models).toBe(0)
|
||||
|
||||
state.healthy = true
|
||||
const model = yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("Qwen/Qwen3-Coder")),
|
||||
(item) => item !== undefined,
|
||||
)
|
||||
expect(yield* catalog.provider.get(providerID)).toEqual({
|
||||
id: providerID,
|
||||
name: "vLLM",
|
||||
package: "@opencode-ai/ai/providers/openai-compatible",
|
||||
settings: { baseURL: `${server.url.origin}/v1`, provider: "vllm", apiKey: "" },
|
||||
activation: "enabled",
|
||||
})
|
||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID)
|
||||
expect(model).toMatchObject({
|
||||
modelID: "Qwen/Qwen3-Coder",
|
||||
name: "Qwen/Qwen3-Coder",
|
||||
capabilities: { tools: false, input: ["text"], output: ["text"] },
|
||||
limit: { context: 65_536, output: 0 },
|
||||
})
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("foreign-model"))).toBeUndefined()
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("refreshes inventory while retaining the last success through transient failures", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const state = { failing: false, models: [remoteModel("first-model")] }
|
||||
return {
|
||||
state,
|
||||
server: Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) => {
|
||||
if (state.failing) return new Response(null, { status: 503 })
|
||||
if (new URL(request.url).pathname === "/health") return new Response()
|
||||
return Response.json({ object: "list", data: state.models })
|
||||
},
|
||||
}),
|
||||
}
|
||||
}),
|
||||
({ state, server }) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = Provider.ID.make("vllm")
|
||||
yield* addPlugin(server.url.origin, "5 millis")
|
||||
yield* eventually(catalog.model.get(providerID, Model.ID.make("first-model")), (model) => model !== undefined)
|
||||
|
||||
state.failing = true
|
||||
state.models = [remoteModel("second-model")]
|
||||
yield* Effect.promise(() => Bun.sleep(30))
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("first-model"))).toBeDefined()
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("second-model"))).toBeUndefined()
|
||||
|
||||
state.failing = false
|
||||
yield* eventually(
|
||||
catalog.model.get(providerID, Model.ID.make("second-model")),
|
||||
(model) => model !== undefined,
|
||||
)
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("first-model"))).toBeUndefined()
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("replaces and restores same-ID Models.dev entries after an empty success", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const models = [remoteModel("discovered-model")]
|
||||
return {
|
||||
models,
|
||||
server: Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) =>
|
||||
new URL(request.url).pathname === "/health"
|
||||
? new Response()
|
||||
: Response.json({ object: "list", data: models }),
|
||||
}),
|
||||
}
|
||||
}),
|
||||
({ models, server }) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const providerID = Provider.ID.make("vllm")
|
||||
yield* integrations.transform((draft) => {
|
||||
draft.update(Integration.ID.make("vllm"), (integration) => {
|
||||
integration.name = "vLLM"
|
||||
})
|
||||
draft.method.update({
|
||||
integrationID: Integration.ID.make("vllm"),
|
||||
method: { type: "env", names: ["VLLM_API_KEY"] },
|
||||
})
|
||||
})
|
||||
yield* catalog.transform((draft) => {
|
||||
draft.provider.update(providerID, (provider) => {
|
||||
provider.name = "vLLM"
|
||||
provider.package = "aisdk:@ai-sdk/openai-compatible"
|
||||
provider.integrationID = Integration.ID.make("vllm")
|
||||
provider.activation = "auto"
|
||||
})
|
||||
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("vllm"))).toBeUndefined()
|
||||
expect((yield* catalog.provider.get(providerID))?.integrationID).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("vllm"))).toBeDefined()
|
||||
expect((yield* catalog.provider.get(providerID))?.integrationID).toBe(Integration.ID.make("vllm"))
|
||||
expect((yield* catalog.provider.get(providerID))?.activation).toBe("auto")
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"reloads layered custom endpoint and bearer authentication settings",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const requests: Array<{ authorization: string | null; path: string }> = []
|
||||
return {
|
||||
requests,
|
||||
initial: Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) =>
|
||||
new URL(request.url).pathname === "/health"
|
||||
? new Response()
|
||||
: Response.json({ object: "list", data: [remoteModel("initial-model")] }),
|
||||
}),
|
||||
configured: Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) => {
|
||||
requests.push({
|
||||
authorization: request.headers.get("authorization"),
|
||||
path: new URL(request.url).pathname,
|
||||
})
|
||||
if (new URL(request.url).pathname === "/proxy/health") return new Response()
|
||||
return Response.json({ object: "list", data: [remoteModel("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("vllm")
|
||||
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 }), 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", path: "/proxy/health" })
|
||||
expect(requests).toContainEqual({ authorization: "Bearer secret", path: "/proxy/v1/models" })
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("initial-model"))).toBeUndefined()
|
||||
expect((yield* catalog.provider.get(providerID))?.settings).toEqual({
|
||||
baseURL,
|
||||
provider: "vllm",
|
||||
apiKey: "secret",
|
||||
})
|
||||
|
||||
requests.splice(0)
|
||||
yield* config.setEntries([configuration({ baseURL }), configuration({ apiKey: "next-secret" })])
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
yield* eventually(
|
||||
catalog.provider.get(providerID),
|
||||
(provider) => provider?.settings?.apiKey === "next-secret",
|
||||
)
|
||||
expect(requests).toContainEqual({ authorization: "Bearer next-secret", path: "/proxy/health" })
|
||||
expect(requests).toContainEqual({ authorization: "Bearer next-secret", path: "/proxy/v1/models" })
|
||||
}),
|
||||
({ initial, configured }) => Effect.promise(() => Promise.all([initial.stop(true), configured.stop(true)])),
|
||||
),
|
||||
10_000,
|
||||
)
|
||||
})
|
||||
|
||||
function configuration(settings: { baseURL?: string; apiKey?: string }) {
|
||||
return new Document({
|
||||
type: "document",
|
||||
info: decode({ providers: { vllm: { settings } } }),
|
||||
})
|
||||
}
|
||||
|
|
@ -221,6 +221,43 @@ For a different host or port, configure the OpenAI-compatible base URL. Models a
|
|||
|
||||
Omit `apiKey` when LM Studio authentication is disabled.
|
||||
|
||||
#### vLLM
|
||||
|
||||
OpenCode automatically discovers models from a vLLM server listening on its default address, `http://127.0.0.1:8000`.
|
||||
Discovered models use the `vllm` provider ID and the model ID reported by vLLM:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"model": "vllm/Qwen/Qwen3-Coder-30B-A3B-Instruct",
|
||||
}
|
||||
```
|
||||
|
||||
OpenCode checks vLLM's `/health` endpoint and refreshes `/v1/models` in the background. It uses the reported
|
||||
`max_model_len` as the context limit and only includes model cards owned by `vllm`. Discovered vLLM models advertise
|
||||
text input and output, but not vision or tools. Tool calling is conservative because vLLM enables it with server-level
|
||||
flags such as `--enable-auto-tool-choice` and `--tool-call-parser`, which model discovery does not report. Disable
|
||||
discovery with `"plugins": ["-opencode.provider.vllm"]`.
|
||||
|
||||
For a different endpoint or an authenticated server, configure its OpenAI-compatible base URL:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"providers": {
|
||||
"vllm": {
|
||||
"settings": {
|
||||
"baseURL": "http://127.0.0.1:9000/v1",
|
||||
"apiKey": "{env:VLLM_API_KEY}",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Omit `apiKey` when authentication is disabled. Path-prefixed proxy URLs are supported; for example,
|
||||
`https://example.com/vllm/v1` checks `/vllm/health` and discovers `/vllm/v1/models`.
|
||||
|
||||
For an OpenAI-compatible server, define a provider package, endpoint, and at least one model:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue