Repository navigation
feat(core): add LiteLLM provider #54558
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,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 | ||
| .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() | ||
| 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 } } }), | ||
| }) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
Contributor
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. Most LiteLLM proxies sit in front of models that support tools and images (Claude, GPT, …). With |
||
| 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: | ||
|
|
||
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.
This now checks
127.0.0.1:4000every 30s for every user, and port 4000 is a common dev-server port (Phoenix, Jekyll, …). The vLLM plugin checks/healthand only accepts models whereowned_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/livelinessendpoint, before reading/v1/models?