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 @@ -116,12 +116,12 @@ 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,
entries(),
filter(([_, info]) => info.status !== "deprecated"),
filter(([_, info]) => (props.providerID ? info.providerID === props.providerID : true)),
map(([model, info]) => {
const value = {
providerID: provider.id,
Expand Down
59 changes: 55 additions & 4 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,6 +26,7 @@ export function createDialogProviderOptions() {
const sync = useSync()
const dialog = useDialog()
const sdk = useSDK()
const toast = useToast()
const options = createMemo(() => {
return pipe(
sync.data.provider_next.all,
Expand Down Expand Up @@ -67,10 +68,58 @@ export function createDialogProviderOptions() {
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) {
toast.show({
variant: "error",
message: "Connection failed. Check the URL or domain.",
})
return
}
if (result.data?.method === "code") {
dialog.replace(() => (
<CodeMethod providerID={provider.id} title={method.label} index={index} authorization={result.data!} />
Expand Down Expand Up @@ -130,7 +179,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 @@ -171,15 +221,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
77 changes: 71 additions & 6 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,8 +150,10 @@ 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,
})
Expand All @@ -108,12 +168,12 @@ export namespace ProviderAuth {
if (result.accountId) {
info.accountId = result.accountId
}
await Auth.set(input.providerID, info)
await Auth.set(saveProvider, info)
}
return
return { provider: saveProvider }
}

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

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

export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
export const OauthCallbackFailed = NamedError.create(
"ProviderAuthOauthCallbackFailed",
z.object({
providerID: z.string(),
}),
)
}
19 changes: 14 additions & 5 deletions packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1855,14 +1855,19 @@ export namespace Server {
"json",
z.object({
method: z.number().meta({ description: "Auth method index" }),
inputs: z
.record(z.string(), z.string())
.optional()
.meta({ description: "Prompt inputs collected from the user" }),
}),
),
async (c) => {
const providerID = c.req.valid("param").providerID
const { method } = c.req.valid("json")
const body = c.req.valid("json")
const result = await ProviderAuth.authorize({
providerID,
method,
method: body.method,
inputs: body.inputs,
})
return c.json(result)
},
Expand All @@ -1878,7 +1883,11 @@ export namespace Server {
description: "OAuth callback processed successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
schema: resolver(
z.object({
provider: z.string().optional(),
}),
),
},
},
},
Expand All @@ -1901,12 +1910,12 @@ export namespace Server {
async (c) => {
const providerID = c.req.valid("param").providerID
const { method, code } = c.req.valid("json")
await ProviderAuth.callback({
const result = await ProviderAuth.callback({
providerID,
method,
code,
})
return c.json(true)
return c.json(result ?? {})
},
)
.get(
Expand Down
4 changes: 4 additions & 0 deletions packages/sdk/js/src/v2/gen/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1907,6 +1907,9 @@ export class Oauth extends HeyApiClient {
providerID: string
directory?: string
method?: number
inputs?: {
[key: string]: string
}
},
options?: Options<never, ThrowOnError>,
) {
Expand All @@ -1918,6 +1921,7 @@ export class Oauth extends HeyApiClient {
{ in: "path", key: "providerID" },
{ in: "query", key: "directory" },
{ in: "body", key: "method" },
{ in: "body", key: "inputs" },
],
},
],
Expand Down
26 changes: 25 additions & 1 deletion packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1929,9 +1929,25 @@ export type Provider = {
}
}

export type ProviderAuthMethodPromptOption = {
label: string
value: string
hint?: string
}

export type ProviderAuthMethodPrompt = {
type: "select" | "text"
key: string
message: string
placeholder?: string
options?: Array<ProviderAuthMethodPromptOption>
conditional?: string
}

export type ProviderAuthMethod = {
type: "oauth" | "api"
label: string
prompts?: Array<ProviderAuthMethodPrompt>
}

export type ProviderAuthAuthorization = {
Expand Down Expand Up @@ -3856,6 +3872,12 @@ export type ProviderOauthAuthorizeData = {
* Auth method index
*/
method: number
/**
* Prompt inputs collected from the user
*/
inputs?: {
[key: string]: string
}
}
path: {
/**
Expand Down Expand Up @@ -3923,7 +3945,9 @@ export type ProviderOauthCallbackResponses = {
/**
* OAuth callback processed successfully
*/
200: boolean
200: {
provider?: string
}
}

export type ProviderOauthCallbackResponse = ProviderOauthCallbackResponses[keyof ProviderOauthCallbackResponses]
Expand Down
Loading