diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts
index 581809e90eb..eef90557db0 100644
--- a/packages/opencode/src/cli/cmd/providers.ts
+++ b/packages/opencode/src/cli/cmd/providers.ts
@@ -3,6 +3,7 @@ import { cmd } from "./cmd"
import * as prompts from "@clack/prompts"
import { UI } from "../ui"
import { ModelsDev } from "../../provider/models"
+import { ProviderTransform } from "../../provider/transform"
import { map, pipe, sortBy, values } from "remeda"
import path from "path"
import os from "os"
@@ -317,6 +318,7 @@ export const ProvidersLoginCommand = cmd({
filtered[key] = value
}
}
+ ModelsDev.injectOpenWebUIPlaceholder(filtered, enabled, disabled, false)
return filtered
})
@@ -436,6 +438,96 @@ export const ProvidersLoginCommand = cmd({
)
}
+ if (provider === "openwebui") {
+ const baseURL = await prompts.text({
+ message: "Enter your Open WebUI instance URL",
+ placeholder: "https://your-instance.com",
+ validate: (x) => (x && x.length > 0 ? undefined : "Required"),
+ })
+ if (prompts.isCancel(baseURL)) throw new UI.CancelledError()
+
+ const key = await prompts.password({
+ message: "Enter your API key",
+ validate: (x) => (x && x.length > 0 ? undefined : "Required"),
+ })
+ if (prompts.isCancel(key)) throw new UI.CancelledError()
+
+ const normalizedBaseURL = ProviderTransform.openwebuiOpenAICompatibleBase(baseURL.replace(/\/+$/, ""))
+ const origin = (() => {
+ try {
+ return new URL(normalizedBaseURL).origin
+ } catch {
+ return ""
+ }
+ })()
+ const fallback = origin ? `${origin}/api/models` : ""
+
+ const spinner = prompts.spinner()
+ spinner.start("Fetching models from Open WebUI...")
+
+ try {
+ const headers = { Authorization: `Bearer ${key}` }
+ let response = await fetch(`${normalizedBaseURL}/models`, {
+ headers,
+ signal: AbortSignal.timeout(10_000),
+ })
+ if (!response.ok && fallback) {
+ response = await fetch(fallback, { headers, signal: AbortSignal.timeout(10_000) })
+ }
+
+ if (!response.ok) {
+ spinner.stop("Failed to connect to Open WebUI", 1)
+ prompts.log.error(`Models endpoint returned status ${response.status}. Check your base URL and API key.`)
+ prompts.outro("Failed")
+ return
+ }
+
+ const data = (await response.json()) as unknown
+ const modelCount = Array.isArray(data)
+ ? data.length
+ : data &&
+ typeof data === "object" &&
+ "data" in data &&
+ Array.isArray((data as { data: unknown[] }).data)
+ ? (data as { data: unknown[] }).data.length
+ : data &&
+ typeof data === "object" &&
+ "models" in data &&
+ Array.isArray((data as { models: unknown[] }).models)
+ ? (data as { models: unknown[] }).models.length
+ : 0
+
+ if (modelCount === 0) {
+ spinner.stop("No models found", 1)
+ prompts.log.error("The Open WebUI instance returned no models. Check that your instance has models available.")
+ prompts.outro("Failed")
+ return
+ }
+
+ spinner.stop(`Found ${modelCount} model${modelCount === 1 ? "" : "s"}`)
+ } catch (e) {
+ spinner.stop("Failed to connect to Open WebUI", 1)
+ prompts.log.error("Could not reach the Open WebUI instance. Check your base URL.")
+ prompts.outro("Failed")
+ return
+ }
+
+ await Config.updateGlobal({
+ provider: {
+ openwebui: {
+ options: { baseURL: normalizedBaseURL },
+ },
+ },
+ })
+ await Auth.set(provider, {
+ type: "api",
+ key,
+ })
+
+ prompts.outro("Done")
+ return
+ }
+
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx
index 635ed71f5b3..638819dca51 100644
--- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx
@@ -39,6 +39,7 @@ export function createDialogProviderOptions() {
opencode: "(Recommended)",
anthropic: "(API key)",
openai: "(ChatGPT Plus/Pro or API key)",
+ openwebui: "(API key + base URL)",
"opencode-go": "Low cost subscription for everyone",
}[provider.id],
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
@@ -105,6 +106,9 @@ export function createDialogProviderOptions() {
}
}
if (method.type === "api") {
+ if (provider.id === "openwebui") {
+ return dialog.replace(() => )
+ }
return dialog.replace(() => )
}
},
@@ -278,6 +282,82 @@ function ApiMethod(props: ApiMethodProps) {
)
}
+function OpenWebUIMethod() {
+ const dialog = useDialog()
+ const sdk = useSDK()
+ const sync = useSync()
+ const { theme } = useTheme()
+
+ return (
+ Enter the base URL of your Open WebUI instance
+ }
+ onConfirm={async (baseURL) => {
+ if (!baseURL) return
+ dialog.replace(() => (
+ Enter your Open WebUI API key
+ }
+ onConfirm={async (apiKey) => {
+ if (!apiKey) return
+ await sdk.client.config.update({
+ config: {
+ provider: {
+ openwebui: {
+ options: { baseURL },
+ },
+ },
+ },
+ })
+ await sdk.client.auth.set({
+ providerID: "openwebui",
+ auth: {
+ type: "api",
+ key: apiKey,
+ },
+ })
+ await sdk.client.instance.dispose()
+ await sync.bootstrap()
+ const provider = sync.data.provider_next.connected.includes("openwebui")
+ if (!provider) {
+ dialog.replace(() => )
+ return
+ }
+ dialog.replace(() => )
+ }}
+ />
+ ))
+ }}
+ />
+ )
+}
+
+function OpenWebUIError() {
+ const { theme } = useTheme()
+ const dialog = useDialog()
+
+ return (
+
+
+
+ Open WebUI
+
+ dialog.clear()}>
+ esc
+
+
+ No models found on the Open WebUI instance.
+ Check that the base URL and API key are correct and that your instance has models available.
+
+ )
+}
+
interface PromptsMethodProps {
dialog: ReturnType
prompts: NonNullable[number][]
diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts
index 396417e9a5b..b6e82ae318a 100644
--- a/packages/opencode/src/config/paths.ts
+++ b/packages/opencode/src/config/paths.ts
@@ -10,7 +10,9 @@ import { Global } from "@/global"
export namespace ConfigPaths {
export async function projectFiles(name: string, directory: string, worktree: string) {
const files: string[] = []
- for (const file of [`${name}.jsonc`, `${name}.json`]) {
+ const primary = [`${name}.jsonc`, `${name}.json`] as const
+ const legacy = name === "opencode" ? (["config.jsonc", "config.json"] as const) : []
+ for (const file of [...legacy, ...primary]) {
const found = await Filesystem.findUp(file, directory, worktree)
for (const resolved of found.toReversed()) {
files.push(resolved)
diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts
index bae33178467..729d2386da8 100644
--- a/packages/opencode/src/provider/models.ts
+++ b/packages/opencode/src/provider/models.ts
@@ -119,6 +119,23 @@ export namespace ModelsDev {
ModelsDev.Data.reset()
}
}
+
+ export function injectOpenWebUIPlaceholder(
+ filtered: Record,
+ enabled: Set | undefined,
+ disabled: Set,
+ server: boolean,
+ ) {
+ if (!filtered["openwebui"] && (enabled ? enabled.has("openwebui") : true) && !disabled.has("openwebui")) {
+ filtered["openwebui"] = {
+ id: "openwebui",
+ name: "Open WebUI",
+ env: ["OPEN_WEBUI_API_KEY"],
+ models: {},
+ ...(server ? { api: "" } : {}),
+ }
+ }
+ }
}
if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) {
diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts
index 9c9c8e83438..3c1cd2e9940 100644
--- a/packages/opencode/src/provider/provider.ts
+++ b/packages/opencode/src/provider/provider.ts
@@ -17,6 +17,7 @@ import { Flag } from "../flag/flag"
import { iife } from "@/util/iife"
import { Global } from "../global"
import path from "path"
+import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib"
import { Filesystem } from "../util/filesystem"
// Direct imports for bundled providers
@@ -104,6 +105,79 @@ export namespace Provider {
})
}
+ async function* openwebuiDecodedWebChunks(
+ webBody: ReadableStream,
+ contentEncoding: string | null | undefined,
+ ) {
+ const enc = contentEncoding?.toLowerCase() ?? ""
+ const reader = webBody.getReader()
+ const first = await reader.read()
+ if (first.done || !first.value?.byteLength) return
+ const head = Buffer.from(first.value.buffer, first.value.byteOffset, first.value.byteLength)
+ const gzipMagic = head.length >= 2 && head[0] === 0x1f && head[1] === 0x8b
+ const prefix = head
+ .toString("utf8", 0, Math.min(head.length, 48))
+ .replace(/^\uFEFF/, "")
+ .trimStart()
+ const alreadyPlain =
+ prefix.startsWith("data:") ||
+ prefix.startsWith("event:") ||
+ prefix.startsWith("{") ||
+ prefix.startsWith("[") ||
+ prefix.startsWith("<")
+ const skipDecode =
+ alreadyPlain &&
+ !gzipMagic &&
+ (enc.includes("gzip") || enc.includes("x-gzip") || enc.includes("deflate") || enc.includes("br"))
+ const wantGzip = gzipMagic || enc.includes("gzip") || enc.includes("x-gzip")
+
+ if (wantGzip && !skipDecode) {
+ const gunzip = createGunzip()
+ const pump = async () => {
+ gunzip.write(head)
+ for (;;) {
+ const n = await reader.read()
+ if (n.done) {
+ gunzip.end()
+ return
+ }
+ const v = n.value
+ gunzip.write(Buffer.from(v.buffer, v.byteOffset, v.byteLength))
+ }
+ }
+ void pump().catch((e) => gunzip.destroy(e))
+ for await (const c of gunzip) yield Buffer.isBuffer(c) ? c : Buffer.from(c)
+ return
+ }
+
+ if ((enc.includes("deflate") || enc.includes("br")) && !skipDecode) {
+ const z = enc.includes("br") ? createBrotliDecompress() : createInflate()
+ const pump = async () => {
+ z.write(head)
+ for (;;) {
+ const n = await reader.read()
+ if (n.done) {
+ z.end()
+ return
+ }
+ const v = n.value
+ z.write(Buffer.from(v.buffer, v.byteOffset, v.byteLength))
+ }
+ }
+ void pump().catch((e) => z.destroy(e))
+ for await (const c of z) yield Buffer.isBuffer(c) ? c : Buffer.from(c)
+ return
+ }
+
+ yield head
+ for (;;) {
+ const n = await reader.read()
+ if (n.done) return
+ const v = n.value
+ yield Buffer.from(v.buffer, v.byteOffset, v.byteLength)
+ }
+ }
+
const BUNDLED_PROVIDERS: Record SDK> = {
"@ai-sdk/amazon-bedrock": createAmazonBedrock,
"@ai-sdk/anthropic": createAnthropic,
@@ -859,6 +933,158 @@ export namespace Provider {
const configProviders = Object.entries(config.provider ?? {})
+ {
+ const openwebuiExisting = database["openwebui"]
+ const openwebuiConfig = config.provider?.["openwebui"]
+ const openwebuiRaw = (openwebuiConfig?.options?.baseURL ?? Env.get("OPEN_WEBUI_BASE_URL"))?.replace(
+ /\/+$/,
+ "",
+ )
+ const openwebuiApiKey = await (async () => {
+ if (openwebuiConfig?.options?.apiKey) return openwebuiConfig.options.apiKey
+ const envKey = Env.get("OPEN_WEBUI_API_KEY")
+ if (envKey) return envKey
+ const auth = await Auth.get("openwebui")
+ if (auth?.type === "api") return auth.key
+ return undefined
+ })()
+
+ if (openwebuiRaw && openwebuiApiKey) {
+ const openAINormal = ProviderTransform.openwebuiOpenAICompatibleBase(openwebuiRaw)
+ let origin: string
+ try {
+ origin = new URL(openAINormal).origin
+ } catch {
+ origin = ""
+ }
+ const models: Record = {}
+
+ const endpointsToTry = Array.from(
+ new Set([
+ `${openAINormal}/models`,
+ ...(origin ? [`${origin}/api/models`, `${origin}/api/v1/models`, `${origin}/v1/models`] : []),
+ ]),
+ )
+
+ for (const endpoint of endpointsToTry) {
+ if (Object.keys(models).length > 0) break
+
+ const response = await fetch(endpoint, {
+ headers: {
+ Authorization: `Bearer ${openwebuiApiKey}`,
+ Accept: "application/json",
+ },
+ signal: AbortSignal.timeout(5_000),
+ }).catch((error) => {
+ log.error("Failed to fetch Open WebUI models", { endpoint, error })
+ return undefined
+ })
+ if (!response) continue
+
+ if (response.ok) {
+ const text = await response.text().catch(() => "")
+ const data = await Promise.resolve(text)
+ .then((x) => (x ? JSON.parse(x) : undefined))
+ .catch(() => undefined)
+ if (!data) {
+ log.error("Open WebUI models endpoint returned non-JSON body", {
+ endpoint,
+ preview: text.slice(0, 200),
+ })
+ continue
+ }
+
+ type OwuiEntry = { id?: string; name?: string; model?: string; info?: { id?: string } }
+ let entries: unknown[] = []
+ if (Array.isArray(data)) entries = data
+ if (
+ !Array.isArray(data) &&
+ data &&
+ typeof data === "object" &&
+ "data" in data &&
+ Array.isArray(data.data)
+ )
+ entries = data.data
+ if (
+ !Array.isArray(data) &&
+ data &&
+ typeof data === "object" &&
+ "models" in data &&
+ Array.isArray(data.models)
+ )
+ entries = data.models
+
+ for (const item of entries) {
+ const rawID = (() => {
+ if (typeof item === "string") return item
+ if (!item || typeof item !== "object") return undefined
+ const e = item as OwuiEntry
+ if (typeof e.id === "string" && e.id) return e.id
+ if (typeof e.name === "string" && e.name) return e.name
+ if (typeof e.model === "string" && e.model) return e.model
+ if (e.info && typeof e.info.id === "string" && e.info.id) return e.info.id
+ return undefined
+ })()
+ if (!rawID) continue
+ const e = item && typeof item === "object" ? (item as OwuiEntry) : undefined
+ const displayName = typeof e?.name === "string" ? e.name : rawID
+ models[rawID] = {
+ id: ModelID.make(rawID),
+ providerID: ProviderID.make("openwebui"),
+ name: displayName,
+ api: {
+ id: rawID,
+ url: openAINormal,
+ npm: "@ai-sdk/openai-compatible",
+ },
+ status: "active",
+ headers: {},
+ options: {},
+ cost: {
+ input: 0,
+ output: 0,
+ cache: { read: 0, write: 0 },
+ },
+ limit: {
+ context: 128_000,
+ output: 4_096,
+ },
+ capabilities: {
+ temperature: true,
+ reasoning: false,
+ attachment: false,
+ toolcall: true,
+ input: { text: true, audio: false, image: false, video: false, pdf: false },
+ output: { text: true, audio: false, image: false, video: false, pdf: false },
+ interleaved: false,
+ },
+ release_date: "",
+ variants: {},
+ }
+ }
+ }
+ }
+
+ if (Object.keys(models).length === 0) {
+ log.error("Open WebUI instance returned no models after trying all endpoints", {
+ baseURL: openwebuiRaw,
+ normalized: openAINormal,
+ })
+ }
+
+ if (Object.keys(models).length > 0) {
+ database["openwebui"] = {
+ id: ProviderID.make("openwebui"),
+ name: openwebuiExisting?.name ?? "Open WebUI",
+ source: "custom",
+ env: ["OPEN_WEBUI_API_KEY"],
+ options: {},
+ models,
+ }
+ }
+ }
+ }
+
function mergeProvider(providerID: ProviderID, provider: Partial) {
const existing = providers[providerID]
if (existing) {
@@ -1092,6 +1318,16 @@ export namespace Provider {
const provider = s.providers[model.providerID]
const options = { ...provider.options }
+ if (model.providerID === "openwebui") {
+ const raw =
+ typeof options["baseURL"] === "string" && options["baseURL"] !== ""
+ ? options["baseURL"]
+ : model.api.url
+ if (typeof raw === "string" && raw !== "") {
+ options["baseURL"] = ProviderTransform.openwebuiOpenAICompatibleBase(raw)
+ }
+ }
+
if (model.providerID === "google-vertex" && !model.api.npm.includes("@ai-sdk/openai-compatible")) {
delete options.fetch
}
@@ -1132,6 +1368,13 @@ export namespace Provider {
...model.headers,
}
+ if (model.providerID === "openwebui") {
+ options["headers"] = {
+ ...options["headers"],
+ "Accept-Encoding": "identity",
+ }
+ }
+
const key = Hash.fast(JSON.stringify({ providerID: model.providerID, npm: model.api.npm, options }))
const existing = s.sdk.get(key)
if (existing) return existing
@@ -1155,6 +1398,74 @@ export namespace Provider {
const combined = signals.length === 0 ? null : signals.length === 1 ? signals[0] : AbortSignal.any(signals)
if (combined) opts.signal = combined
+ if (model.providerID === "openwebui") {
+ const res = await fetch(input, {
+ ...opts,
+ timeout: false,
+ decompress: false,
+ } as RequestInit & { decompress?: boolean; timeout?: boolean })
+
+ const ctype = res.headers.get("content-type") ?? "text/event-stream; charset=utf-8"
+ const contentEncoding = res.headers.get("content-encoding")
+ const outHeaders = new Headers()
+ outHeaders.set("content-type", ctype)
+
+ if (!res.ok) {
+ const text = await res.text().catch(() => "")
+ const errRes = new Response(text, {
+ status: res.status,
+ statusText: res.statusText,
+ headers: outHeaders,
+ })
+ if (text) {
+ const msg = `Open WebUI Error (${res.status} ${res.statusText}): ${text.slice(0, 500)}`
+ log.error(msg)
+ Object.defineProperty(errRes, "statusText", { value: msg })
+ }
+ return errRes
+ }
+
+ if (!res.body)
+ return new Response(null, { status: res.status, statusText: res.statusText, headers: outHeaders })
+
+ const chunkIter = openwebuiDecodedWebChunks(res.body, contentEncoding)
+ if (opts.signal) {
+ opts.signal.addEventListener(
+ "abort",
+ () => {
+ void res.body?.cancel()
+ },
+ { once: true },
+ )
+ }
+
+ const bodyStream = new ReadableStream({
+ async pull(controller) {
+ await chunkIter
+ .next()
+ .then((n) => {
+ if (n.done) {
+ controller.close()
+ return
+ }
+ controller.enqueue(new Uint8Array(n.value))
+ })
+ .catch((e) => {
+ controller.error(e)
+ })
+ },
+ cancel() {
+ void res.body?.cancel()
+ },
+ })
+
+ return new Response(bodyStream, {
+ status: res.status,
+ statusText: res.statusText,
+ headers: outHeaders,
+ })
+ }
+
// Strip openai itemId metadata following what codex does
// Codex uses #[serde(skip_serializing)] on id fields for all item types:
// Message, Reasoning, FunctionCall, LocalShellCall, CustomToolCall, WebSearchCall
diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts
index 05b9f031fe6..61b6e4ee121 100644
--- a/packages/opencode/src/provider/transform.ts
+++ b/packages/opencode/src/provider/transform.ts
@@ -331,6 +331,7 @@ export namespace ProviderTransform {
export function variants(model: Provider.Model): Record> {
if (!model.capabilities.reasoning) return {}
+ if (model.providerID === "openwebui") return {}
const id = model.id.toLowerCase()
const isAnthropicAdaptive = ["opus-4-6", "opus-4.6", "sonnet-4-6", "sonnet-4.6"].some((v) =>
@@ -716,6 +717,8 @@ export namespace ProviderTransform {
sessionID: string
providerOptions?: Record
}): Record {
+ if (input.model.providerID === "openwebui") return {}
+
const result: Record = {}
// openai and providers using openai package should set store to false by default.
@@ -830,6 +833,8 @@ export namespace ProviderTransform {
}
export function smallOptions(model: Provider.Model) {
+ if (model.providerID === "openwebui") return {}
+
if (
model.providerID === "openai" ||
model.api.npm === "@ai-sdk/openai" ||
@@ -929,7 +934,10 @@ export namespace ProviderTransform {
*/
// Convert integer enums to string enums for Google/Gemini
- if (model.providerID === "google" || model.api.id.includes("gemini")) {
+ if (
+ model.providerID !== "openwebui" &&
+ (model.providerID === "google" || model.api.id.includes("gemini"))
+ ) {
const isPlainObject = (node: unknown): node is Record =>
typeof node === "object" && node !== null && !Array.isArray(node)
const hasCombiner = (node: unknown) =>
@@ -1009,4 +1017,21 @@ export namespace ProviderTransform {
return schema as JSONSchema7
}
+
+ export function openwebuiOpenAICompatibleBase(text: string): string {
+ const trimmed = text.trim().replace(/\/+$/, "")
+ let parsed: URL
+ try {
+ parsed = new URL(trimmed)
+ } catch {
+ return trimmed
+ }
+ const pathname = parsed.pathname.replace(/\/+$/, "") || ""
+ const base = `${parsed.origin}${pathname}`
+ if (base.endsWith("/api/v1")) return base
+ if (/\/v1$/.test(base)) return base
+ if (!pathname || pathname === "/") return `${parsed.origin}/api/v1`
+ if (pathname.endsWith("/api")) return `${base}/v1`
+ return `${base}/api/v1`
+ }
}
diff --git a/packages/opencode/src/server/routes/provider.ts b/packages/opencode/src/server/routes/provider.ts
index 3ac3e7c64a8..6fbe3542eb3 100644
--- a/packages/opencode/src/server/routes/provider.ts
+++ b/packages/opencode/src/server/routes/provider.ts
@@ -6,7 +6,7 @@ import { Provider } from "../../provider/provider"
import { ModelsDev } from "../../provider/models"
import { ProviderAuth } from "../../provider/auth"
import { ProviderID } from "../../provider/schema"
-import { mapValues } from "remeda"
+import { mapValues, pickBy } from "remeda"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
@@ -48,6 +48,8 @@ export const ProviderRoutes = lazy(() =>
}
}
+ ModelsDev.injectOpenWebUIPlaceholder(filteredProviders, enabled, disabled, true)
+
const connected = await Provider.list()
const providers = Object.assign(
mapValues(filteredProviders, (x) => Provider.fromModelsDevProvider(x)),
@@ -55,7 +57,10 @@ export const ProviderRoutes = lazy(() =>
)
return c.json({
all: Object.values(providers),
- default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
+ default: mapValues(
+ pickBy(providers, (item) => Object.keys(item.models).length > 0),
+ (item) => Provider.sort(Object.values(item.models))[0].id,
+ ),
connected: Object.keys(connected),
})
},
diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts
index 917d357eafa..fcc9c50dd17 100644
--- a/packages/opencode/test/provider/transform.test.ts
+++ b/packages/opencode/test/provider/transform.test.ts
@@ -2653,3 +2653,16 @@ describe("ProviderTransform.variants", () => {
})
})
})
+
+describe("ProviderTransform.openwebuiOpenAICompatibleBase", () => {
+ test("origin becomes /api/v1", () => {
+ expect(ProviderTransform.openwebuiOpenAICompatibleBase("https://chat.example.com")).toBe(
+ "https://chat.example.com/api/v1",
+ )
+ })
+ test("/api suffix becomes /api/v1", () => {
+ expect(ProviderTransform.openwebuiOpenAICompatibleBase("https://chat.example.com/api")).toBe(
+ "https://chat.example.com/api/v1",
+ )
+ })
+})
diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx
index 0c0ba30a085..af028e3d129 100644
--- a/packages/web/src/content/docs/providers.mdx
+++ b/packages/web/src/content/docs/providers.mdx
@@ -1341,6 +1341,68 @@ To use Ollama Cloud with OpenCode:
---
+### Open WebUI
+
+[Open WebUI](https://openwebui.com/) is a self-hosted AI interface that provides an OpenAI-compatible API. OpenCode can automatically discover all models available on your Open WebUI instance.
+
+1. Run the `/connect` command and search for **Open WebUI**.
+
+ ```txt
+ /connect
+ ```
+
+2. Enter the base URL of your Open WebUI instance.
+
+ ```txt
+ ┌ Base URL
+ │ https://your-instance.com
+ │
+ └ enter
+ ```
+
+3. Enter your Open WebUI API key.
+
+ ```txt
+ ┌ API key
+ │
+ │
+ └ enter
+ ```
+
+4. OpenCode will fetch the available models from your instance. Run the `/models` command to select one.
+
+ ```txt
+ /models
+ ```
+
+##### Environment variables
+
+You can also configure Open WebUI using environment variables instead of `/connect`.
+
+```bash
+export OPEN_WEBUI_BASE_URL="https://your-instance.com"
+export OPEN_WEBUI_API_KEY="your-api-key"
+```
+
+##### Config file
+
+Alternatively, you can set the base URL in your `opencode.json`.
+
+```json title="opencode.json"
+{
+ "$schema": "https://opencode.ai/config.json",
+ "provider": {
+ "openwebui": {
+ "options": {
+ "baseURL": "https://your-instance.com"
+ }
+ }
+ }
+}
+```
+
+---
+
### OpenAI
We recommend signing up for [ChatGPT Plus or Pro](https://chatgpt.com/pricing).