Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export function DialogModel(props: { providerID?: string }) {
(provider) => provider.id !== "opencode",
(provider) => provider.name,
),
filter((provider) => (props.providerID ? provider.id === props.providerID : true)),
flatMap((provider) =>
pipe(
provider.models,
Expand Down
185 changes: 127 additions & 58 deletions packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { DialogPrompt } from "../ui/dialog-prompt"
import { Link } from "../ui/link"
import { useTheme } from "../context/theme"
import { TextAttributes } from "@opentui/core"
import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2"
import type { ProviderAuthAuthorization, ProviderAuthMethodPrompt } from "@opencode-ai/sdk/v2"
import { DialogModel } from "./dialog-model"
import { useKeyboard } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard"
Expand All @@ -26,67 +26,134 @@ export function createDialogProviderOptions() {
const sync = useSync()
const dialog = useDialog()
const sdk = useSDK()
const toast = useToast()
const connected = createMemo(() => new Set(sync.data.provider_next.connected))

const options = createMemo(() => {
return pipe(
sync.data.provider_next.all,
sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99),
map((provider) => ({
title: provider.name,
value: provider.id,
description: {
opencode: "(Recommended)",
anthropic: "(Claude Max or API key)",
openai: "(ChatGPT Plus/Pro or API key)",
}[provider.id],
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
async onSelect() {
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<number | null>((resolve) => {
dialog.replace(
() => (
<DialogSelect
title="Select auth method"
options={methods.map((x, index) => ({
title: x.label,
value: index,
}))}
onSelect={(option) => resolve(option.value)}
map((provider) => {
const isConnected = connected().has(provider.id)
return {
title: provider.name,
value: provider.id,
description: {
opencode: "(Recommended)",
anthropic: "(Claude Max or API key)",
openai: "(ChatGPT Plus/Pro or API key)",
}[provider.id],
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
footer: isConnected ? "Connected" : undefined,
async onSelect() {
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<number | null>((resolve) => {
dialog.replace(
() => (
<DialogSelect
title="Select auth method"
options={methods.map((x, index) => ({
title: x.label,
value: index,
}))}
onSelect={(option) => resolve(option.value)}
/>
),
() => resolve(null),
)
})
}
if (index == null) return
const method = methods[index]
if (method.type === "oauth") {
const inputs: Record<string, string> = {}

for (const prompt of method.prompts ?? []) {
if (prompt.conditional) {
// Format: "key:value" - checks if inputs[key] === value
const [key, value] = prompt.conditional.split(":")
if (!key || !value || inputs[key] !== value) continue
}

if (prompt.type === "select") {
if (!prompt.options?.length) continue

const selectedValue = await new Promise<string | null>((resolve) => {
dialog.replace(
() => (
<DialogSelect
title={prompt.message}
options={prompt.options!.map((opt) => ({
title: opt.label,
value: opt.value,
description: opt.hint,
}))}
onSelect={(option) => resolve(option.value)}
/>
),
() => resolve(null),
)
})
if (selectedValue === null) return
inputs[prompt.key] = selectedValue
continue
}

const textValue = await DialogPrompt.show(dialog, prompt.message, {
placeholder: prompt.placeholder ?? "Enter value",
})
if (textValue === null) return
inputs[prompt.key] = textValue
}

const result = await sdk.client.provider.oauth.authorize({
providerID: provider.id,
method: index,
inputs,
})
if (result.error) {
const errorMessage =
(result.error as { error?: string })?.error ?? "Connection failed. Check the URL or domain."
toast.show({
variant: "error",
message: errorMessage,
})
return
}
if (result.data?.method === "code") {
dialog.replace(() => (
<CodeMethod
providerID={provider.id}
title={method.label}
index={index}
authorization={result.data!}
/>
))
}
if (result.data?.method === "auto") {
dialog.replace(() => (
<AutoMethod
providerID={provider.id}
title={method.label}
index={index}
authorization={result.data!}
/>
),
() => resolve(null),
)
})
}
if (index == null) return
const method = methods[index]
if (method.type === "oauth") {
const result = await sdk.client.provider.oauth.authorize({
providerID: provider.id,
method: index,
})
if (result.data?.method === "code") {
dialog.replace(() => (
<CodeMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} />
))
))
}
}
if (result.data?.method === "auto") {
dialog.replace(() => (
<AutoMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} />
))
if (method.type === "api") {
return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />)
}
}
if (method.type === "api") {
return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />)
}
},
})),
},
}
}),
)
})
return options
Expand Down Expand Up @@ -130,7 +197,8 @@ function AutoMethod(props: AutoMethodProps) {
}
await sdk.client.instance.dispose()
await sync.bootstrap()
dialog.replace(() => <DialogModel providerID={props.providerID} />)
const actualProvider = result.data?.provider ?? props.providerID
dialog.replace(() => <DialogModel providerID={actualProvider} />)
})

