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 d6cbda4133..16812fa8ab 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -25,6 +25,60 @@ const PROVIDER_PRIORITY: Record = { google: 5, } +const CUSTOM_PROVIDER_OPTION_VALUE = "__opencode_custom_provider__" +const CUSTOM_PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/ + +type ProviderOptionBase = { + title: string + value: string + description?: string + category: string +} + +type ProviderOption = + | (ProviderOptionBase & { + type: "provider" + providerID: string + }) + | (ProviderOptionBase & { + type: "custom" + }) + +export function providerOptions(list: { id: string; name: string }[]): ProviderOption[] { + return [ + ...pipe( + list, + sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99), + map((provider) => ({ + type: "provider" as const, + title: provider.name, + value: provider.id, + providerID: provider.id, + description: { + opencode: "(Recommended)", + anthropic: "(API key)", + openai: "(ChatGPT Plus/Pro or API key)", + "opencode-go": "Low cost subscription for everyone", + }[provider.id], + category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Providers", + })), + ), + { + type: "custom", + title: "Other", + value: CUSTOM_PROVIDER_OPTION_VALUE, + description: "Custom provider", + category: "Providers", + }, + ] +} + +export function normalizeCustomProviderID(value: string) { + const providerID = value.trim().replace(/^@ai-sdk\//, "") + if (!CUSTOM_PROVIDER_ID.test(providerID)) return + return providerID +} + export function createDialogProviderOptions() { const sync = useSync() const dialog = useDialog() @@ -32,30 +86,61 @@ export function createDialogProviderOptions() { const toast = useToast() const { theme } = useTheme() const onboarded = useConnected() + + async function promptCustomProviderID(): Promise { + const value = await DialogPrompt.show(dialog, "Other", { + placeholder: "Provider id", + description: () => ( + + This only stores a credential. Configure the provider in opencode.json to use it. + + ), + }) + if (value === null) return + + const providerID = normalizeCustomProviderID(value) + if (providerID) return providerID + + toast.show({ + variant: "error", + message: "Provider ids must start with a lowercase letter or number and only use lowercase letters, numbers, hyphens, and underscores", + }) + return promptCustomProviderID() + } + const options = createMemo(() => { return pipe( - sync.data.provider_next.all, - sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99), + providerOptions(sync.data.provider_next.all), map((provider) => { - const consoleManaged = isConsoleManagedProvider(sync.data.console_state.consoleManagedProviders, provider.id) - const connected = sync.data.provider_next.connected.includes(provider.id) + if (provider.type === "custom") { + return { + title: provider.title, + value: provider.value, + description: provider.description, + category: provider.category, + async onSelect() { + const providerID = await promptCustomProviderID() + if (!providerID) return + return dialog.replace(() => ) + }, + } + } + + const providerID = provider.providerID + const consoleManaged = isConsoleManagedProvider(sync.data.console_state.consoleManagedProviders, providerID) + const connected = sync.data.provider_next.connected.includes(providerID) 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], + title: provider.title, + value: provider.value, + description: provider.description, footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined, - category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other", + category: provider.category, gutter: connected && onboarded() ? () => : undefined, async onSelect() { if (consoleManaged) return - const methods = sync.data.provider_auth[provider.id] ?? [ + const methods = sync.data.provider_auth[providerID] ?? [ { type: "api", label: "API key", @@ -93,7 +178,7 @@ export function createDialogProviderOptions() { } const result = await sdk.client.provider.oauth.authorize({ - providerID: provider.id, + providerID, method: index, inputs, }) @@ -108,7 +193,7 @@ export function createDialogProviderOptions() { if (result.data?.method === "code") { dialog.replace(() => ( ( ( - + )) } }, @@ -256,11 +341,13 @@ interface ApiMethodProps { providerID: string title: string metadata?: Record + custom?: boolean } function ApiMethod(props: ApiMethodProps) { const dialog = useDialog() const sdk = useSDK() const sync = useSync() + const toast = useToast() const { theme } = useTheme() return ( @@ -305,6 +392,14 @@ function ApiMethod(props: ApiMethodProps) { }) await sdk.client.instance.dispose() await sync.bootstrap() + if (props.custom && !sync.data.provider_next.all.some((provider) => provider.id === props.providerID)) { + toast.show({ + variant: "info", + message: `Saved credential for ${props.providerID}. Configure it in opencode.json to use it.`, + }) + dialog.clear() + return + } dialog.replace(() => ) }} /> diff --git a/packages/opencode/test/cli/cmd/tui/provider-options.test.ts b/packages/opencode/test/cli/cmd/tui/provider-options.test.ts new file mode 100644 index 0000000000..39d6398379 --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/provider-options.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test" +import { normalizeCustomProviderID, providerOptions } from "../../../../src/cli/cmd/tui/component/dialog-provider" + +describe("providerOptions", () => { + test("includes a synthetic Other option for custom providers", () => { + expect(providerOptions([{ id: "openai", name: "OpenAI" }]).at(-1)).toMatchObject({ + title: "Other", + description: "Custom provider", + category: "Providers", + }) + }) + + test("does not use Other as the generic provider category", () => { + expect(providerOptions([{ id: "mistral", name: "Mistral" }])[0]?.category).toBe("Providers") + }) + + test("does not collide with a configured provider named other", () => { + const values = providerOptions([{ id: "other", name: "Other Provider" }]).map((option) => option.value) + expect(new Set(values).size).toBe(values.length) + }) + + test("normalizes and validates custom provider ids", () => { + expect(normalizeCustomProviderID(" custom-provider ")).toBe("custom-provider") + expect(normalizeCustomProviderID("custom_provider")).toBe("custom_provider") + expect(normalizeCustomProviderID("@ai-sdk/custom-provider")).toBe("custom-provider") + expect(normalizeCustomProviderID("-custom-provider")).toBeUndefined() + expect(normalizeCustomProviderID("Custom Provider")).toBeUndefined() + }) +})