fix(tui): restore custom provider in /connect (#26168)
This commit is contained in:
@@ -25,6 +25,60 @@ const PROVIDER_PRIORITY: Record<string, number> = {
|
|||||||
google: 5,
|
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() {
|
export function createDialogProviderOptions() {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
@@ -32,30 +86,61 @@ export function createDialogProviderOptions() {
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const onboarded = useConnected()
|
const onboarded = useConnected()
|
||||||
|
|
||||||
|
async function promptCustomProviderID(): Promise<string | undefined> {
|
||||||
|
const value = await DialogPrompt.show(dialog, "Other", {
|
||||||
|
placeholder: "Provider id",
|
||||||
|
description: () => (
|
||||||
|
<text fg={theme.textMuted}>
|
||||||
|
This only stores a credential. Configure the provider in opencode.json to use it.
|
||||||
|
</text>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
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(() => {
|
const options = createMemo(() => {
|
||||||
return pipe(
|
return pipe(
|
||||||
sync.data.provider_next.all,
|
providerOptions(sync.data.provider_next.all),
|
||||||
sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99),
|
|
||||||
map((provider) => {
|
map((provider) => {
|
||||||
const consoleManaged = isConsoleManagedProvider(sync.data.console_state.consoleManagedProviders, provider.id)
|
if (provider.type === "custom") {
|
||||||
const connected = sync.data.provider_next.connected.includes(provider.id)
|
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(() => <ApiMethod providerID={providerID} title="API key" custom />)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerID = provider.providerID
|
||||||
|
const consoleManaged = isConsoleManagedProvider(sync.data.console_state.consoleManagedProviders, providerID)
|
||||||
|
const connected = sync.data.provider_next.connected.includes(providerID)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: provider.name,
|
title: provider.title,
|
||||||
value: provider.id,
|
value: provider.value,
|
||||||
description: {
|
description: provider.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,
|
footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined,
|
||||||
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
|
category: provider.category,
|
||||||
gutter: connected && onboarded() ? () => <text fg={theme.success}>✓</text> : undefined,
|
gutter: connected && onboarded() ? () => <text fg={theme.success}>✓</text> : undefined,
|
||||||
async onSelect() {
|
async onSelect() {
|
||||||
if (consoleManaged) return
|
if (consoleManaged) return
|
||||||
|
|
||||||
const methods = sync.data.provider_auth[provider.id] ?? [
|
const methods = sync.data.provider_auth[providerID] ?? [
|
||||||
{
|
{
|
||||||
type: "api",
|
type: "api",
|
||||||
label: "API key",
|
label: "API key",
|
||||||
@@ -93,7 +178,7 @@ export function createDialogProviderOptions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await sdk.client.provider.oauth.authorize({
|
const result = await sdk.client.provider.oauth.authorize({
|
||||||
providerID: provider.id,
|
providerID,
|
||||||
method: index,
|
method: index,
|
||||||
inputs,
|
inputs,
|
||||||
})
|
})
|
||||||
@@ -108,7 +193,7 @@ export function createDialogProviderOptions() {
|
|||||||
if (result.data?.method === "code") {
|
if (result.data?.method === "code") {
|
||||||
dialog.replace(() => (
|
dialog.replace(() => (
|
||||||
<CodeMethod
|
<CodeMethod
|
||||||
providerID={provider.id}
|
providerID={providerID}
|
||||||
title={method.label}
|
title={method.label}
|
||||||
index={index}
|
index={index}
|
||||||
authorization={result.data!}
|
authorization={result.data!}
|
||||||
@@ -118,7 +203,7 @@ export function createDialogProviderOptions() {
|
|||||||
if (result.data?.method === "auto") {
|
if (result.data?.method === "auto") {
|
||||||
dialog.replace(() => (
|
dialog.replace(() => (
|
||||||
<AutoMethod
|
<AutoMethod
|
||||||
providerID={provider.id}
|
providerID={providerID}
|
||||||
title={method.label}
|
title={method.label}
|
||||||
index={index}
|
index={index}
|
||||||
authorization={result.data!}
|
authorization={result.data!}
|
||||||
@@ -134,7 +219,7 @@ export function createDialogProviderOptions() {
|
|||||||
metadata = value
|
metadata = value
|
||||||
}
|
}
|
||||||
return dialog.replace(() => (
|
return dialog.replace(() => (
|
||||||
<ApiMethod providerID={provider.id} title={method.label} metadata={metadata} />
|
<ApiMethod providerID={providerID} title={method.label} metadata={metadata} />
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -256,11 +341,13 @@ interface ApiMethodProps {
|
|||||||
providerID: string
|
providerID: string
|
||||||
title: string
|
title: string
|
||||||
metadata?: Record<string, string>
|
metadata?: Record<string, string>
|
||||||
|
custom?: boolean
|
||||||
}
|
}
|
||||||
function ApiMethod(props: ApiMethodProps) {
|
function ApiMethod(props: ApiMethodProps) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const toast = useToast()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -305,6 +392,14 @@ function ApiMethod(props: ApiMethodProps) {
|
|||||||
})
|
})
|
||||||
await sdk.client.instance.dispose()
|
await sdk.client.instance.dispose()
|
||||||
await sync.bootstrap()
|
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(() => <DialogModel providerID={props.providerID} />)
|
dialog.replace(() => <DialogModel providerID={props.providerID} />)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user