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
2 changes: 2 additions & 0 deletions packages/core/src/plugin/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { GithubCopilotPlugin } from "./provider/github-copilot.js"
import { GitLabPlugin } from "./provider/gitlab.js"
import { GoogleVertexPlugin } from "./provider/google-vertex.js"
import { KiloPlugin } from "./provider/kilo.js"
import { LiteLLMPlugin } from "./provider/litellm.js"
import { LLMGatewayPlugin } from "./provider/llmgateway.js"
import { LMStudioPlugin } from "./provider/lmstudio.js"
import { ModalPlugin } from "./provider/modal.js"
Expand Down Expand Up @@ -46,6 +47,7 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
GitLabPlugin,
GoogleVertexPlugin,
KiloPlugin,
LiteLLMPlugin,
LLMGatewayPlugin,
LMStudioPlugin,
ModalPlugin,
Expand Down
33 changes: 33 additions & 0 deletions packages/core/src/plugin/provider/litellm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Effect, Schema } from "effect"
import { createLocalProviderPlugin } from "./local.js"

const RemoteModel = Schema.Struct({
id: Schema.String,
// LiteLLM reports these only when it knows the model, and older proxies omit them.
mode: Schema.NullOr(Schema.String).pipe(Schema.optional),
max_input_tokens: Schema.NullOr(Schema.Finite).pipe(Schema.optional),
})

const Response = Schema.Struct({ data: Schema.Array(RemoteModel) })

// Only LiteLLM gives this body on this path. Other OpenAI-compatible servers on port 4000 are not shown as LiteLLM.
const Liveliness = Schema.Literal("I'm alive!")

export const make = createLocalProviderPlugin({
id: "opencode.provider.litellm",
providerID: "litellm",
name: "LiteLLM",
origin: "http://127.0.0.1:4000",
discover: (client) =>
Effect.gen(function* () {
yield* client.getJson("/health/liveliness", Liveliness)
const response = yield* client.getJson("/v1/models", Response)
return response.data

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This now checks 127.0.0.1:4000 every 30s for every user, and port 4000 is a common dev-server port (Phoenix, Jekyll, …). The vLLM plugin checks /health and only accepts models where owned_by === "vllm", so other servers aren't mistaken for it. Here, any OpenAI-compatible server on port 4000 would appear as "LiteLLM". Could you first check something only LiteLLM serves, such as the unauthenticated /health/liveliness endpoint, before reading /v1/models?

.filter((model) => model.id.length > 0 && (model.mode ?? "chat") === "chat")
.toSorted((a, b) => a.id.localeCompare(b.id))
}),
// Tool and image support depend on the model behind each alias, which /v1/models does not report.
model: (item) => ({ id: item.id, context: item.max_input_tokens }),
})

export const LiteLLMPlugin = make()
260 changes: 260 additions & 0 deletions packages/core/test/plugin/provider-litellm.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
import { Bus } from "@opencode/core/bus"
import { Config } from "@opencode/core/config"
import { Model } from "@opencode/core/model"
import { Plugin } from "@opencode/core/plugin"
import { PluginHost } from "@opencode/core/plugin/host"
import { ProviderPlugins } from "@opencode/core/plugin/provider"
import { LiteLLMPlugin, make } from "@opencode/core/plugin/provider/litellm"
import { Provider } from "@opencode/core/provider"
import { Document, Event, Info } from "@opencode/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, extra: Record<string, unknown> = {}) => ({
id,
object: "model",
created: 1677610602,
owned_by: "openai",
...extra,
})

// Answers the LiteLLM liveliness check, under any path prefix, and passes other requests to the handler.
const proxy = (handler: (request: Request) => Response) => (request: Request) =>
new URL(request.url).pathname.endsWith("/health/liveliness") ? Response.json("I'm alive!") : handler(request)

