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
1 change: 1 addition & 0 deletions packages/app/src/components/dialog-select-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/components/settings-providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,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 = () => {
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const ID = Schema.String.pipe(
openrouter: schema.make("openrouter"),
mistral: schema.make("mistral"),
gitlab: schema.make("gitlab"),
litellm: schema.make("litellm"),
})),
)
export type ID = typeof ID.Type
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { CodexAuthPlugin } from "./openai/codex"
import { Session } from "@/session/session"
import { NamedError } from "@opencode-ai/core/util/error"
import { CopilotAuthPlugin } from "./github-copilot/copilot"
import { LiteLLMPlugin } from "./litellm/litellm"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
import { PoeAuthPlugin } from "opencode-poe-auth"
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
Expand Down Expand Up @@ -71,6 +72,7 @@ function internalPlugins(flags: RuntimeFlags.Info): PluginInstance[] {
experimentalWebSockets: experimentalWebSocketsEnabled({ enabled: flags.experimentalWebSockets }),
}),
CopilotAuthPlugin,
LiteLLMPlugin,
GitlabAuthPlugin,
PoeAuthPlugin,
CloudflareWorkersAuthPlugin,
Expand Down
66 changes: 66 additions & 0 deletions packages/opencode/src/plugin/litellm/litellm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import * as Log from "@opencode-ai/core/util/log"
import { LiteLLMModels } from "./models"

const log = Log.create({ service: "plugin.litellm" })

export async function LiteLLMPlugin(input: PluginInput): Promise<Hooks> {
return {
provider: {
id: "litellm",
async models(provider, ctx) {
const baseURL = (() => {
if (provider.options?.baseURL) return provider.options.baseURL as string
if (process.env["LITELLM_BASE_URL"]) return process.env["LITELLM_BASE_URL"]
return undefined
})()

if (!baseURL) {
return provider.models
}

const headers: Record<string, string> = {}
if (ctx.auth?.type === "api" && ctx.auth.key) {
headers["Authorization"] = `Bearer ${ctx.auth.key}`
} else if (process.env["LITELLM_API_KEY"]) {
headers["Authorization"] = `Bearer ${process.env["LITELLM_API_KEY"]}`
}

return LiteLLMModels.get(baseURL, headers, provider.models).catch((error) => {
log.error("failed to fetch litellm models", { error })
return provider.models
})
},
},
auth: {
provider: "litellm",
async loader(getAuth) {
const auth = await getAuth()
if (auth.type !== "api") {
// Fall back to env var
const envKey = process.env["LITELLM_API_KEY"]
if (envKey) return { apiKey: envKey }
return {}
}

return {
apiKey: auth.key,
}
},
methods: [
{
label: "Enter LiteLLM API Key",
type: "api",
prompts: [
{
type: "text",
key: "base_url",
message: "LiteLLM proxy base URL (e.g. http://localhost:4000)",
placeholder: "http://localhost:4000",
},
],
},
],
},
}
}
133 changes: 133 additions & 0 deletions packages/opencode/src/plugin/litellm/models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import type { Model } from "@opencode-ai/sdk/v2"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import * as Log from "@opencode-ai/core/util/log"
import os from "os"

const log = Log.create({ service: "plugin.litellm.models" })

interface LiteLLMModelEntry {
id: string
created?: number
owned_by?: string
}

interface LiteLLMModelInfo {
max_tokens?: number
max_input_tokens?: number
max_output_tokens?: number
input_cost_per_token?: number
output_cost_per_token?: number
supports_vision?: boolean
supports_function_calling?: boolean
mode?: string
}

async function fetchModelInfo(
baseURL: string,
headers: Record<string, string>,
): Promise<Record<string, LiteLLMModelInfo>> {
try {
const url = `${baseURL.replace(/\/+$/, "")}/model/info`
const res = await fetch(url, {
headers,
signal: AbortSignal.timeout(5_000),
})
if (!res.ok) return {}
const body = (await res.json()) as { data?: Array<{ model_name: string; model_info?: LiteLLMModelInfo }> }
if (!Array.isArray(body.data)) return {}
const info: Record<string, LiteLLMModelInfo> = {}
for (const entry of body.data) {
if (entry.model_name && entry.model_info) {
info[entry.model_name] = entry.model_info
}
}
return info
} catch {
return {}
}
}

