import { Button } from "@opencode-ai/ui/button" 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 { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@opencode-ai/ui/toast" import { For } from "solid-js" import { createStore } from "solid-js/store" import { Link } from "@/components/link" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" import { DialogSelectProvider } from "./dialog-select-provider" const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/ const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible" type Translator = ReturnType["t"] type ModelRow = { id: string name: string } type HeaderRow = { key: string value: string } type FormState = { providerID: string name: string baseURL: string apiKey: string models: ModelRow[] headers: HeaderRow[] saving: boolean } type FormErrors = { providerID: string | undefined name: string | undefined baseURL: string | undefined models: Array<{ id?: string; name?: string }> headers: Array<{ key?: string; value?: string }> } type ValidateArgs = { form: FormState t: Translator disabledProviders: string[] existingProviderIDs: Set } function validateCustomProvider(input: ValidateArgs) { const providerID = input.form.providerID.trim() const name = input.form.name.trim() const baseURL = input.form.baseURL.trim() const apiKey = input.form.apiKey.trim() const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim() const key = apiKey && !env ? apiKey : undefined const idError = !providerID ? input.t("provider.custom.error.providerID.required") : !PROVIDER_ID.test(providerID) ? input.t("provider.custom.error.providerID.format") : undefined const nameError = !name ? input.t("provider.custom.error.name.required") : undefined const urlError = !baseURL ? input.t("provider.custom.error.baseURL.required") : !/^https?:\/\//.test(baseURL) ? input.t("provider.custom.error.baseURL.format") : undefined const disabled = input.disabledProviders.includes(providerID) const existsError = idError ? undefined : input.existingProviderIDs.has(providerID) && !disabled ? input.t("provider.custom.error.providerID.exists") : undefined const seenModels = new Set() const modelErrors = input.form.models.map((m) => { const id = m.id.trim() const modelIdError = !id ? input.t("provider.custom.error.required") : seenModels.has(id) ? input.t("provider.custom.error.duplicate") : (() => { seenModels.add(id) return undefined })() const modelNameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined return { id: modelIdError, name: modelNameError } }) const modelsValid = modelErrors.every((m) => !m.id && !m.name) const models = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }])) const seenHeaders = new Set() const headerErrors = input.form.headers.map((h) => { const key = h.key.trim() const value = h.value.trim() if (!key && !value) return {} const keyError = !key ? input.t("provider.custom.error.required") : seenHeaders.has(key.toLowerCase()) ? input.t("provider.custom.error.duplicate") : (() => { seenHeaders.add(key.toLowerCase()) return undefined })() const valueError = !value ? input.t("provider.custom.error.required") : undefined return { key: keyError, value: valueError } }) const headersValid = headerErrors.every((h) => !h.key && !h.value) const headers = Object.fromEntries( input.form.headers .map((h) => ({ key: h.key.trim(), value: h.value.trim() })) .filter((h) => !!h.key && !!h.value) .map((h) => [h.key, h.value]), ) const errors: FormErrors = { providerID: idError ?? existsError, name: nameError, baseURL: urlError, models: modelErrors, headers: headerErrors, } const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid if (!ok) return { errors } const options = { baseURL, ...(Object.keys(headers).length ? { headers } : {}), } return { errors, result: { providerID, name, key, config: { npm: OPENAI_COMPATIBLE, name, ...(env ? { env: [env] } : {}), options, models, }, }, } } type Props = { back?: "providers" | "close" } export function DialogCustomProvider(props: Props) { const dialog = useDialog() const globalSync = useGlobalSync() const globalSDK = useGlobalSDK() const language = useLanguage() const [form, setForm] = createStore({ providerID: "", name: "", baseURL: "", apiKey: "", models: [{ id: "", name: "" }], headers: [{ key: "", value: "" }], saving: false, }) const [errors, setErrors] = createStore({ providerID: undefined, name: undefined, baseURL: undefined, models: [{}], headers: [{}], }) const goBack = () => { if (props.back === "close") { dialog.close() return } dialog.show(() => ) } const addModel = () => { setForm("models", (v) => [...v, { id: "", name: "" }]) setErrors("models", (v) => [...v, {}]) } const removeModel = (index: number) => { if (form.models.length <= 1) return setForm("models", (v) => v.filter((_, i) => i !== index)) setErrors("models", (v) => v.filter((_, i) => i !== index)) } const addHeader = () => { setForm("headers", (v) => [...v, { key: "", value: "" }]) setErrors("headers", (v) => [...v, {}]) } const removeHeader = (index: number) => { if (form.headers.length <= 1) return setForm("headers", (v) => v.filter((_, i) => i !== index)) setErrors("headers", (v) => v.filter((_, i) => i !== index)) } const validate = () => { const output = validateCustomProvider({ form, t: language.t, disabledProviders: globalSync.data.config.disabled_providers ?? [], existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)), }) setErrors(output.errors) return output.result } const save = async (e: SubmitEvent) => { e.preventDefault() if (form.saving) return const result = validate() if (!result) return setForm("saving", true) const disabledProviders = globalSync.data.config.disabled_providers ?? [] const nextDisabled = disabledProviders.filter((id) => id !== result.providerID) const auth = result.key ? globalSDK.client.auth.set({ providerID: result.providerID, auth: { type: "api", key: result.key, }, }) : Promise.resolve() auth .then(() => globalSync.updateConfig({ provider: { [result.providerID]: result.config }, disabled_providers: nextDisabled }), ) .then(() => { dialog.close() showToast({ variant: "success", icon: "circle-check", title: language.t("provider.connect.toast.connected.title", { provider: result.name }), description: language.t("provider.connect.toast.connected.description", { provider: result.name }), }) }) .catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err) showToast({ title: language.t("common.requestFailed"), description: message }) }) .finally(() => { setForm("saving", false) }) } return ( } transition >
{language.t("provider.custom.title")}

{language.t("provider.custom.description.prefix")} {language.t("provider.custom.description.link")} {language.t("provider.custom.description.suffix")}

setForm("providerID", v)} validationState={errors.providerID ? "invalid" : undefined} error={errors.providerID} /> setForm("name", v)} validationState={errors.name ? "invalid" : undefined} error={errors.name} /> setForm("baseURL", v)} validationState={errors.baseURL ? "invalid" : undefined} error={errors.baseURL} /> setForm("apiKey", v)} />
{(m, i) => (
setForm("models", i(), "id", v)} validationState={errors.models[i()]?.id ? "invalid" : undefined} error={errors.models[i()]?.id} />
setForm("models", i(), "name", v)} validationState={errors.models[i()]?.name ? "invalid" : undefined} error={errors.models[i()]?.name} />
removeModel(i())} disabled={form.models.length <= 1} aria-label={language.t("provider.custom.models.remove")} />
)}
{(h, i) => (
setForm("headers", i(), "key", v)} validationState={errors.headers[i()]?.key ? "invalid" : undefined} error={errors.headers[i()]?.key} />
setForm("headers", i(), "value", v)} validationState={errors.headers[i()]?.value ? "invalid" : undefined} error={errors.headers[i()]?.value} />
removeHeader(i())} disabled={form.headers.length <= 1} aria-label={language.t("provider.custom.headers.remove")} />
)}
) }