return (
Expand Down Expand Up @@ -173,15 +241,16 @@ function CodeMethod(props: CodeMethodProps) {
title={props.title}
placeholder="Authorization code"
onConfirm={async (value) => {
const { error } = await sdk.client.provider.oauth.callback({
const { error, data } = await sdk.client.provider.oauth.callback({
providerID: props.providerID,
method: props.index,
code: value,
})
if (!error) {
await sdk.client.instance.dispose()
await sync.bootstrap()
dialog.replace(() => <DialogModel providerID={props.providerID} />)
const actualProvider = data?.provider ?? props.providerID
dialog.replace(() => <DialogModel providerID={actualProvider} />)
return
}
setError(true)
Expand Down
91 changes: 77 additions & 14 deletions packages/opencode/src/provider/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,80 @@ export namespace ProviderAuth {
return { methods, pending: {} as Record<string, AuthOuathResult> }
})

export const MethodPromptOption = z
.object({
label: z.string(),
value: z.string(),
hint: z.string().optional(),
})
.meta({
ref: "ProviderAuthMethodPromptOption",
})
export type MethodPromptOption = z.infer<typeof MethodPromptOption>

export const MethodPrompt = z
.object({
type: z.union([z.literal("select"), z.literal("text")]),
key: z.string(),
message: z.string(),
placeholder: z.string().optional(),
options: MethodPromptOption.array().optional(),
conditional: z.string().optional(), // Serialized condition: "key:value"
})
.meta({
ref: "ProviderAuthMethodPrompt",
})
export type MethodPrompt = z.infer<typeof MethodPrompt>

export const Method = z
.object({
type: z.union([z.literal("oauth"), z.literal("api")]),
label: z.string(),
prompts: MethodPrompt.array().optional(),
})
.meta({
ref: "ProviderAuthMethod",
})
export type Method = z.infer<typeof Method>

function serializeCondition(condition: unknown): string | undefined {
if (typeof condition === "string") return condition
if (typeof condition !== "function") return undefined
const source = condition.toString()
const match = source.match(/inputs\.(\w+)\s*===?\s*["'`]([^"'`]+)["'`]/)

if (!match) {
console.warn(`[ProviderAuth] Failed to serialize condition: ${source.slice(0, 100)}`)
return undefined
}

return `${match[1]}:${match[2]}`
}

export async function methods() {
const s = await state().then((x) => x.methods)
return mapValues(s, (x) =>
x.methods.map(
(y): Method => ({
type: y.type,
label: y.label,
prompts: y.prompts?.map(
(p: {
type: string
key: string
message: string
placeholder?: string
options?: MethodPromptOption[]
condition?: unknown
}): MethodPrompt => ({
type: p.type as "select" | "text",
key: p.key,
message: p.message,
placeholder: p.placeholder,
options: p.options,
conditional: serializeCondition(p.condition),
}),
),
}),
),
)
Expand All @@ -55,12 +112,13 @@ export namespace ProviderAuth {
z.object({
providerID: z.string(),
method: z.number(),
inputs: z.record(z.string(), z.string()).optional(),
}),
async (input): Promise<Authorization | undefined> => {
const auth = await state().then((s) => s.methods[input.providerID])
const method = auth.methods[input.method]
if (method.type === "oauth") {
const result = await method.authorize()
const result = await method.authorize(input.inputs ?? {})
await state().then((s) => (s.pending[input.providerID] = result))
return {
url: result.url,
Expand Down Expand Up @@ -92,28 +150,28 @@ export namespace ProviderAuth {
}

if (result?.type === "success") {
const saveProvider = result.provider ?? input.providerID

if ("key" in result) {
await Auth.set(input.providerID, {
await Auth.set(saveProvider, {
type: "api",
key: result.key,
})
}
if ("refresh" in result) {
const info: Auth.Info = {
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
await Auth.set(saveProvider, {
type: "oauth",
access: result.access,
refresh: result.refresh,
expires: result.expires,
}
if (result.accountId) {
info.accountId = result.accountId
}
await Auth.set(input.providerID, info)
refresh,
access,
expires,
...extraFields,
})
}
return
return { provider: saveProvider }
}

throw new OauthCallbackFailed({})
throw new OauthCallbackFailed({ providerID: input.providerID })
},
)

Expand Down Expand Up @@ -143,5 +201,10 @@ export namespace ProviderAuth {
}),
)

export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
export const OauthCallbackFailed = NamedError.create(
"ProviderAuthOauthCallbackFailed",
z.object({
providerID: z.string(),
}),
)
}
Loading
Loading