|
@@ -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,
|
|
|
|
|
- sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99),
|
|
|
|
|
|
|
+ providerOptions(sync.data.provider_next.all),
|
|
|
map((provider) => {
|
|
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(() => <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,
|
|
|
|
|
- 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,
|
|
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} />)
|
|
|
}}
|
|
}}
|
|
|
/>
|
|
/>
|