From 166b9ce06407d45aa711107176321f80adc3b7f0 Mon Sep 17 00:00:00 2001 From: CodeX Assistant <854605104@qq.com> Date: Thu, 30 Apr 2026 21:44:28 +0800 Subject: [PATCH 1/2] Add TUI custom provider setup --- .../dialog-custom-provider-form.test.ts | 78 ++++ .../component/dialog-custom-provider-form.ts | 113 +++++ .../cli/cmd/tui/component/dialog-provider.tsx | 422 +++++++++++++----- 3 files changed, 512 insertions(+), 101 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-custom-provider-form.test.ts create mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-custom-provider-form.ts diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-custom-provider-form.test.ts b/packages/opencode/src/cli/cmd/tui/component/dialog-custom-provider-form.test.ts new file mode 100644 index 000000000000..51f2878d52ff --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-custom-provider-form.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from "bun:test" +import { validateCustomProvider } from "./dialog-custom-provider-form" + +describe("validateCustomProvider", () => { + test("builds an OpenAI-compatible provider config", () => { + const result = validateCustomProvider({ + form: { + providerID: "custom-provider", + name: " Custom Provider ", + baseURL: "https://api.example.com/v1 ", + apiKey: " {env: CUSTOM_PROVIDER_KEY} ", + models: [{ id: " model-a ", name: " Model A " }], + headers: [ + { key: " X-Test ", value: " enabled " }, + { key: "", value: "" }, + ], + }, + disabledProviders: [], + existingProviderIDs: new Set(), + }) + + expect(result).toEqual({ + ok: true, + providerID: "custom-provider", + name: "Custom Provider", + key: undefined, + config: { + npm: "@ai-sdk/openai-compatible", + name: "Custom Provider", + env: ["CUSTOM_PROVIDER_KEY"], + options: { + baseURL: "https://api.example.com/v1", + headers: { + "X-Test": "enabled", + }, + }, + models: { + "model-a": { name: "Model A" }, + }, + }, + }) + }) + + test("rejects duplicate models and allows reconnecting disabled providers", () => { + const duplicate = validateCustomProvider({ + form: { + providerID: "custom-provider", + name: "Provider", + baseURL: "https://api.example.com", + apiKey: "secret", + models: [ + { id: "model-a", name: "Model A" }, + { id: "model-a", name: "Model A 2" }, + ], + headers: [], + }, + disabledProviders: ["custom-provider"], + existingProviderIDs: new Set(["custom-provider"]), + }) + + expect(duplicate).toEqual({ ok: false, error: "Duplicate model ID: model-a" }) + + const reconnected = validateCustomProvider({ + form: { + providerID: "custom-provider", + name: "Provider", + baseURL: "https://api.example.com", + apiKey: "secret", + models: [{ id: "model-a", name: "Model A" }], + headers: [], + }, + disabledProviders: ["custom-provider"], + existingProviderIDs: new Set(["custom-provider"]), + }) + + expect(reconnected.ok).toBe(true) + }) +}) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-custom-provider-form.ts b/packages/opencode/src/cli/cmd/tui/component/dialog-custom-provider-form.ts new file mode 100644 index 000000000000..715dd25e1c4d --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-custom-provider-form.ts @@ -0,0 +1,113 @@ +const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/ +const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible" + +export type CustomProviderModel = { + id: string + name: string +} + +export type CustomProviderHeader = { + key: string + value: string +} + +export type CustomProviderForm = { + providerID: string + name: string + baseURL: string + apiKey: string + models: CustomProviderModel[] + headers: CustomProviderHeader[] +} + +type ValidateArgs = { + form: CustomProviderForm + disabledProviders: string[] + existingProviderIDs: Set +} + +export type CustomProviderValidation = + | { + ok: true + providerID: string + name: string + key?: string + config: { + npm: typeof OPENAI_COMPATIBLE + name: string + env?: string[] + options: { + baseURL: string + headers?: Record + } + models: Record + } + } + | { + ok: false + error: string + } + +export function validateCustomProvider(input: ValidateArgs): CustomProviderValidation { + const providerID = input.form.providerID.trim() + const name = input.form.name.trim() + const baseURL = input.form.baseURL.trim() + const apiKey = input.form.apiKey.trim() + + if (!providerID) return { ok: false, error: "Provider ID is required" } + if (!PROVIDER_ID.test(providerID)) { + return { ok: false, error: "Provider ID can only contain lowercase letters, numbers, hyphens, and underscores" } + } + if (input.existingProviderIDs.has(providerID) && !input.disabledProviders.includes(providerID)) { + return { ok: false, error: "Provider ID already exists" } + } + if (!name) return { ok: false, error: "Display name is required" } + if (!baseURL) return { ok: false, error: "Base URL is required" } + if (!/^https?:\/\//.test(baseURL)) return { ok: false, error: "Base URL must start with http:// or https://" } + + const seenModels = new Set() + const models: Record = {} + for (const model of input.form.models) { + const id = model.id.trim() + const modelName = model.name.trim() + if (!id) return { ok: false, error: "Model ID is required" } + if (!modelName) return { ok: false, error: "Model name is required" } + if (seenModels.has(id)) return { ok: false, error: `Duplicate model ID: ${id}` } + seenModels.add(id) + models[id] = { name: modelName } + } + + const headers: Record = {} + const seenHeaders = new Set() + for (const header of input.form.headers) { + const key = header.key.trim() + const value = header.value.trim() + if (!key && !value) continue + if (!key) return { ok: false, error: "Header name is required" } + if (!value) return { ok: false, error: "Header value is required" } + const normalized = key.toLowerCase() + if (seenHeaders.has(normalized)) return { ok: false, error: `Duplicate header: ${key}` } + seenHeaders.add(normalized) + headers[key] = value + } + + const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim() + const key = apiKey && !env ? apiKey : undefined + + return { + ok: true, + providerID, + name, + key, + config: { + npm: OPENAI_COMPATIBLE, + name, + ...(env ? { env: [env] } : {}), + options: { + baseURL, + ...(Object.keys(headers).length ? { headers } : {}), + }, + models, + }, + } +} 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 ebc28847f0d8..5a53f17bd359 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -5,6 +5,7 @@ import { DialogSelect } from "@tui/ui/dialog-select" import { useDialog } from "@tui/ui/dialog" import { useSDK } from "../context/sdk" import { DialogPrompt } from "../ui/dialog-prompt" +import { DialogConfirm } from "../ui/dialog-confirm" import { Link } from "../ui/link" import { useTheme } from "../context/theme" import { TextAttributes } from "@opentui/core" @@ -15,6 +16,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 { type CustomProviderForm, validateCustomProvider } from "./dialog-custom-provider-form" const PROVIDER_PRIORITY: Record = { opencode: 0, @@ -33,114 +35,127 @@ export function createDialogProviderOptions() { const { theme } = useTheme() const onboarded = useConnected() const options = createMemo(() => { - return pipe( - sync.data.provider_next.all, - sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99), - map((provider) => { - const consoleManaged = isConsoleManagedProvider(sync.data.console_state.consoleManagedProviders, provider.id) - const connected = sync.data.provider_next.connected.includes(provider.id) - - return { - title: provider.name, - value: provider.id, - description: { - opencode: "(Recommended)", - anthropic: "(API key)", - openai: "(ChatGPT Plus/Pro or API key)", - "opencode-go": "Low cost subscription for everyone", - }[provider.id], - footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined, - category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other", - gutter: connected && onboarded() ? : undefined, - async onSelect() { - if (consoleManaged) return - - const methods = sync.data.provider_auth[provider.id] ?? [ - { - type: "api", - label: "API key", - }, - ] - let index: number | null = 0 - if (methods.length > 1) { - index = await new Promise((resolve) => { - dialog.replace( - () => ( - ({ - title: x.label, - value: index, - }))} - onSelect={(option) => resolve(option.value)} - /> - ), - () => resolve(null), - ) - }) - } - if (index == null) return - const method = methods[index] - if (method.type === "oauth") { - let inputs: Record | undefined - if (method.prompts?.length) { - const value = await PromptsMethod({ - dialog, - prompts: method.prompts, + const custom = { + title: "Custom provider", + value: "__custom_provider__", + description: "OpenAI-compatible endpoint", + category: "Popular", + async onSelect() { + await CustomProviderMethod({ dialog, sdk, sync, toast }) + }, + } + + return [ + custom, + ...pipe( + sync.data.provider_next.all, + sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99), + map((provider) => { + const consoleManaged = isConsoleManagedProvider(sync.data.console_state.consoleManagedProviders, provider.id) + const connected = sync.data.provider_next.connected.includes(provider.id) + + return { + title: provider.name, + value: provider.id, + description: { + opencode: "(Recommended)", + anthropic: "(API key)", + openai: "(ChatGPT Plus/Pro or API key)", + "opencode-go": "Low cost subscription for everyone", + }[provider.id], + footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined, + category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other", + gutter: connected && onboarded() ? : undefined, + async onSelect() { + if (consoleManaged) return + + const methods = sync.data.provider_auth[provider.id] ?? [ + { + type: "api", + label: "API key", + }, + ] + let index: number | null = 0 + if (methods.length > 1) { + index = await new Promise((resolve) => { + dialog.replace( + () => ( + ({ + title: x.label, + value: index, + }))} + onSelect={(option) => resolve(option.value)} + /> + ), + () => resolve(null), + ) }) - if (!value) return - inputs = value } + if (index == null) return + const method = methods[index] + if (method.type === "oauth") { + let inputs: Record | undefined + if (method.prompts?.length) { + const value = await PromptsMethod({ + dialog, + prompts: method.prompts, + }) + if (!value) return + inputs = value + } - const result = await sdk.client.provider.oauth.authorize({ - providerID: provider.id, - method: index, - inputs, - }) - if (result.error) { - toast.show({ - variant: "error", - message: JSON.stringify(result.error), + const result = await sdk.client.provider.oauth.authorize({ + providerID: provider.id, + method: index, + inputs, }) - dialog.clear() - return - } - if (result.data?.method === "code") { - dialog.replace(() => ( - - )) + if (result.error) { + toast.show({ + variant: "error", + message: JSON.stringify(result.error), + }) + dialog.clear() + return + } + if (result.data?.method === "code") { + dialog.replace(() => ( + + )) + } + if (result.data?.method === "auto") { + dialog.replace(() => ( + + )) + } } - if (result.data?.method === "auto") { - dialog.replace(() => ( - + if (method.type === "api") { + let metadata: Record | undefined + if (method.prompts?.length) { + const value = await PromptsMethod({ dialog, prompts: method.prompts }) + if (!value) return + metadata = value + } + return dialog.replace(() => ( + )) } - } - if (method.type === "api") { - let metadata: Record | undefined - if (method.prompts?.length) { - const value = await PromptsMethod({ dialog, prompts: method.prompts }) - if (!value) return - metadata = value - } - return dialog.replace(() => ( - - )) - } - }, - } - }), - ) + }, + } + }), + ), + ] }) return options } @@ -311,6 +326,211 @@ function ApiMethod(props: ApiMethodProps) { ) } +async function CustomProviderMethod(input: { + dialog: ReturnType + sdk: ReturnType + sync: ReturnType + toast: ReturnType +}) { + const prompt = (args: { + title: string + placeholder?: string + value?: string + validate?: (value: string) => string | undefined + }) => promptCustomProviderValue({ ...args, dialog: input.dialog, toast: input.toast }) + + const providerID = await prompt({ + title: "Provider ID", + placeholder: "myprovider", + validate(value) { + if (!value.trim()) return "Provider ID is required" + if (!/^[a-z0-9][a-z0-9-_]*$/.test(value.trim())) { + return "Use lowercase letters, numbers, hyphens, and underscores" + } + const disabled = input.sync.data.config.disabled_providers ?? [] + if ( + input.sync.data.provider_next.all.some((provider) => provider.id === value.trim()) && + !disabled.includes(value.trim()) + ) { + return "Provider ID already exists" + } + return undefined + }, + }) + if (providerID === null) return + + const name = await prompt({ + title: "Display name", + placeholder: "My AI Provider", + value: providerID, + validate: (value) => (value.trim() ? undefined : "Display name is required"), + }) + if (name === null) return + + const baseURL = await prompt({ + title: "Base URL", + placeholder: "https://api.myprovider.com/v1", + validate(value) { + if (!value.trim()) return "Base URL is required" + if (!/^https?:\/\//.test(value.trim())) return "Base URL must start with http:// or https://" + return undefined + }, + }) + if (baseURL === null) return + + const apiKey = await prompt({ + title: "API key", + placeholder: "Leave empty to skip, or use {env: PROVIDER_API_KEY}", + }) + if (apiKey === null) return + + const models: CustomProviderForm["models"] = [] + while (true) { + const id = await prompt({ + title: models.length === 0 ? "Model ID" : "Additional model ID", + placeholder: "model-id", + validate(value) { + const id = value.trim() + if (!id) return "Model ID is required" + if (models.some((model) => model.id.trim() === id)) return "Model ID already exists" + return undefined + }, + }) + if (id === null) return + + const modelName = await prompt({ + title: "Model name", + placeholder: "Display name", + value: id, + validate: (value) => (value.trim() ? undefined : "Model name is required"), + }) + if (modelName === null) return + models.push({ id, name: modelName }) + + const more = await DialogConfirm.show( + input.dialog, + "Add another model?", + "Configure another model for this custom provider.", + "done", + ) + if (more === undefined) return + if (!more) break + } + + const headers: CustomProviderForm["headers"] = [] + let addHeader = await DialogConfirm.show( + input.dialog, + "Add custom header?", + "Add optional headers to every request for this provider.", + "skip", + ) + if (addHeader === undefined) return + while (addHeader) { + const key = await prompt({ + title: "Header name", + placeholder: "Header-Name", + validate(value) { + const key = value.trim() + if (!key) return "Header name is required" + if (headers.some((header) => header.key.trim().toLowerCase() === key.toLowerCase())) { + return "Header already exists" + } + return undefined + }, + }) + if (key === null) return + + const value = await prompt({ + title: "Header value", + placeholder: "value", + validate: (value) => (value.trim() ? undefined : "Header value is required"), + }) + if (value === null) return + headers.push({ key, value }) + + addHeader = await DialogConfirm.show( + input.dialog, + "Add another header?", + "Configure another custom header for this provider.", + "done", + ) + if (addHeader === undefined) return + } + + const result = validateCustomProvider({ + form: { + providerID, + name, + baseURL, + apiKey, + models, + headers, + }, + disabledProviders: input.sync.data.config.disabled_providers ?? [], + existingProviderIDs: new Set(input.sync.data.provider_next.all.map((provider) => provider.id)), + }) + + if (!result.ok) { + input.toast.show({ variant: "error", message: result.error }) + return + } + + input.dialog.replace(() => ) + + try { + if (result.key) { + await input.sdk.client.auth.set({ + providerID: result.providerID, + auth: { + type: "api", + key: result.key, + }, + }) + } + + const disabledProviders = input.sync.data.config.disabled_providers ?? [] + await input.sdk.client.global.config.update({ + config: { + provider: { [result.providerID]: result.config }, + disabled_providers: disabledProviders.filter((id) => id !== result.providerID), + }, + }) + await input.sync.bootstrap() + input.toast.show({ variant: "success", message: `${result.name} connected` }) + input.dialog.replace(() => ) + } catch (err) { + input.dialog.clear() + input.toast.show({ + variant: "error", + message: err instanceof Error ? err.message : String(err), + }) + } +} + +async function promptCustomProviderValue(input: { + dialog: ReturnType + toast: ReturnType + title: string + placeholder?: string + value?: string + validate?: (value: string) => string | undefined +}) { + let current = input.value + while (true) { + const value = await DialogPrompt.show(input.dialog, input.title, { + placeholder: input.placeholder, + value: current, + }) + if (value === null) return null + + const error = input.validate?.(value) + if (!error) return value + + current = value + input.toast.show({ variant: "error", message: error }) + } +} + interface PromptsMethodProps { dialog: ReturnType prompts: NonNullable[number][] From 1f78905422e49f371c6e29fec99cab79812173a9 Mon Sep 17 00:00:00 2001 From: CodeX Assistant <854605104@qq.com> Date: Fri, 1 May 2026 21:38:43 +0800 Subject: [PATCH 2/2] Fix TUI provider gutter conflict --- .../cli/cmd/tui/component/dialog-provider.tsx | 209 +++++++++--------- .../src/cli/cmd/tui/ui/dialog-select.tsx | 6 +- 2 files changed, 107 insertions(+), 108 deletions(-) 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 5a53f17bd359..98fe139a18ee 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -45,117 +45,116 @@ export function createDialogProviderOptions() { }, } - return [ - custom, - ...pipe( - sync.data.provider_next.all, - sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99), - map((provider) => { - const consoleManaged = isConsoleManagedProvider(sync.data.console_state.consoleManagedProviders, provider.id) - const connected = sync.data.provider_next.connected.includes(provider.id) - - return { - title: provider.name, - value: provider.id, - description: { - opencode: "(Recommended)", - anthropic: "(API key)", - openai: "(ChatGPT Plus/Pro or API key)", - "opencode-go": "Low cost subscription for everyone", - }[provider.id], - footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined, - category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other", - gutter: connected && onboarded() ? : undefined, - async onSelect() { - if (consoleManaged) return - - const methods = sync.data.provider_auth[provider.id] ?? [ - { - type: "api", - label: "API key", - }, - ] - let index: number | null = 0 - if (methods.length > 1) { - index = await new Promise((resolve) => { - dialog.replace( - () => ( - ({ - title: x.label, - value: index, - }))} - onSelect={(option) => resolve(option.value)} - /> - ), - () => resolve(null), - ) + const providerOptions = pipe( + sync.data.provider_next.all, + sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99), + map((provider) => { + const consoleManaged = isConsoleManagedProvider(sync.data.console_state.consoleManagedProviders, provider.id) + const connected = sync.data.provider_next.connected.includes(provider.id) + + return { + title: provider.name, + value: provider.id, + description: { + opencode: "(Recommended)", + anthropic: "(API key)", + openai: "(ChatGPT Plus/Pro or API key)", + "opencode-go": "Low cost subscription for everyone", + }[provider.id], + footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined, + category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other", + gutter: connected && onboarded() ? () => : undefined, + async onSelect() { + if (consoleManaged) return + + const methods = sync.data.provider_auth[provider.id] ?? [ + { + type: "api", + label: "API key", + }, + ] + let index: number | null = 0 + if (methods.length > 1) { + index = await new Promise((resolve) => { + dialog.replace( + () => ( + ({ + title: x.label, + value: index, + }))} + onSelect={(option) => resolve(option.value)} + /> + ), + () => resolve(null), + ) + }) + } + if (index == null) return + const method = methods[index] + if (method.type === "oauth") { + let inputs: Record | undefined + if (method.prompts?.length) { + const value = await PromptsMethod({ + dialog, + prompts: method.prompts, }) + if (!value) return + inputs = value } - if (index == null) return - const method = methods[index] - if (method.type === "oauth") { - let inputs: Record | undefined - if (method.prompts?.length) { - const value = await PromptsMethod({ - dialog, - prompts: method.prompts, - }) - if (!value) return - inputs = value - } - - const result = await sdk.client.provider.oauth.authorize({ - providerID: provider.id, - method: index, - inputs, + + const result = await sdk.client.provider.oauth.authorize({ + providerID: provider.id, + method: index, + inputs, + }) + if (result.error) { + toast.show({ + variant: "error", + message: JSON.stringify(result.error), }) - if (result.error) { - toast.show({ - variant: "error", - message: JSON.stringify(result.error), - }) - dialog.clear() - return - } - if (result.data?.method === "code") { - dialog.replace(() => ( - - )) - } - if (result.data?.method === "auto") { - dialog.replace(() => ( - - )) - } + dialog.clear() + return + } + if (result.data?.method === "code") { + dialog.replace(() => ( + + )) } - if (method.type === "api") { - let metadata: Record | undefined - if (method.prompts?.length) { - const value = await PromptsMethod({ dialog, prompts: method.prompts }) - if (!value) return - metadata = value - } - return dialog.replace(() => ( - + if (result.data?.method === "auto") { + dialog.replace(() => ( + )) } - }, - } - }), - ), - ] + } + if (method.type === "api") { + let metadata: Record | undefined + if (method.prompts?.length) { + const value = await PromptsMethod({ dialog, prompts: method.prompts }) + if (!value) return + metadata = value + } + return dialog.replace(() => ( + + )) + } + }, + } + }), + ) + + return [custom, ...providerOptions] }) return options } diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index b6c937f4115c..4d68c4430891 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -42,7 +42,7 @@ export interface DialogSelectOption { categoryView?: JSX.Element disabled?: boolean bg?: RGBA - gutter?: JSX.Element + gutter?: () => JSX.Element margin?: JSX.Element onSelect?: (ctx: DialogContext) => void } @@ -407,7 +407,7 @@ function Option(props: { active?: boolean current?: boolean footer?: JSX.Element | string - gutter?: JSX.Element + gutter?: () => JSX.Element onMouseOver?: () => void }) { const { theme } = useTheme() @@ -422,7 +422,7 @@ function Option(props: { - {props.gutter} + {props.gutter?.()}