diff --git a/packages/app/src/components/dialog-custom-provider-discovery.test.ts b/packages/app/src/components/dialog-custom-provider-discovery.test.ts new file mode 100644 index 000000000000..052b4e0d068d --- /dev/null +++ b/packages/app/src/components/dialog-custom-provider-discovery.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test" +import { parseDiscoveredModels } from "./dialog-custom-provider-discovery" + +describe("parseDiscoveredModels", () => { + test("parses OpenAI-style data arrays", () => { + expect( + parseDiscoveredModels({ + data: [ + { id: "gpt-4o", name: "GPT-4o" }, + { id: "gpt-4o-mini" }, + { id: "gpt-4o" }, + { id: "", name: "skip" }, + ], + }), + ).toEqual([ + { id: "gpt-4o", name: "GPT-4o" }, + { id: "gpt-4o-mini", name: "gpt-4o-mini" }, + ]) + }) + + test("falls back to models arrays", () => { + expect( + parseDiscoveredModels({ + models: [ + { id: "claude-sonnet", name: "Claude Sonnet" }, + { id: "claude-haiku", name: "" }, + ], + }), + ).toEqual([ + { id: "claude-sonnet", name: "Claude Sonnet" }, + { id: "claude-haiku", name: "claude-haiku" }, + ]) + }) +}) \ No newline at end of file diff --git a/packages/app/src/components/dialog-custom-provider-discovery.ts b/packages/app/src/components/dialog-custom-provider-discovery.ts new file mode 100644 index 000000000000..d855f6b5b420 --- /dev/null +++ b/packages/app/src/components/dialog-custom-provider-discovery.ts @@ -0,0 +1,134 @@ +type DiscoveredModel = { + id: string + name: string +} + +type DiscoverArgs = { + baseURL: string + apiKey?: string + headers?: Record + fetcher?: typeof fetch + fetchJson?: (url: string, init?: { method?: string; headers?: Record; body?: string; timeoutMs?: number }) => Promise<{ + ok: boolean + status: number + data: unknown + }> + signal?: AbortSignal + timeoutMs?: number +} + +const MODEL_PATHS = ["models", "v1/models"] as const +const DEFAULT_TIMEOUT_MS = 5_000 + +export function parseDiscoveredModels(input: unknown): DiscoveredModel[] { + if (!input || typeof input !== "object") return [] + + const value = input as { data?: unknown; models?: unknown } + const items = Array.isArray(value.data) ? value.data : Array.isArray(value.models) ? value.models : [] + const seen = new Set() + + return items.flatMap((item) => { + if (!item || typeof item !== "object") return [] + + const entry = item as { id?: unknown; name?: unknown } + const id = typeof entry.id === "string" ? entry.id.trim() : "" + if (!id || seen.has(id)) return [] + + seen.add(id) + const name = typeof entry.name === "string" && entry.name.trim() ? entry.name.trim() : id + + return [{ id, name }] + }) +} + +export async function discoverCustomProviderModels(input: DiscoverArgs): Promise { + const fetcher = input.fetcher ?? fetch + const headers = new Headers(input.headers ?? {}) + const apiKey = input.apiKey?.trim() + + if (apiKey && !headers.has("authorization")) { + const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim() + if (!env) headers.set("Authorization", `Bearer ${apiKey}`) + } + + const timeout = createTimeoutSignal(input.signal, input.timeoutMs ?? DEFAULT_TIMEOUT_MS) + + try { + let lastError: unknown + + for (const path of MODEL_PATHS) { + const url = new URL(path, `${input.baseURL.replace(/\/+$/, "")}/`).toString() + + try { + if (input.fetchJson) { + const response = await input.fetchJson(url, { + method: "GET", + headers: Object.fromEntries(headers.entries()), + timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + }) + if (!response.ok) { + lastError = new Error(`Failed to fetch models from ${url}: ${response.status}`) + continue + } + + const models = parseDiscoveredModels(response.data) + if (models.length > 0) return models + + lastError = new Error(`No models returned from ${url}`) + continue + } + + const response = await fetcher(url, { + headers, + signal: timeout.signal, + }) + + if (!response.ok) { + lastError = new Error(`Failed to fetch models from ${url}: ${response.status}`) + continue + } + + const models = parseDiscoveredModels(await response.json()) + if (models.length > 0) return models + + lastError = new Error(`No models returned from ${url}`) + } catch (error) { + if (isAbortError(error)) throw error + lastError = error + } + } + + if (lastError instanceof Error) throw lastError + throw new Error("Failed to fetch models") + } finally { + timeout.clear() + } +} + +function createTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + + if (signal) { + if (signal.aborted) { + controller.abort() + } else { + signal.addEventListener( + "abort", + () => { + controller.abort() + }, + { once: true }, + ) + } + } + + return { + signal: controller.signal, + clear: () => clearTimeout(timer), + } +} + +function isAbortError(error: unknown) { + return !!error && typeof error === "object" && "name" in error && error.name === "AbortError" +} diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index ad30236b0410..d1722be45493 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -3,16 +3,19 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" import { IconButton } from "@opencode-ai/ui/icon-button" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { Spinner } from "@opencode-ai/ui/spinner" import { useMutation } from "@tanstack/solid-query" import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@opencode-ai/ui/toast" -import { batch, For } from "solid-js" +import { batch, createEffect, For, onCleanup, Show } from "solid-js" import { createStore, produce } from "solid-js/store" import { Link } from "@/components/link" +import { usePlatform } from "@/context/platform" import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form" +import { discoverCustomProviderModels } from "./dialog-custom-provider-discovery" import { DialogSelectProvider } from "./dialog-select-provider" type Props = { @@ -23,6 +26,7 @@ export function DialogCustomProvider(props: Props) { const dialog = useDialog() const serverSync = useServerSync() const serverSDK = useServerSDK() + const platform = usePlatform() const language = useLanguage() const [form, setForm] = createStore({ @@ -34,6 +38,22 @@ export function DialogCustomProvider(props: Props) { headers: [headerRow()], err: {}, }) + const [state, setState] = createStore({ + discovering: false, + discoveryError: "", + manualModels: false, + lastDiscoveredBaseURL: "", + }) + + let discoveryTimer: ReturnType | undefined + let discoveryAbort: AbortController | undefined + + onCleanup(() => { + if (discoveryTimer !== undefined) clearTimeout(discoveryTimer) + discoveryTimer = undefined + discoveryAbort?.abort() + discoveryAbort = undefined + }) const goBack = () => { if (props.back === "close") { @@ -44,6 +64,8 @@ export function DialogCustomProvider(props: Props) { } const addModel = () => { + setState("manualModels", true) + setState("discoveryError", "") setForm( "models", produce((rows) => { @@ -54,6 +76,8 @@ export function DialogCustomProvider(props: Props) { const removeModel = (index: number) => { if (form.models.length <= 1) return + setState("manualModels", true) + setState("discoveryError", "") setForm( "models", produce((rows) => { @@ -82,12 +106,23 @@ export function DialogCustomProvider(props: Props) { } const setField = (key: "providerID" | "name" | "baseURL" | "apiKey", value: string) => { + if (key === "baseURL" || key === "apiKey") { + cancelDiscovery() + } setForm(key, value) - if (key === "apiKey") return + if (key === "apiKey") { + setState("discoveryError", "") + return + } + if (key === "baseURL") { + setState("discoveryError", "") + } setForm("err", key, undefined) } const setModel = (index: number, key: "id" | "name", value: string) => { + setState("manualModels", true) + setState("discoveryError", "") batch(() => { setForm("models", index, key, value) setForm("models", index, "err", key, undefined) @@ -95,12 +130,117 @@ export function DialogCustomProvider(props: Props) { } const setHeader = (index: number, key: "key" | "value", value: string) => { + cancelDiscovery() + setState("discoveryError", "") batch(() => { setForm("headers", index, key, value) setForm("headers", index, "err", key, undefined) }) } + const cancelDiscovery = () => { + if (discoveryTimer !== undefined) clearTimeout(discoveryTimer) + discoveryTimer = undefined + discoveryAbort?.abort() + discoveryAbort = undefined + } + + const buildHeaders = () => { + const headers = new Headers() + for (const row of form.headers) { + const key = row.key.trim() + const value = row.value.trim() + if (!key || !value) continue + headers.set(key, value) + } + + const apiKey = form.apiKey.trim() + if (apiKey && !headers.has("authorization")) { + const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim() + if (!env) headers.set("Authorization", `Bearer ${apiKey}`) + } + + return headers + } + + const applyModels = (models: Array<{ id: string; name: string }>) => { + setForm( + "models", + models.map((model) => ({ + ...modelRow(), + id: model.id, + name: model.name, + })), + ) + } + + const toErrorMessage = (error: unknown) => { + if (error instanceof Error && error.message) return error.message + return String(error) + } + + const discoverModels = async (opts?: { force?: boolean; notify?: boolean }) => { + const baseURL = form.baseURL.trim() + if (!baseURL) return false + if (!opts?.force && state.manualModels && state.lastDiscoveredBaseURL === baseURL) return false + + cancelDiscovery() + const controller = new AbortController() + discoveryAbort = controller + setState("discovering", true) + setState("discoveryError", "") + + try { + const models = await discoverCustomProviderModels({ + baseURL, + apiKey: form.apiKey.trim(), + headers: Object.fromEntries(buildHeaders().entries()), + fetcher: platform.fetch ?? fetch, + fetchJson: platform.fetchJson, + signal: controller.signal, + }) + + if (controller.signal.aborted) return false + + batch(() => { + applyModels(models) + setState("manualModels", false) + setState("lastDiscoveredBaseURL", baseURL) + }) + return true + } catch (error) { + if (controller.signal.aborted) return false + const message = toErrorMessage(error) + setState("discoveryError", message) + if (opts?.notify) { + showToast({ title: language.t("common.requestFailed"), description: message }) + } + return false + } finally { + if (discoveryAbort === controller) { + discoveryAbort = undefined + setState("discovering", false) + } + } + } + + createEffect(() => { + const baseURL = form.baseURL.trim() + if (!baseURL) return + if (state.manualModels && state.lastDiscoveredBaseURL === baseURL) return + + if (discoveryTimer !== undefined) clearTimeout(discoveryTimer) + discoveryTimer = setTimeout(() => { + discoveryTimer = undefined + void discoverModels() + }, 350) + + onCleanup(() => { + if (discoveryTimer !== undefined) clearTimeout(discoveryTimer) + discoveryTimer = undefined + }) + }) + const validate = () => { const output = validateCustomProvider({ form, @@ -152,10 +292,15 @@ export function DialogCustomProvider(props: Props) { }, })) - const save = (e: SubmitEvent) => { + const save = async (e: SubmitEvent) => { e.preventDefault() if (saveMutation.isPending) return + if (!state.manualModels) { + const discovered = await discoverModels({ force: true, notify: true }) + if (!discovered) return + } + const result = validate() if (!result) return saveMutation.mutate(result) @@ -211,6 +356,7 @@ export function DialogCustomProvider(props: Props) { setField("baseURL", v)} validationState={form.err.baseURL ? "invalid" : undefined} @@ -226,7 +372,29 @@ export function DialogCustomProvider(props: Props) {
- +
+ +
+ + + + {language.t("common.loading")} + + + +
+
+ +
{state.discoveryError}
+
{(m, i) => (
diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index ffd85f97dce1..f68837055773 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -4,7 +4,9 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Tag } from "@opencode-ai/ui/tag" import { showToast } from "@opencode-ai/ui/toast" import { popularProviders, useProviders } from "@/hooks/use-providers" +import { decode64 } from "@/utils/base64" import { createMemo, type Component, For, Show } from "solid-js" +import { useParams } from "@solidjs/router" import { useLanguage } from "@/context/language" import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" @@ -33,6 +35,18 @@ export const SettingsProviders: Component = () => { const serverSDK = useServerSDK() const serverSync = useServerSync() const providers = useProviders() + const params = useParams() + const directory = createMemo(() => decode64(params.dir) ?? "") + + const currentConfig = createMemo(() => { + if (!directory()) return serverSync.data.config + return serverSync.child(directory())[0].config + }) + + const currentClient = createMemo(() => { + if (!directory()) return + return serverSDK.createClient({ directory: directory(), throwOnError: true }) + }) const connected = createMemo(() => { return providers @@ -69,12 +83,14 @@ export const SettingsProviders: Component = () => { return language.t("settings.providers.tag.other") } + const isCustomProvider = (item: ProviderItem) => source(item) === "custom" || isConfigCustom(item.id) + const canDisconnect = (item: ProviderItem) => source(item) !== "env" const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key const isConfigCustom = (providerID: string) => { - const provider = serverSync.data.config.provider?.[providerID] + const provider = currentConfig().provider?.[providerID] if (!provider) return false if (provider.npm !== "@ai-sdk/openai-compatible") return false if (!provider.models || Object.keys(provider.models).length === 0) return false @@ -104,9 +120,28 @@ export const SettingsProviders: Component = () => { } const disconnect = async (providerID: string, name: string) => { - if (isConfigCustom(providerID)) { + const item = connected().find((entry) => entry.id === providerID) + if (item && isCustomProvider(item)) { await serverSDK.client.auth.remove({ providerID }).catch(() => undefined) - await disableProvider(providerID, name) + const client = currentClient() + const remove = client + ? client.config.provider.remove({ providerID }) + : serverSDK.client.global.config.provider.remove({ providerID }) + + await remove + .then(async () => { + if (client) await serverSDK.client.global.dispose() + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }), + description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }), + }) + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("common.requestFailed"), description: message }) + }) return } await serverSDK.client.auth @@ -163,7 +198,7 @@ export const SettingsProviders: Component = () => { } >
diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 79e2503b1e86..081465ca8d0e 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -69,6 +69,13 @@ export type Platform = { /** Fetch override */ fetch?: typeof fetch + /** JSON request override for desktop-only cross-origin calls */ + fetchJson?: (url: string, init?: { method?: string; headers?: Record; body?: string; timeoutMs?: number }) => Promise<{ + ok: boolean + status: number + data: unknown + }> + /** Get the configured default server URL (platform-specific) */ getDefaultServer?(): Promise diff --git a/packages/app/src/custom-elements.d.ts b/packages/app/src/custom-elements.d.ts index e4ea0d6cebda..d4b0371d1a09 120000 --- a/packages/app/src/custom-elements.d.ts +++ b/packages/app/src/custom-elements.d.ts @@ -1 +1,11 @@ -../../ui/src/custom-elements.d.ts \ No newline at end of file +import { DIFFS_TAG_NAME } from "@pierre/diffs" + +declare module "solid-js" { + namespace JSX { + interface IntrinsicElements { + [DIFFS_TAG_NAME]: HTMLAttributes + } + } +} + +export {} \ No newline at end of file diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 64258454ef17..d8b104339989 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -159,7 +159,7 @@ export const dict = { "provider.connect.toast.connected.description": "{{provider}} models are now available to use.", "provider.custom.title": "Custom provider", - "provider.custom.description.prefix": "Configure an OpenAI-compatible provider. See the ", + "provider.custom.description.prefix": "Configure an OpenAI-compatible provider. Enter its endpoint and the app will try to discover models automatically. See the ", "provider.custom.description.link": "provider config docs", "provider.custom.description.suffix": ".", "provider.custom.field.providerID.label": "Provider ID", @@ -167,8 +167,9 @@ export const dict = { "provider.custom.field.providerID.description": "Lowercase letters, numbers, hyphens, or underscores", "provider.custom.field.name.label": "Display name", "provider.custom.field.name.placeholder": "My AI Provider", - "provider.custom.field.baseURL.label": "Base URL", + "provider.custom.field.baseURL.label": "API endpoint", "provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1", + "provider.custom.field.baseURL.description": "The app will try /models and /v1/models to discover models automatically.", "provider.custom.field.apiKey.label": "API key", "provider.custom.field.apiKey.placeholder": "API key", "provider.custom.field.apiKey.description": "Optional. Leave empty if you manage auth via headers.", @@ -179,6 +180,7 @@ export const dict = { "provider.custom.models.name.placeholder": "Display Name", "provider.custom.models.remove": "Remove model", "provider.custom.models.add": "Add model", + "provider.custom.models.refresh": "Detect models", "provider.custom.headers.label": "Headers (optional)", "provider.custom.headers.key.label": "Header", "provider.custom.headers.key.placeholder": "Header-Name", @@ -889,7 +891,7 @@ export const dict = { "settings.providers.connected.empty": "No connected providers", "settings.providers.connected.environmentDescription": "Connected from your environment variables", "settings.providers.section.popular": "Popular providers", - "settings.providers.custom.description": "Add an OpenAI-compatible provider by base URL.", + "settings.providers.custom.description": "Add an OpenAI-compatible provider by API endpoint.", "settings.providers.tag.environment": "Environment", "settings.providers.tag.config": "Config", "settings.providers.tag.custom": "Custom", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index f5c962a990c4..ac9ccf7df15b 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -178,7 +178,7 @@ export const dict = { "provider.connect.toast.connected.description": "现在可以使用 {{provider}} 模型了。", "provider.custom.title": "自定义提供商", - "provider.custom.description.prefix": "配置与 OpenAI 兼容的提供商。请查看", + "provider.custom.description.prefix": "配置与 OpenAI 兼容的提供商。输入端点后,应用会自动尝试发现模型。请查看", "provider.custom.description.link": "提供商配置文档", "provider.custom.description.suffix": "。", "provider.custom.field.providerID.label": "提供商 ID", @@ -186,8 +186,9 @@ export const dict = { "provider.custom.field.providerID.description": "使用小写字母、数字、连字符或下划线", "provider.custom.field.name.label": "显示名称", "provider.custom.field.name.placeholder": "我的 AI 提供商", - "provider.custom.field.baseURL.label": "基础 URL", + "provider.custom.field.baseURL.label": "API 端点", "provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1", + "provider.custom.field.baseURL.description": "应用会尝试 /models 和 /v1/models 来自动发现模型。", "provider.custom.field.apiKey.label": "API 密钥", "provider.custom.field.apiKey.placeholder": "API 密钥", "provider.custom.field.apiKey.description": "可选。如果你通过请求头管理认证,可留空。", @@ -198,6 +199,7 @@ export const dict = { "provider.custom.models.name.placeholder": "显示名称", "provider.custom.models.remove": "移除模型", "provider.custom.models.add": "添加模型", + "provider.custom.models.refresh": "检测模型", "provider.custom.headers.label": "请求头(可选)", "provider.custom.headers.key.label": "请求头", "provider.custom.headers.key.placeholder": "Header-Name", @@ -852,7 +854,7 @@ export const dict = { "common.time.hoursAgo.short": "{{count}}小时前", "common.time.daysAgo.short": "{{count}}天前", "settings.providers.connected.environmentDescription": "已通过环境变量连接", - "settings.providers.custom.description": "通过基础 URL 添加与 OpenAI 兼容的提供商。", + "settings.providers.custom.description": "通过 API 端点添加与 OpenAI 兼容的提供商。", "app.server.unreachable": "无法连接到 {{server}}", "app.server.retrying": "正在自动重试...", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index edd6f7bc0647..9a95422fe193 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -157,7 +157,7 @@ export const dict = { "provider.connect.toast.connected.description": "現在可以使用 {{provider}} 模型了。", "provider.custom.title": "自訂提供商", - "provider.custom.description.prefix": "設定與 OpenAI 相容的提供商。請參閱", + "provider.custom.description.prefix": "設定與 OpenAI 相容的提供商。輸入端點後,應用程式會自動嘗試探索模型。請參閱", "provider.custom.description.link": "提供商設定文件", "provider.custom.description.suffix": "。", "provider.custom.field.providerID.label": "提供商 ID", @@ -165,8 +165,9 @@ export const dict = { "provider.custom.field.providerID.description": "使用小寫字母、數字、連字號或底線", "provider.custom.field.name.label": "顯示名稱", "provider.custom.field.name.placeholder": "我的 AI 提供商", - "provider.custom.field.baseURL.label": "基礎 URL", + "provider.custom.field.baseURL.label": "API 端點", "provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1", + "provider.custom.field.baseURL.description": "應用程式會嘗試 /models 和 /v1/models 來自動探索模型。", "provider.custom.field.apiKey.label": "API 金鑰", "provider.custom.field.apiKey.placeholder": "API 金鑰", "provider.custom.field.apiKey.description": "選填。若您透過標頭管理驗證,可留空。", @@ -177,6 +178,7 @@ export const dict = { "provider.custom.models.name.placeholder": "顯示名稱", "provider.custom.models.remove": "移除模型", "provider.custom.models.add": "新增模型", + "provider.custom.models.refresh": "偵測模型", "provider.custom.headers.label": "標頭(選填)", "provider.custom.headers.key.label": "標頭", "provider.custom.headers.key.placeholder": "Header-Name", @@ -840,7 +842,7 @@ export const dict = { "common.time.hoursAgo.short": "{{count}}小時前", "common.time.daysAgo.short": "{{count}}天前", "settings.providers.connected.environmentDescription": "已從環境變數連線", - "settings.providers.custom.description": "透過基本 URL 新增與 OpenAI 相容的提供者。", + "settings.providers.custom.description": "透過 API 端點新增與 OpenAI 相容的提供者。", "app.server.unreachable": "無法連線至 {{server}}", "app.server.retrying": "正在自動重試...", diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index a1bdfa3ddfe7..b50e81743508 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -78,6 +78,33 @@ export function registerIpcHandlers(deps: Deps) { ipcMain.handle("record-fatal-renderer-error", (_event: IpcMainInvokeEvent, error: FatalRendererError) => deps.recordFatalRendererError(error), ) + ipcMain.handle( + "fetch-json", + async ( + _event: IpcMainInvokeEvent, + url: string, + init?: { method?: string; headers?: Record; body?: string; timeoutMs?: number }, + ) => { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), init?.timeoutMs ?? 5000) + try { + const response = await fetch(url, { + method: init?.method, + headers: init?.headers, + body: init?.body, + signal: controller.signal, + }) + const text = await response.text() + return { + ok: response.ok, + status: response.status, + data: text ? JSON.parse(text) : null, + } + } finally { + clearTimeout(timeout) + } + }, + ) ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => { try { const store = getStore(name) diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index f6d83a270fe5..d760ba6feb45 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -81,6 +81,7 @@ const api: ElectronAPI = { setBackgroundColor: (color: string) => ipcRenderer.invoke("set-background-color", color), exportDebugLogs: () => ipcRenderer.invoke("export-debug-logs"), recordFatalRendererError: (error) => ipcRenderer.invoke("record-fatal-renderer-error", error), + fetchJson: (url, init) => ipcRenderer.invoke("fetch-json", url, init), } contextBridge.exposeInMainWorld("api", api) diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index 081659d2b6b7..c6c106ed00b5 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -92,4 +92,9 @@ export type ElectronAPI = { setBackgroundColor: (color: string) => Promise exportDebugLogs: () => Promise recordFatalRendererError: (error: FatalRendererError) => Promise + fetchJson: (url: string, init?: { method?: string; headers?: Record; body?: string; timeoutMs?: number }) => Promise<{ + ok: boolean + status: number + data: unknown + }> } diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index 3ccd34596e02..73b3982a2b4e 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -246,6 +246,10 @@ const createPlatform = (): Platform => { return fetch(input, init) }, + fetchJson: (url, init) => { + return window.api.fetchJson(url, init) + }, + getWslEnabled: () => isWslEnabled(), setWslEnabled: async (enabled) => { diff --git a/packages/enterprise/src/custom-elements.d.ts b/packages/enterprise/src/custom-elements.d.ts index e4ea0d6cebda..d4b0371d1a09 120000 --- a/packages/enterprise/src/custom-elements.d.ts +++ b/packages/enterprise/src/custom-elements.d.ts @@ -1 +1,11 @@ -../../ui/src/custom-elements.d.ts \ No newline at end of file +import { DIFFS_TAG_NAME } from "@pierre/diffs" + +declare module "solid-js" { + namespace JSX { + interface IntrinsicElements { + [DIFFS_TAG_NAME]: HTMLAttributes + } + } +} + +export {} \ No newline at end of file diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index 09c2d64b00dc..030599d8eba3 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -101,7 +101,7 @@ export function DialogModel(props: { providerID?: string }) { const popularProviders = !connected() ? pipe( - providers(), + providers.options(), map((option) => ({ ...option, category: "Popular providers", 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 2caa67b559ac..76e921136f1c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -1,9 +1,10 @@ -import { createMemo, createSignal, onMount, Show } from "solid-js" +import { createEffect, createMemo, createSignal, onMount, on, Show } from "solid-js" import { useSync } from "@tui/context/sync" import { map, pipe, sortBy } from "remeda" import { DialogSelect } from "@tui/ui/dialog-select" import { useDialog } from "@tui/ui/dialog" import { useSDK } from "../context/sdk" +import { useProject } from "../context/project" import { DialogPrompt } from "../ui/dialog-prompt" import { Link } from "../ui/link" import { useTheme } from "../context/theme" @@ -14,7 +15,7 @@ import * as Clipboard from "@tui/util/clipboard" import { useToast } from "../ui/toast" import { isConsoleManagedProvider } from "@tui/util/provider-origin" import { useConnected } from "./use-connected" -import { useBindings } from "../keymap" +import { useBindings, useCommandShortcut } from "../keymap" const PROVIDER_PRIORITY: Record = { opencode: 0, @@ -27,6 +28,7 @@ const PROVIDER_PRIORITY: Record = { const CUSTOM_PROVIDER_OPTION_VALUE = "__opencode_custom_provider__" const CUSTOM_PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/ +const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible" type ProviderOptionBase = { title: string @@ -83,9 +85,45 @@ export function createDialogProviderOptions() { const sync = useSync() const dialog = useDialog() const sdk = useSDK() + const project = useProject() const toast = useToast() const { theme } = useTheme() const onboarded = useConnected() + const [toDelete, setToDelete] = createSignal() + const [selectedProviderID, setSelectedProviderID] = createSignal() + const deleteHint = useCommandShortcut("provider.delete") + const deleteLabel = createMemo(() => deleteHint() || "ctrl+d") + + const isConfigCustom = (providerID: string) => { + const provider = sync.data.config.provider?.[providerID] + if (!provider) return false + if (provider.npm !== OPENAI_COMPATIBLE) return false + if (!provider.models || Object.keys(provider.models).length === 0) return false + return true + } + + const deletableProviderID = createMemo(() => { + const providerID = selectedProviderID() + if (!providerID) return + if (!isConfigCustom(providerID)) return + return providerID + }) + + createEffect( + on( + () => sync.data.provider_next.all, + (all) => { + if (selectedProviderID() && all.some((provider) => provider.id === selectedProviderID())) return + const [first] = providerOptions(all) + setSelectedProviderID(first?.type === "provider" ? first.providerID : undefined) + }, + { defer: true }, + ), + ) + + createEffect(() => { + if (toDelete() && !isConfigCustom(toDelete()!)) setToDelete(undefined) + }) async function promptCustomProviderID(): Promise { const value = await DialogPrompt.show(dialog, "Other", { @@ -109,6 +147,152 @@ export function createDialogProviderOptions() { return promptCustomProviderID() } + async function promptCustomProviderEndpoint(): Promise { + const value = await DialogPrompt.show(dialog, "Endpoint", { + placeholder: "https://api.example.com/v1", + description: () => ( + OpenCode will try /models and /v1/models to discover models automatically. + ), + }) + if (value === null) return + + const endpoint = normalizeCustomProviderEndpoint(value) + if (endpoint) return endpoint + + toast.show({ + variant: "error", + message: "Endpoint must start with http:// or https://", + }) + return promptCustomProviderEndpoint() + } + + async function promptCustomProviderApiKey(): Promise { + const value = await DialogPrompt.show(dialog, "API key", { + placeholder: "Optional API key", + description: () => Leave empty if this endpoint does not require a bearer token., + }) + if (value === null) return + return value.trim() + } + + async function connectCustomProvider() { + const providerID = await promptCustomProviderID() + if (!providerID) return + + const endpoint = await promptCustomProviderEndpoint() + if (!endpoint) return + + const apiKey = await promptCustomProviderApiKey() + if (apiKey === undefined) return + + try { + const models = await discoverCustomProviderModels({ + baseURL: endpoint, + apiKey, + }) + + const existingProvider = sync.data.config.provider?.[providerID] + const config = { + ...sync.data.config, + provider: { + ...(sync.data.config.provider ?? {}), + [providerID]: { + ...existingProvider, + name: existingProvider?.name ?? providerID, + npm: OPENAI_COMPATIBLE, + options: { + ...(existingProvider?.options ?? {}), + baseURL: endpoint, + }, + models, + }, + }, + disabled_providers: (sync.data.config.disabled_providers ?? []).filter((item) => item !== providerID), + } + + const workspace = project.workspace.current() + const result = await sdk.client.config.update({ workspace, config }) + if (result.error) { + toast.show({ + variant: "error", + message: JSON.stringify(result.error), + }) + dialog.clear() + return + } + + if (apiKey) { + const auth = await sdk.client.auth.set({ + providerID, + auth: { + type: "api", + key: apiKey, + }, + }) + if (auth.error) { + toast.show({ + variant: "error", + message: JSON.stringify(auth.error), + }) + dialog.clear() + return + } + } + + await sdk.client.instance.dispose() + await sync.bootstrap({ fatal: false }) + const refreshed = await sdk.client.config.providers({ workspace }) + const available = (refreshed.data?.providers ?? []).some((provider) => provider.id === providerID) + + if (!available) { + toast.show({ + variant: "info", + message: `Saved ${providerID}, but it is not available yet. Check the endpoint and model list.`, + }) + dialog.clear() + return + } + + dialog.replace(() => ) + } catch (error) { + toast.show({ + variant: "error", + message: error instanceof Error ? error.message : String(error), + }) + dialog.clear() + } + } + + async function deleteCustomProvider(providerID: string) { + try { + const workspace = project.workspace.current() + const result = await sdk.client.config.provider.remove({ workspace, providerID }) + if (result.error) { + toast.show({ + variant: "error", + message: JSON.stringify(result.error), + }) + setToDelete(undefined) + return + } + + await sdk.client.auth.remove({ providerID }).catch(() => undefined) + await sdk.client.instance.dispose() + await sync.bootstrap({ fatal: false }) + setToDelete(undefined) + toast.show({ + variant: "success", + message: `Deleted ${providerID}`, + }) + } catch (error) { + setToDelete(undefined) + toast.show({ + variant: "error", + message: error instanceof Error ? error.message : String(error), + }) + } + } + const options = createMemo(() => { return pipe( providerOptions(sync.data.provider_next.all), @@ -120,9 +304,7 @@ export function createDialogProviderOptions() { description: provider.description, category: provider.category, async onSelect() { - const providerID = await promptCustomProviderID() - if (!providerID) return - return dialog.replace(() => ) + await connectCustomProvider() }, } } @@ -130,10 +312,12 @@ export function createDialogProviderOptions() { const providerID = provider.providerID const consoleManaged = isConsoleManagedProvider(sync.data.console_state.consoleManagedProviders, providerID) const connected = sync.data.provider_next.connected.includes(providerID) + const deleting = toDelete() === providerID && isConfigCustom(providerID) return { - title: provider.title, + title: deleting ? `Press ${deleteLabel()} again to confirm` : provider.title, value: provider.value, + bg: deleting ? theme.error : undefined, description: provider.description, footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined, category: provider.category, @@ -218,12 +402,105 @@ export function createDialogProviderOptions() { }), ) }) - return options + return { + options, + toDelete, + setToDelete, + isConfigCustom, + deletableProviderID, + deleteCustomProvider, + setSelectedProviderID, + } } export function DialogProvider() { const options = createDialogProviderOptions() - return + return ( + { + options.setSelectedProviderID(option.value === CUSTOM_PROVIDER_OPTION_VALUE ? undefined : option.value) + options.setToDelete(undefined) + }} + actions={[ + { + command: "provider.delete", + title: "delete", + disabled: !options.deletableProviderID(), + onTrigger: async (option) => { + if (option.value === CUSTOM_PROVIDER_OPTION_VALUE) return + if (!options.isConfigCustom(option.value)) return + if (options.toDelete() === option.value) { + await options.deleteCustomProvider(option.value) + return + } + options.setToDelete(option.value) + }, + }, + ]} + /> + ) +} + +function normalizeCustomProviderEndpoint(value: string) { + const endpoint = value.trim().replace(/\/+$/, "") + if (!endpoint) return + if (!/^https?:\/\//.test(endpoint)) return + return endpoint +} + +async function discoverCustomProviderModels(input: { baseURL: string; apiKey?: string }) { + const headers = new Headers() + if (input.apiKey) headers.set("Authorization", `Bearer ${input.apiKey}`) + + let lastError: unknown + for (const path of ["models", "v1/models"]) { + const url = new URL(path, `${input.baseURL}/`).toString() + try { + const response = await fetch(url, { + headers, + signal: AbortSignal.timeout(5_000), + }) + if (!response.ok) { + lastError = new Error(`Failed to fetch models from ${url}: ${response.status}`) + continue + } + + const body = await response.json() + const models = parseCustomProviderModels(body) + if (Object.keys(models).length > 0) return models + lastError = new Error(`No models returned from ${url}`) + } catch (error) { + lastError = error + } + } + + if (lastError instanceof Error) throw lastError + throw new Error("Failed to discover models") +} + +function parseCustomProviderModels(input: unknown) { + if (!input || typeof input !== "object") throw new Error("Invalid model discovery response") + + const value = input as { data?: unknown; models?: unknown } + const list = Array.isArray(value.data) ? value.data : Array.isArray(value.models) ? value.models : [] + const result: Record = {} + + for (const item of list) { + if (!item || typeof item !== "object") continue + const entry = item as { id?: unknown; name?: unknown } + const id = typeof entry.id === "string" ? entry.id.trim() : "" + if (!id || result[id]) continue + const name = typeof entry.name === "string" && entry.name.trim() ? entry.name.trim() : id + result[id] = { name } + } + + if (Object.keys(result).length === 0) { + throw new Error("No models found at this endpoint") + } + + return result } interface AutoMethodProps { diff --git a/packages/opencode/src/cli/cmd/tui/config/keybind.ts b/packages/opencode/src/cli/cmd/tui/config/keybind.ts index c03123aed1c0..bdcae43116f6 100644 --- a/packages/opencode/src/cli/cmd/tui/config/keybind.ts +++ b/packages/opencode/src/cli/cmd/tui/config/keybind.ts @@ -120,6 +120,7 @@ export const Definitions = { model_cycle_favorite_reverse: keybind("none", "Previous favorite model"), mcp_list: keybind("none", "List MCP servers"), provider_connect: keybind("none", "Connect provider"), + provider_delete: keybind("ctrl+d", "Delete custom provider"), console_org_switch: keybind("none", "Switch console organization"), agent_list: keybind("a", "List agents"), agent_cycle: keybind("tab", "Next agent"), @@ -316,6 +317,7 @@ export const CommandMap = { model_cycle_favorite_reverse: "model.cycle_favorite_reverse", mcp_list: "mcp.list", provider_connect: "provider.connect", + provider_delete: "provider.delete", console_org_switch: "console.org.switch", agent_list: "agent.list", agent_cycle: "agent.cycle", diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 307b02ca4d9f..3a8a6ca48925 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -326,7 +326,9 @@ export interface Interface { readonly getGlobal: () => Effect.Effect readonly getConsoleState: () => Effect.Effect readonly update: (config: Info) => Effect.Effect + readonly removeProvider: (providerID: string) => Effect.Effect<{ info: Info; changed: boolean }> readonly updateGlobal: (config: Info) => Effect.Effect<{ info: Info; changed: boolean }> + readonly removeProviderGlobal: (providerID: string) => Effect.Effect<{ info: Info; changed: boolean }> readonly invalidate: () => Effect.Effect readonly directories: () => Effect.Effect readonly waitForDependencies: () => Effect.Effect @@ -346,6 +348,12 @@ function globalConfigFile() { return candidates[0] } +function localConfigCandidate(ctx: InstanceContext, directories: string[]) { + const closestProjectDir = directories.find((dir) => dir.endsWith(".opencode") && containsPath(dir, ctx)) + if (closestProjectDir) return path.join(closestProjectDir, "opencode.jsonc") + return path.join(ctx.directory, ".opencode", "opencode.jsonc") +} + function patchJsonc(input: string, patch: unknown, path: string[] = []): string { if (!isRecord(patch)) { const edits = modify(input, path, patch, { @@ -372,6 +380,42 @@ function writableGlobal(info: Info) { return next } +function removeProviderFromInfo(info: Info, providerID: string) { + const next = { ...info } + const providers = next.provider ? { ...next.provider } : undefined + const hasProvider = !!providers?.[providerID] + if (providers) delete providers[providerID] + + const disabled = next.disabled_providers + const nextDisabled = disabled?.filter((item) => item !== providerID) + const disabledChanged = !!disabled && nextDisabled!.length !== disabled.length + if (!hasProvider && !disabledChanged) return { info, changed: false } + + if (providers) next.provider = providers + if (disabledChanged) next.disabled_providers = nextDisabled + return { info: next, changed: true } +} + +function removeProviderJson(before: string, file: string, providerID: string, global: boolean) { + const parsed = ConfigParse.schema(Info, ConfigParse.jsonc(before, file), file) + const result = removeProviderFromInfo(global ? writableGlobal(parsed) : writable(parsed), providerID) + const serialized = JSON.stringify(result.info, null, 2) + return { ...result, text: serialized, changed: serialized !== before } +} + +function removeProviderJsonc(before: string, file: string, providerID: string, global: boolean) { + const parsed = ConfigParse.schema(Info, ConfigParse.jsonc(before, file), file) + const result = removeProviderFromInfo(global ? writableGlobal(parsed) : writable(parsed), providerID) + if (!result.changed) return { ...result, text: before } + const updated = patchJsonc(before, { + provider: { + [providerID]: undefined, + }, + disabled_providers: result.info.disabled_providers, + }) + return { ...result, text: updated, changed: updated !== before } +} + export const ConfigDirectoryTypoError = NamedError.create("ConfigDirectoryTypoError", { path: Schema.String, dir: Schema.String, @@ -813,13 +857,54 @@ export const layer = Layer.effect( ) }) + const localConfigFile = Effect.fn("Config.localConfigFile")(function* () { + const ctx = yield* InstanceState.context + const files = yield* ConfigPaths.files("opencode", ctx.directory, ctx.worktree).pipe( + Effect.provideService(AppFileSystem.Service, fs), + Effect.orDie, + ) + const closestFile = files.at(-1) + if (closestFile) return closestFile + + const directories = yield* ConfigPaths.directories(ctx.directory, ctx.worktree).pipe( + Effect.provideService(AppFileSystem.Service, fs), + Effect.orDie, + ) + return localConfigCandidate(ctx, directories) + }) + + const existingLocalConfigFile = Effect.fn("Config.existingLocalConfigFile")(function* () { + const file = yield* localConfigFile() + return (yield* fs.existsSafe(file)) ? file : undefined + }) + const update = Effect.fn("Config.update")(function* (config: Info) { - const dir = yield* InstanceState.directory - const file = path.join(dir, "config.json") - const existing = yield* loadFile(file) - yield* fs - .writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2)) - .pipe(Effect.orDie) + const file = yield* localConfigFile() + const before = (yield* readConfigFile(file)) ?? "{}" + const patch = writable(config) + + if (!file.endsWith(".jsonc")) { + const existing = ConfigParse.schema(Info, ConfigParse.jsonc(before, file), file) + const merged = mergeDeep(writable(existing), patch) + const serialized = JSON.stringify(merged, null, 2) + yield* fs.writeWithDirs(file, serialized).pipe(Effect.orDie) + return + } + + const updated = patchJsonc(before, patch) + yield* fs.writeWithDirs(file, updated).pipe(Effect.orDie) + }) + + const removeProvider = Effect.fn("Config.removeProvider")(function* (providerID: string) { + const file = yield* existingLocalConfigFile() + if (!file) return { info: yield* get(), changed: false } + + const before = (yield* readConfigFile(file)) ?? "{}" + const result = file.endsWith(".jsonc") + ? removeProviderJsonc(before, file, providerID, false) + : removeProviderJson(before, file, providerID, false) + if (result.changed) yield* fs.writeWithDirs(file, result.text).pipe(Effect.orDie) + return { info: result.info, changed: result.changed } }) const invalidate = Effect.fn("Config.invalidate")(function* () { @@ -851,12 +936,25 @@ export const layer = Layer.effect( return { info: next, changed } }) + const removeProviderGlobal = Effect.fn("Config.removeProviderGlobal")(function* (providerID: string) { + const file = globalConfigFile() + const before = (yield* readConfigFile(file)) ?? "{}" + const result = file.endsWith(".jsonc") + ? removeProviderJsonc(before, file, providerID, true) + : removeProviderJson(before, file, providerID, true) + if (result.changed) yield* fs.writeFileString(file, result.text).pipe(Effect.orDie) + if (result.changed) yield* invalidate() + return { info: result.info, changed: result.changed } + }) + return Service.of({ get, getGlobal, getConsoleState, update, + removeProvider, updateGlobal, + removeProviderGlobal, invalidate, directories, waitForDependencies, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts index a86845beff30..54e49c4c1351 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts @@ -1,5 +1,6 @@ import { Config } from "@/config/config" import { Provider } from "@/provider/provider" +import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" @@ -7,6 +8,9 @@ import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware import { described } from "./metadata" const root = "/config" +const ConfigProviderParams = Schema.Struct({ + providerID: Schema.String, +}) export const ConfigApi = HttpApi.make("config") .add( @@ -34,6 +38,18 @@ export const ConfigApi = HttpApi.make("config") description: "Update OpenCode configuration settings and preferences.", }), ), + HttpApiEndpoint.delete("removeProvider", `${root}/provider/:providerID`, { + query: WorkspaceRoutingQuery, + params: ConfigProviderParams, + success: described(Config.Info, "Successfully removed provider from config"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "config.provider.remove", + summary: "Remove configured provider", + description: "Remove a configured provider from the current OpenCode configuration.", + }), + ), HttpApiEndpoint.get("providers", `${root}/providers`, { query: WorkspaceRoutingQuery, success: described(Provider.ConfigProvidersResult, "List of providers"), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts index 75441b4ca4a3..0aa4d9181269 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts @@ -6,6 +6,10 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { described } from "./metadata" +const GlobalConfigProviderParams = Schema.Struct({ + providerID: Schema.String, +}) + const GlobalHealth = Schema.Struct({ healthy: Schema.Literal(true), version: Schema.String, @@ -82,6 +86,17 @@ export const GlobalApi = HttpApi.make("global").add( description: "Update global OpenCode configuration settings and preferences.", }), ), + HttpApiEndpoint.delete("configProviderRemove", `${GlobalPaths.config}/provider/:providerID`, { + params: GlobalConfigProviderParams, + success: described(Config.Info, "Successfully removed provider from global config"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "global.config.provider.remove", + summary: "Remove global provider configuration", + description: "Remove a configured provider from the global OpenCode configuration.", + }), + ), HttpApiEndpoint.post("dispose", GlobalPaths.dispose, { success: described(Schema.Boolean, "Global disposed"), }).annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts index 3d0e8a06c09c..7f8cafb92d55 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts @@ -21,6 +21,12 @@ export const configHandlers = HttpApiBuilder.group(InstanceHttpApi, "config", (h return ctx.payload }) + const removeProvider = Effect.fn("ConfigHttpApi.removeProvider")(function* (ctx) { + const result = yield* configSvc.removeProvider(ctx.params.providerID) + if (result.changed) yield* markInstanceForDisposal(yield* InstanceState.context) + return result.info + }) + const providers = Effect.fn("ConfigHttpApi.providers")(function* () { const providers = yield* providerSvc.list() return { @@ -29,6 +35,6 @@ export const configHandlers = HttpApiBuilder.group(InstanceHttpApi, "config", (h } }) - return handlers.handle("get", get).handle("update", update).handle("providers", providers) + return handlers.handle("get", get).handle("update", update).handle("removeProvider", removeProvider).handle("providers", providers) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts index f80869b64d3f..9eaf4b56165a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts @@ -90,6 +90,12 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl return result.info }) + const configProviderRemove = Effect.fn("GlobalHttpApi.configProviderRemove")(function* (ctx) { + const result = yield* config.removeProviderGlobal(ctx.params.providerID) + if (result.changed) bridge.fork(disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true })) + return result.info + }) + const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () { yield* disposeAllInstancesAndEmitGlobalDisposed() return true @@ -151,6 +157,7 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl .handleRaw("event", event) .handle("configGet", configGet) .handle("configUpdate", configUpdate) + .handle("configProviderRemove", configProviderRemove) .handle("dispose", dispose) .handleRaw("upgrade", upgradeRaw) }), diff --git a/packages/opencode/test/fixture/config.ts b/packages/opencode/test/fixture/config.ts index 4cd90c51bf5a..33ae9a9dfe8a 100644 --- a/packages/opencode/test/fixture/config.ts +++ b/packages/opencode/test/fixture/config.ts @@ -8,7 +8,9 @@ export function make(overrides: Partial = {}) { getGlobal: () => Effect.succeed({}), getConsoleState: () => Effect.succeed(emptyConsoleState), update: () => Effect.void, + removeProvider: () => Effect.succeed({ info: {}, changed: false }), updateGlobal: (config) => Effect.succeed({ info: config, changed: false }), + removeProviderGlobal: () => Effect.succeed({ info: {}, changed: false }), invalidate: () => Effect.void, directories: () => Effect.succeed([]), waitForDependencies: () => Effect.void, diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index cd17e70fdf0e..62a7bc5fd992 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -20,6 +20,8 @@ import type { Config as Config3, ConfigGetErrors, ConfigGetResponses, + ConfigProviderRemoveErrors, + ConfigProviderRemoveResponses, ConfigProvidersErrors, ConfigProvidersResponses, ConfigUpdateErrors, @@ -70,6 +72,8 @@ import type { FormatterStatusResponses, GlobalConfigGetErrors, GlobalConfigGetResponses, + GlobalConfigProviderRemoveErrors, + GlobalConfigProviderRemoveResponses, GlobalConfigUpdateErrors, GlobalConfigUpdateResponses, GlobalDisposeErrors, @@ -493,6 +497,31 @@ export class App extends HeyApiClient { } } +export class Provider extends HeyApiClient { + /** + * Remove global provider configuration + * + * Remove a configured provider from the global OpenCode configuration. + */ + public remove( + parameters: { + providerID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "providerID" }] }]) + return (options?.client ?? this.client).delete< + GlobalConfigProviderRemoveResponses, + GlobalConfigProviderRemoveErrors, + ThrowOnError + >({ + url: "/global/config/provider/{providerID}", + ...options, + ...params, + }) + } +} + export class Config extends HeyApiClient { /** * Get global configuration @@ -529,6 +558,11 @@ export class Config extends HeyApiClient { }, }) } + + private _provider?: Provider + get provider(): Provider { + return (this._provider ??= new Provider({ client: this.client })) + } } export class Global extends HeyApiClient { @@ -630,6 +664,44 @@ export class Event extends HeyApiClient { } } +export class Provider2 extends HeyApiClient { + /** + * Remove configured provider + * + * Remove a configured provider from the current OpenCode configuration. + */ + public remove( + parameters: { + providerID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "providerID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete< + ConfigProviderRemoveResponses, + ConfigProviderRemoveErrors, + ThrowOnError + >({ + url: "/config/provider/{providerID}", + ...options, + ...params, + }) + } +} + export class Config2 extends HeyApiClient { /** * Get configuration @@ -727,6 +799,11 @@ export class Config2 extends HeyApiClient { ...params, }) } + + private _provider?: Provider2 + get provider(): Provider2 { + return (this._provider ??= new Provider2({ client: this.client })) + } } export class Console extends HeyApiClient { @@ -2969,7 +3046,7 @@ export class Oauth extends HeyApiClient { } } -export class Provider extends HeyApiClient { +export class Provider3 extends HeyApiClient { /** * List providers * @@ -4488,7 +4565,7 @@ export class Model extends HeyApiClient { } } -export class Provider2 extends HeyApiClient { +export class Provider4 extends HeyApiClient { /** * List v2 providers * @@ -4556,9 +4633,9 @@ export class V2 extends HeyApiClient { return (this._model ??= new Model({ client: this.client })) } - private _provider?: Provider2 - get provider(): Provider2 { - return (this._provider ??= new Provider2({ client: this.client })) + private _provider?: Provider4 + get provider(): Provider4 { + return (this._provider ??= new Provider4({ client: this.client })) } } @@ -5122,9 +5199,9 @@ export class OpencodeClient extends HeyApiClient { return (this._permission ??= new Permission({ client: this.client })) } - private _provider?: Provider - get provider(): Provider { - return (this._provider ??= new Provider({ client: this.client })) + private _provider?: Provider3 + get provider(): Provider3 { + return (this._provider ??= new Provider3({ client: this.client })) } private _session?: Session2 diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index aae1b06ad320..b91bf89464a5 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4025,6 +4025,34 @@ export type GlobalConfigUpdateResponses = { export type GlobalConfigUpdateResponse = GlobalConfigUpdateResponses[keyof GlobalConfigUpdateResponses] +export type GlobalConfigProviderRemoveData = { + body?: never + path: { + providerID: string + } + query?: never + url: "/global/config/provider/{providerID}" +} + +export type GlobalConfigProviderRemoveErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError +} + +export type GlobalConfigProviderRemoveError = GlobalConfigProviderRemoveErrors[keyof GlobalConfigProviderRemoveErrors] + +export type GlobalConfigProviderRemoveResponses = { + /** + * Successfully removed provider from global config + */ + 200: Config +} + +export type GlobalConfigProviderRemoveResponse = + GlobalConfigProviderRemoveResponses[keyof GlobalConfigProviderRemoveResponses] + export type GlobalDisposeData = { body?: never path?: never @@ -4160,6 +4188,36 @@ export type ConfigUpdateResponses = { export type ConfigUpdateResponse = ConfigUpdateResponses[keyof ConfigUpdateResponses] +export type ConfigProviderRemoveData = { + body?: never + path: { + providerID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/config/provider/{providerID}" +} + +export type ConfigProviderRemoveErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError +} + +export type ConfigProviderRemoveError = ConfigProviderRemoveErrors[keyof ConfigProviderRemoveErrors] + +export type ConfigProviderRemoveResponses = { + /** + * Successfully removed provider from config + */ + 200: Config +} + +export type ConfigProviderRemoveResponse = ConfigProviderRemoveResponses[keyof ConfigProviderRemoveResponses] + export type ConfigProvidersData = { body?: never path?: never