diff --git a/packages/app/src/components/dialog-select-provider.tsx b/packages/app/src/components/dialog-select-provider.tsx index 1273db596fcd..b55dc84ead4d 100644 --- a/packages/app/src/components/dialog-select-provider.tsx +++ b/packages/app/src/components/dialog-select-provider.tsx @@ -24,6 +24,7 @@ export const DialogSelectProvider: Component = () => { if (id === "openai") return language.t("dialog.provider.openai.note") if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note") if (id === "opencode-go") return language.t("dialog.provider.opencodeGo.tagline") + if (id === "litellm") return language.t("dialog.provider.litellm.note") } return ( diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index ffd85f97dce1..8a4de0e94202 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -25,6 +25,7 @@ const PROVIDER_NOTES = [ { match: (id: string) => id === "google", key: "dialog.provider.google.note" }, { match: (id: string) => id === "openrouter", key: "dialog.provider.openrouter.note" }, { match: (id: string) => id === "vercel", key: "dialog.provider.vercel.note" }, + { match: (id: string) => id === "litellm", key: "dialog.provider.litellm.note" }, ] as const export const SettingsProviders: Component = () => { diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 29f662f73270..bd10e35739b2 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -110,6 +110,7 @@ export const dict = { "dialog.provider.google.note": "Gemini models for fast, structured responses", "dialog.provider.openrouter.note": "Access all supported models from one provider", "dialog.provider.vercel.note": "Unified access to AI models with smart routing", + "dialog.provider.litellm.note": "Unified proxy for 100+ LLMs with load balancing and spend tracking", "dialog.model.select.title": "Select model", "dialog.model.search.placeholder": "Search models", diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 49c582e0a99e..f5a7f7b92aab 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -858,6 +858,79 @@ function custom(dep: CustomDep): Record { }, }, }), + litellm: Effect.fnUntraced(function* (input: Info) { + const env = yield* dep.env() + const auth = yield* dep.auth(input.id) + + const apiKey = iife(() => { + if (input.env.some((item) => env[item])) return input.env.map((item) => env[item]).find(Boolean) + if (auth?.type === "api") return auth.key + if (input.options?.apiKey) return input.options.apiKey + return undefined + }) + + const baseURL = iife(() => { + if (input.options?.baseURL) return input.options.baseURL + if (env["LITELLM_BASE_URL"]) return env["LITELLM_BASE_URL"] + return undefined + }) + + if (!baseURL) return { autoload: false } + + return { + autoload: true, + options: { + ...(apiKey ? { apiKey } : {}), + baseURL, + }, + async discoverModels(): Promise> { + try { + const url = `${baseURL.replace(/\/+$/, "")}/v1/models` + const headers: Record = { + "User-Agent": `opencode/${InstallationVersion} litellm (${os.platform()} ${os.release()}; ${os.arch()})`, + } + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}` + const res = await fetch(url, { headers }) + if (!res.ok) return {} + const body = (await res.json()) as { data?: Array<{ id: string; created?: number }> } + if (!Array.isArray(body.data)) return {} + const models: Record = {} + for (const entry of body.data) { + const id = entry.id + models[id] = { + id: ModelID.make(id), + providerID: ProviderID.make("litellm"), + name: id, + api: { + id, + url: baseURL, + npm: "@ai-sdk/openai-compatible", + }, + status: "active", + headers: {}, + options: {}, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 128_000, output: 16_384 }, + capabilities: { + temperature: true, + reasoning: false, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + release_date: "", + variants: {}, + } + } + return models + } catch { + return {} + } + }, + } + }), } } @@ -1433,6 +1506,25 @@ export const layer = Layer.effect( mergeProvider(providerID, patch) } + // Seed LiteLLM into database if env vars or stored auth indicate it should be available + // but it's not already in models.dev or config. This lets the custom loader and + // model discovery run without requiring a manual config entry. + if (!database["litellm"] && !disabled.has(ProviderID.make("litellm"))) { + const litellmBaseURL = envs["LITELLM_BASE_URL"] + const litellmKey = envs["LITELLM_API_KEY"] + const litellmAuth = auths["litellm"] + if (litellmBaseURL || litellmKey || litellmAuth) { + database["litellm"] = { + id: ProviderID.make("litellm"), + name: "LiteLLM", + source: "env", + env: ["LITELLM_API_KEY"], + options: {}, + models: {}, + } + } + } + for (const [id, fn] of Object.entries(custom(dep))) { const providerID = ProviderID.make(id) if (disabled.has(providerID)) continue @@ -1478,6 +1570,22 @@ export const layer = Layer.effect( }) } + const litellm = ProviderID.make("litellm") + if (discoveryLoaders[litellm] && providers[litellm] && isProviderAllowed(litellm)) { + yield* Effect.promise(async () => { + try { + const discovered = await discoveryLoaders[litellm]() + for (const [modelID, model] of Object.entries(discovered)) { + if (!providers[litellm].models[modelID]) { + providers[litellm].models[modelID] = model + } + } + } catch (e) { + log.warn("state discovery error", { id: "litellm", error: e }) + } + }) + } + for (const [id, provider] of Object.entries(providers)) { const providerID = ProviderID.make(id) if (!isProviderAllowed(providerID)) { diff --git a/packages/opencode/src/provider/schema.ts b/packages/opencode/src/provider/schema.ts index db05b47843e9..aa263e6e878c 100644 --- a/packages/opencode/src/provider/schema.ts +++ b/packages/opencode/src/provider/schema.ts @@ -20,6 +20,7 @@ export const ProviderID = providerIdSchema.pipe( openrouter: schema.make("openrouter"), mistral: schema.make("mistral"), gitlab: schema.make("gitlab"), + litellm: schema.make("litellm"), })), ) diff --git a/packages/ui/src/components/provider-icons/sprite.svg b/packages/ui/src/components/provider-icons/sprite.svg index 68b99ce56d4a..cc948dc66b40 100644 --- a/packages/ui/src/components/provider-icons/sprite.svg +++ b/packages/ui/src/components/provider-icons/sprite.svg @@ -1131,5 +1131,8 @@ d="M 554.228 729.711 C 659.104 725.931 747.227 807.802 751.164 912.672 C 755.1 1017.54 673.362 1105.79 568.498 1109.88 C 463.411 1113.98 374.937 1032.03 370.992 926.942 C 367.047 821.849 449.129 733.498 554.228 729.711 z" > + + 🚅 + diff --git a/packages/ui/src/components/provider-icons/types.ts b/packages/ui/src/components/provider-icons/types.ts index 1c6f5fe6d2f0..181da3b1a622 100644 --- a/packages/ui/src/components/provider-icons/types.ts +++ b/packages/ui/src/components/provider-icons/types.ts @@ -33,6 +33,7 @@ export const iconNames = [ "ovhcloud", "openrouter", "llmgateway", + "litellm", "opencode", "opencode-go", "openai",