describe("LiteLLMPlugin", () => {
it.live("discovers chat models from an unauthenticated proxy", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const requests: Array<{ authorization: string | null; path: string }> = []
return {
requests,
server: Bun.serve({
port: 0,
fetch: (request) => {
requests.push({
authorization: request.headers.get("authorization"),
path: new URL(request.url).pathname,
})
return proxy(() =>
Response.json({
object: "list",
data: [
remoteModel("gpt-6-luna", { mode: "chat", max_input_tokens: 200_000, max_output_tokens: 64_000 }),
remoteModel("old-proxy-alias"),
remoteModel("null-metadata", { mode: null, max_input_tokens: null }),
remoteModel("text-embedding-3-large", { mode: "embedding", max_input_tokens: 8191 }),
remoteModel("image-model", { mode: "image_generation" }),
remoteModel(""),
],
}),
)(request)
},
}),
}
}),
({ requests, server }) =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const modelState = yield* Model.Service
const providerID = Provider.ID.make("litellm")
expect(LiteLLMPlugin.id).toBe("opencode.provider.litellm")
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.litellm")
yield* addPlugin(server.url.origin, "5 millis")
const model = yield* eventually(
modelState.get(providerID, Model.ID.make("gpt-6-luna")),
(item) => item !== undefined,
)
expect(yield* providers.get(providerID)).toEqual({
id: providerID,
name: "LiteLLM",
package: "@opencode/ai/providers/openai-compatible",
settings: { baseURL: `${server.url.origin}/v1`, provider: "litellm", apiKey: "" },
activation: "enabled",
})
expect((yield* providers.available()).map((provider) => provider.id)).toContain(providerID)
expect(model).toMatchObject({
modelID: "gpt-6-luna",
name: "gpt-6-luna",
capabilities: { tools: false, input: ["text"], output: ["text"] },
limit: { context: 200_000, output: 32_000 },
})
expect(yield* modelState.get(providerID, Model.ID.make("old-proxy-alias"))).toMatchObject({
limit: { context: 200_000, output: 32_000 },
})
expect(yield* modelState.get(providerID, Model.ID.make("null-metadata"))).toBeDefined()
expect(yield* modelState.get(providerID, Model.ID.make("text-embedding-3-large"))).toBeUndefined()
expect(yield* modelState.get(providerID, Model.ID.make("image-model"))).toBeUndefined()
expect(requests).toContainEqual({ authorization: null, path: "/health/liveliness" })
expect(requests).toContainEqual({ authorization: null, path: "/v1/models" })
expect(requests.every((request) => request.authorization === null)).toBe(true)
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)

it.live("keeps the last inventory through a transient failure", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const state = { failing: false, models: [remoteModel("first-model")] }
return {
state,
server: Bun.serve({
port: 0,
fetch: proxy(() =>
state.failing
? new Response(null, { status: 502 })
: Response.json({ object: "list", data: state.models }),
),
}),
}
}),
({ state, server }) =>
Effect.gen(function* () {
const modelState = yield* Model.Service
const providerID = Provider.ID.make("litellm")
yield* addPlugin(server.url.origin, "5 millis")
yield* eventually(modelState.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* modelState.get(providerID, Model.ID.make("first-model"))).toBeDefined()
expect(yield* modelState.get(providerID, Model.ID.make("second-model"))).toBeUndefined()

state.failing = false
yield* eventually(modelState.get(providerID, Model.ID.make("second-model")), (model) => model !== undefined)
expect(yield* modelState.get(providerID, Model.ID.make("first-model"))).toBeUndefined()
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)

it.live("ignores an OpenAI-compatible server that is not LiteLLM", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const requests: string[] = []
return {
requests,
server: Bun.serve({
port: 0,
// A dev server that gives 200 for every path, and another OpenAI-compatible server.
fetch: (request) => {
const path = new URL(request.url).pathname
requests.push(path)
return path === "/v1/models"
? Response.json({ object: "list", data: [remoteModel("other-model")] })
: new Response("<html></html>", { headers: { "content-type": "text/html" } })
},
}),
}
}),
({ requests, server }) =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const modelState = yield* Model.Service
const providerID = Provider.ID.make("litellm")
yield* addPlugin(server.url.origin, "5 millis")
yield* eventually(
Effect.sync(() => requests.length),
(count) => count >= 3,
)
expect(requests).not.toContain("/v1/models")
expect(yield* modelState.get(providerID, Model.ID.make("other-model"))).toBeUndefined()
expect((yield* providers.available()).map((provider) => provider.id)).not.toContain(providerID)
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)