function buildModel(id: string, baseURL: string, info?: LiteLLMModelInfo, prev?: Model): Model {
const maxContext = info?.max_tokens ?? info?.max_input_tokens ?? 128_000
const maxOutput = info?.max_output_tokens ?? 16_384

const inputCost = info?.input_cost_per_token ?? 0
const outputCost = info?.output_cost_per_token ?? 0

return {
id: id,
providerID: "litellm",
name: prev?.name ?? id,
api: {
id,
url: baseURL,
npm: "@ai-sdk/openai-compatible",
},
status: "active",
headers: prev?.headers ?? {},
options: prev?.options ?? {},
cost: {
input: inputCost * 1_000_000,
output: outputCost * 1_000_000,
cache: { read: 0, write: 0 },
},
limit: {
context: maxContext,
output: maxOutput,
},
capabilities: {
temperature: true,
reasoning: prev?.capabilities?.reasoning ?? false,
attachment: true,
toolcall: info?.supports_function_calling ?? prev?.capabilities?.toolcall ?? true,
input: {
text: true,
audio: false,
image: info?.supports_vision ?? prev?.capabilities?.input?.image ?? true,
video: false,
pdf: false,
},
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
release_date: prev?.release_date ?? "",
variants: prev?.variants ?? {},
}
}

export async function get(
baseURL: string,
headers: Record<string, string> = {},
existing: Record<string, Model> = {},
): Promise<Record<string, Model>> {
const modelsURL = `${baseURL.replace(/\/+$/, "")}/v1/models`
const reqHeaders: Record<string, string> = {
"User-Agent": `opencode/${InstallationVersion} litellm (${os.platform()} ${os.release()}; ${os.arch()})`,
...headers,
}

const res = await fetch(modelsURL, {
headers: reqHeaders,
signal: AbortSignal.timeout(5_000),
})
if (!res.ok) throw new Error(`Failed to fetch models: ${res.status}`)

const body = (await res.json()) as { data?: LiteLLMModelEntry[] }
if (!Array.isArray(body.data)) return existing

// Enrich with per-model metadata from /model/info
const modelInfo = await fetchModelInfo(baseURL, reqHeaders)

const result = { ...existing }

for (const entry of body.data) {
const id = entry.id
if (!id) continue
const info = modelInfo[id]
result[id] = buildModel(id, baseURL, info, existing[id])
}

return result
}

export * as LiteLLMModels from "./models"
20 changes: 18 additions & 2 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1127,7 +1127,7 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
const base = fromModelsDevModel(provider, model)
models[id] = {
...base,
id: ProviderV2.ModelID.make(id),
id: ProviderV2.ProviderV2.ModelID.make(id),
name: `${model.name} ${mode[0].toUpperCase()}${mode.slice(1)}`,
cost: opts.cost ? mergeDeep(base.cost, cost(opts.cost)) : base.cost,
options: opts.provider?.body
Expand Down Expand Up @@ -1257,6 +1257,17 @@ export const layer = Layer.effect(
const providerID = ProviderV2.ID.make(p.id)
if (disabled.has(providerID)) continue

// Seed provider entry if the plugin declares one but it's not in models.dev or config
if (!database[providerID]) {
database[providerID] = {
id: providerID,
name: providerID,
source: "env",
env: [],
options: {},
models: {},
}
}
const provider = database[providerID]
if (!provider) continue
const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie)
Expand All @@ -1268,12 +1279,17 @@ export const layer = Layer.effect(
id,
{
...model,
id: ProviderV2.ModelID.make(id),
id: ProviderV2.ProviderV2.ModelID.make(id),
providerID,
},
]),
)
})

// Ensure plugin-seeded providers appear in the state
if (!providers[providerID]) {
mergeProvider(providerID, { source: "env" })
}
}

// extend database from config
Expand Down
3 changes: 3 additions & 0 deletions packages/ui/src/components/provider-icons/sprite.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions packages/ui/src/components/provider-icons/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const iconNames = [
"ovhcloud",
"openrouter",
"llmgateway",
"litellm",
"opencode",
"opencode-go",
"openai",
Expand Down
Loading