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).