Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
92 changes: 92 additions & 0 deletions packages/opencode/src/cli/cmd/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -317,6 +318,7 @@ export const ProvidersLoginCommand = cmd({
filtered[key] = value
}
}
ModelsDev.injectOpenWebUIPlaceholder(filtered, enabled, disabled, false)
return filtered
})

Expand Down Expand Up @@ -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"),
Expand Down
80 changes: 80 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -105,6 +106,9 @@ export function createDialogProviderOptions() {
}
}
if (method.type === "api") {
if (provider.id === "openwebui") {
return dialog.replace(() => <OpenWebUIMethod />)
}
return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />)
}
},
Expand Down Expand Up @@ -278,6 +282,82 @@ function ApiMethod(props: ApiMethodProps) {
)
}

function OpenWebUIMethod() {
const dialog = useDialog()
const sdk = useSDK()
const sync = useSync()
const { theme } = useTheme()

return (
<DialogPrompt
title="Open WebUI"
placeholder="https://your-instance.com"
description={
<text fg={theme.textMuted}>Enter the base URL of your Open WebUI instance</text>
}
onConfirm={async (baseURL) => {
if (!baseURL) return
dialog.replace(() => (
<DialogPrompt
title="Open WebUI"
placeholder="API key"
description={
<text fg={theme.textMuted}>Enter your Open WebUI API key</text>
}
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(() => <OpenWebUIError />)
return
}
dialog.replace(() => <DialogModel providerID="openwebui" />)
}}
/>
))
}}
/>
)
}

function OpenWebUIError() {
const { theme } = useTheme()
const dialog = useDialog()

return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.error}>
Open WebUI
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<text fg={theme.error}>No models found on the Open WebUI instance.</text>
<text fg={theme.textMuted}>Check that the base URL and API key are correct and that your instance has models available.</text>
</box>
)
}

interface PromptsMethodProps {
dialog: ReturnType<typeof useDialog>
prompts: NonNullable<ProviderAuthMethod["prompts"]>[number][]
Expand Down
4 changes: 3 additions & 1 deletion packages/opencode/src/config/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
17 changes: 17 additions & 0 deletions packages/opencode/src/provider/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,23 @@ export namespace ModelsDev {
ModelsDev.Data.reset()
}
}

export function injectOpenWebUIPlaceholder(
filtered: Record<string, Provider>,
enabled: Set<string> | undefined,
disabled: Set<string>,
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")) {
Expand Down
Loading
Loading