it.live(
"reloads a configured endpoint and sends the key as a bearer token",
() =>
Effect.acquireUseRelease(
Effect.sync(() => {
const requests: Array<{ authorization: string | null; path: string }> = []
return {
requests,
initial: Bun.serve({
port: 0,
fetch: proxy(() => 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,
})
return proxy(() => Response.json({ object: "list", data: [remoteModel("configured-model")] }))(request)
},
}),
}
}),
({ requests, initial, configured }) =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const providers = yield* Provider.Service
const modelState = yield* Model.Service
const config = yield* Config.Test
const providerID = Provider.ID.make("litellm")
yield* addPlugin(initial.url.origin)
yield* eventually(
modelState.get(providerID, Model.ID.make("initial-model")),
(model) => model !== undefined,
)

const baseURL = `${configured.url.origin}/litellm/v1`
yield* config.setEntries([configuration({ baseURL }), configuration({ apiKey: "sk-secret" })])
yield* bus.publish(Event.Updated, {})
yield* eventually(
modelState.get(providerID, Model.ID.make("configured-model")),
(model) => model !== undefined,
)

expect(requests).toContainEqual({ authorization: "Bearer sk-secret", path: "/litellm/health/liveliness" })
expect(requests).toContainEqual({ authorization: "Bearer sk-secret", path: "/litellm/v1/models" })
expect(yield* modelState.get(providerID, Model.ID.make("initial-model"))).toBeUndefined()
expect((yield* providers.get(providerID))?.settings).toEqual({
baseURL,
provider: "litellm",
apiKey: "sk-secret",
})
}),
({ 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: { litellm: { settings } } }),
})
}
63 changes: 61 additions & 2 deletions services/www/src/docs/content/models.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -228,13 +228,14 @@ the model.

## Local

OpenCode automatically discovers models from Ollama, LM Studio, and vLLM at their default local addresses. You can also
configure any OpenAI-compatible server manually.
OpenCode automatically discovers models from Ollama, LM Studio, vLLM, and LiteLLM at their default local addresses. You
can also configure any OpenAI-compatible server manually.

```text
ollama/gemma3:4b
lmstudio/google/gemma-4-26b-a4b
vllm/Qwen/Qwen3-Coder-30B-A3B-Instruct
litellm/gpt-6-luna
```

### Ollama
Expand Down Expand Up @@ -340,6 +341,64 @@ For another endpoint or an authenticated server, set its OpenAI-compatible URL:
- Path-prefixed proxies are supported. For example, `https://example.com/vllm/v1` checks `/vllm/health` and discovers
`/vllm/v1/models`.

### LiteLLM

With a [LiteLLM proxy](https://docs.litellm.ai/docs/proxy/quick_start) listening at `http://127.0.0.1:4000`, select a
model by the name in your proxy configuration with the `litellm` provider ID:

```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"model": "litellm/gpt-6-luna",
}
```

OpenCode first checks `/health/liveliness`, so another OpenAI-compatible server on port 4000 is not shown as LiteLLM.
Then it refreshes `/v1/models` in the background. It uses `max_input_tokens` as the context limit when the proxy
reports it, and it excludes models whose `mode` is not `chat`.

Discovered models advertise text input and output, but not vision or tools. `/v1/models` does not report what the model

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Most LiteLLM proxies sit in front of models that support tools and images (Claude, GPT, …). With input: ["text"], pasted images are replaced with a text placeholder (see #47480). Overriding this per model in providers.litellm.models.<id>.capabilities works: I tested it, and the override is kept after discovery. A short example of that override here would save users from getting stuck.

behind each name supports. To use tools and images with a model, set its capabilities. Discovery keeps this setting:

```jsonc title="opencode.jsonc"
{
"providers": {
"litellm": {
"models": {
"gpt-6-luna": {
"capabilities": {
"tools": true,
"input": ["text", "image"],
"output": ["text"],
},
},
},
},
},
}
```

For another host or a proxy with [virtual keys](https://docs.litellm.ai/docs/proxy/virtual_keys), set its URL and key:

```jsonc title="opencode.jsonc"
{
"providers": {
"litellm": {
"settings": {
"baseURL": "https://litellm.example.com/v1",
"apiKey": "{env:LITELLM_API_KEY}",
},
},
},
}
```

- Omit `apiKey` when the proxy does not require authentication. A proxy that requires a key is not discovered without
one.
- Disable discovery with `"plugins": ["-opencode.provider.litellm"]`.
- Path-prefixed proxies are supported. For example, `https://example.com/litellm/v1` checks
`/litellm/health/liveliness` and discovers `/litellm/v1/models`.

### Compatible

For another OpenAI-compatible server, define its provider package, endpoint, and at least one model:
Expand Down
Loading
Loading