diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index c30b8d12a93..f20d07d223a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -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, 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 f77e4727aaa..bfc670b4c49 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -27,6 +27,9 @@ 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, @@ -41,6 +44,7 @@ export function createDialogProviderOptions() { "opencode-go": "Low cost subscription for everyone", }[provider.id], category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other", + footer: connected().has(provider.id) ? "Connected" : undefined, async onSelect() { const methods = sync.data.provider_auth[provider.id] ?? [ { @@ -69,10 +73,58 @@ export function createDialogProviderOptions() { if (index == null) return const method = methods[index] if (method.type === "oauth") { + const inputs: Record = {} + + 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 selected = await new Promise((resolve) => { + dialog.replace( + () => ( + ({ + title: opt.label, + value: opt.value, + description: opt.hint, + }))} + onSelect={(option) => resolve(option.value)} + /> + ), + () => resolve(null), + ) + }) + if (selected === null) return + inputs[prompt.key] = selected + continue + } + + const text = await DialogPrompt.show(dialog, prompt.message, { + placeholder: prompt.placeholder ?? "Enter value", + }) + if (text === null) return + inputs[prompt.key] = text + } + 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(() => ( diff --git a/packages/opencode/src/provider/auth-service.ts b/packages/opencode/src/provider/auth-service.ts index 2e998593984..b392b8dc36a 100644 --- a/packages/opencode/src/provider/auth-service.ts +++ b/packages/opencode/src/provider/auth-service.ts @@ -2,14 +2,40 @@ import type { AuthOuathResult } from "@opencode-ai/plugin" import { NamedError } from "@opencode-ai/util/error" import * as Auth from "@/auth/service" import { ProviderID } from "./schema" -import { Effect, Layer, Record, ServiceMap, Struct } from "effect" +import { Effect, Layer, Record, ServiceMap } from "effect" import { filter, fromEntries, map, pipe } from "remeda" import z from "zod" +export const MethodPromptOption = z + .object({ + label: z.string(), + value: z.string(), + hint: z.string().optional(), + }) + .meta({ + ref: "ProviderAuthMethodPromptOption", + }) +export type MethodPromptOption = z.infer + +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(), + }) + .meta({ + ref: "ProviderAuthMethodPrompt", + }) +export type MethodPrompt = z.infer + export const Method = z .object({ type: z.union([z.literal("oauth"), z.literal("api")]), label: z.string(), + prompts: MethodPrompt.array().optional(), }) .meta({ ref: "ProviderAuthMethod", @@ -49,10 +75,30 @@ export type ProviderAuthError = | InstanceType | InstanceType +// Converts plugin condition functions to serializable "key:value" strings. +// Only handles simple `inputs.key === "value"` patterns. Complex conditions +// or minified/transpiled output may not match — plugins should prefer +// providing pre-serialized condition strings when possible. +export 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 namespace ProviderAuthService { export interface Service { readonly methods: () => Effect.Effect> - readonly authorize: (input: { providerID: ProviderID; method: number }) => Effect.Effect + readonly authorize: (input: { + providerID: ProviderID + method: number + inputs?: Record + }) => Effect.Effect readonly callback: (input: { providerID: ProviderID method: number @@ -80,16 +126,41 @@ export class ProviderAuthService extends ServiceMap.Service() const methods = Effect.fn("ProviderAuthService.methods")(function* () { - return Record.map(hooks, (item) => item.methods.map((method): Method => Struct.pick(method, ["type", "label"]))) + return Record.map(hooks, (item) => + item.methods.map( + (method): Method => ({ + type: method.type, + label: method.label, + prompts: method.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), + }), + ), + }), + ), + ) }) const authorize = Effect.fn("ProviderAuthService.authorize")(function* (input: { providerID: ProviderID method: number + inputs?: Record }) { const method = hooks[input.providerID].methods[input.method] if (method.type !== "oauth") return - const result = yield* Effect.promise(() => method.authorize()) + const result = yield* Effect.promise(() => method.authorize(input.inputs ?? {})) pending.set(input.providerID, result) return { url: result.url, @@ -121,13 +192,8 @@ export class ProviderAuthService extends ServiceMap.Service => runPromiseInstance(S.ProviderAuthService.use((service) => service.authorize(input))), diff --git a/packages/opencode/src/server/routes/provider.ts b/packages/opencode/src/server/routes/provider.ts index fc716d25cb0..400f0d537db 100644 --- a/packages/opencode/src/server/routes/provider.ts +++ b/packages/opencode/src/server/routes/provider.ts @@ -109,14 +109,18 @@ export const ProviderRoutes = lazy(() => "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 json = c.req.valid("json") const result = await ProviderAuth.authorize({ - providerID, - method, + providerID: c.req.valid("param").providerID, + method: json.method, + inputs: json.inputs, }) return c.json(result) }, diff --git a/packages/opencode/test/provider/auth-service.test.ts b/packages/opencode/test/provider/auth-service.test.ts new file mode 100644 index 00000000000..51444ee4adf --- /dev/null +++ b/packages/opencode/test/provider/auth-service.test.ts @@ -0,0 +1,116 @@ +import { test, expect, describe } from "bun:test" +import { serializeCondition, Method, MethodPrompt, MethodPromptOption } from "../../src/provider/auth-service" + +describe("serializeCondition", () => { + test("serializes arrow function with strict equality", () => { + const fn = (inputs: Record) => inputs.type === "enterprise" + expect(serializeCondition(fn)).toBe("type:enterprise") + }) + + test("serializes arrow function with loose equality", () => { + const fn = (inputs: Record) => inputs.mode == "cloud" + expect(serializeCondition(fn)).toBe("mode:cloud") + }) + + test("passes through already-serialized string", () => { + expect(serializeCondition("type:enterprise")).toBe("type:enterprise") + }) + + test("returns undefined for non-function non-string", () => { + expect(serializeCondition(undefined)).toBeUndefined() + expect(serializeCondition(null)).toBeUndefined() + expect(serializeCondition(42)).toBeUndefined() + expect(serializeCondition({})).toBeUndefined() + }) + + test("returns undefined for unrecognized function pattern", () => { + const fn = () => true + expect(serializeCondition(fn)).toBeUndefined() + }) + + test("handles single-quoted values", () => { + const fn = (inputs: Record) => inputs.org === "myorg" + expect(serializeCondition(fn)).toBe("org:myorg") + }) + + test("handles backtick-quoted values", () => { + const fn = (inputs: Record) => inputs.env === `prod` + expect(serializeCondition(fn)).toBe("env:prod") + }) +}) + +describe("MethodPromptOption schema", () => { + test("parses valid option", () => { + const result = MethodPromptOption.parse({ label: "Org A", value: "org-a" }) + expect(result.label).toBe("Org A") + expect(result.value).toBe("org-a") + }) + + test("parses option with hint", () => { + const result = MethodPromptOption.parse({ label: "Org A", value: "org-a", hint: "Primary org" }) + expect(result.hint).toBe("Primary org") + }) + + test("rejects missing required fields", () => { + expect(() => MethodPromptOption.parse({ label: "Org A" })).toThrow() + expect(() => MethodPromptOption.parse({ value: "org-a" })).toThrow() + }) +}) + +describe("MethodPrompt schema", () => { + test("parses select prompt", () => { + const result = MethodPrompt.parse({ + type: "select", + key: "org", + message: "Select organization", + options: [{ label: "Org A", value: "org-a" }], + }) + expect(result.type).toBe("select") + expect(result.options).toHaveLength(1) + }) + + test("parses text prompt", () => { + const result = MethodPrompt.parse({ + type: "text", + key: "domain", + message: "Enter domain", + placeholder: "github.example.com", + }) + expect(result.type).toBe("text") + expect(result.placeholder).toBe("github.example.com") + }) + + test("parses prompt with conditional", () => { + const result = MethodPrompt.parse({ + type: "text", + key: "url", + message: "Enter URL", + conditional: "type:enterprise", + }) + expect(result.conditional).toBe("type:enterprise") + }) + + test("rejects invalid type", () => { + expect(() => MethodPrompt.parse({ type: "radio", key: "x", message: "y" })).toThrow() + }) +}) + +describe("Method schema", () => { + test("parses oauth method with prompts", () => { + const result = Method.parse({ + type: "oauth", + label: "GitHub Copilot", + prompts: [ + { type: "select", key: "type", message: "Select type" }, + { type: "text", key: "domain", message: "Enter domain", conditional: "type:enterprise" }, + ], + }) + expect(result.type).toBe("oauth") + expect(result.prompts).toHaveLength(2) + }) + + test("parses method without prompts (backward compatible)", () => { + const result = Method.parse({ type: "api", label: "API key" }) + expect(result.prompts).toBeUndefined() + }) +}) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 27c188838b9..aa759bb1e09 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -2496,6 +2496,9 @@ export class Oauth extends HeyApiClient { directory?: string workspace?: string method?: number + inputs?: { + [key: string]: string + } }, options?: Options, ) { @@ -2508,6 +2511,7 @@ export class Oauth extends HeyApiClient { { in: "query", key: "directory" }, { in: "query", key: "workspace" }, { in: "body", key: "method" }, + { in: "body", key: "inputs" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index fd80a51a214..68d740c6ee0 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1766,9 +1766,25 @@ export type SubtaskPartInput = { command?: string } +export type ProviderAuthMethodPromptOption = { + label: string + value: string + hint?: string +} + +export type ProviderAuthMethodPrompt = { + type: "select" | "text" + key: string + message: string + placeholder?: string + options?: Array + conditional?: string +} + export type ProviderAuthMethod = { type: "oauth" | "api" label: string + prompts?: Array } export type ProviderAuthAuthorization = { @@ -3983,6 +3999,12 @@ export type ProviderOauthAuthorizeData = { * Auth method index */ method: number + /** + * Prompt inputs collected from the user + */ + inputs?: { + [key: string]: string + } } path: { /** diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 2f7e9952ede..19780e672f9 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -4761,6 +4761,16 @@ "method": { "description": "Auth method index", "type": "number" + }, + "inputs": { + "description": "Prompt inputs collected from the user", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } } }, "required": ["method"] @@ -11524,6 +11534,57 @@ }, "required": ["type", "prompt", "description", "agent"] }, + "ProviderAuthMethodPromptOption": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + }, + "hint": { + "type": "string" + } + }, + "required": ["label", "value"] + }, + "ProviderAuthMethodPrompt": { + "type": "object", + "properties": { + "type": { + "anyOf": [ + { + "type": "string", + "const": "select" + }, + { + "type": "string", + "const": "text" + } + ] + }, + "key": { + "type": "string" + }, + "message": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProviderAuthMethodPromptOption" + } + }, + "conditional": { + "type": "string" + } + }, + "required": ["type", "key", "message"] + }, "ProviderAuthMethod": { "type": "object", "properties": { @@ -11541,6 +11602,12 @@ }, "label": { "type": "string" + }, + "prompts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProviderAuthMethodPrompt" + } } }, "required": ["type", "label"] diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index 7f993205193..a67e64e09a3 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -840,7 +840,17 @@ subscription](https://github.com/features/copilot/plans) to use. /connect ``` -2. Navigate to [github.com/login/device](https://github.com/login/device) and enter the code. +2. Select your GitHub deployment type. + + ```txt + ┌ Select GitHub deployment type + │ + │ GitHub.com Public + │ GitHub Enterprise + └ + ``` + +3. Navigate to [github.com/login/device](https://github.com/login/device) and enter the code. ```txt ┌ Login with GitHub Copilot @@ -852,12 +862,44 @@ subscription](https://github.com/features/copilot/plans) to use. └ Waiting for authorization... ``` -3. Now run the `/models` command to select the model you want. +4. Now run the `/models` command to select the model you want. ```txt /models ``` +##### GitHub Enterprise + +For organizations using GitHub Enterprise (data residency or self-hosted): + +1. Run `/connect` and select **GitHub Copilot**. + +2. Select **GitHub Enterprise** as the deployment type. + +3. Enter your GitHub Enterprise URL or domain. + + ```txt + ┌ Enter your GitHub Enterprise URL or domain + │ + │ company.ghe.com or https://company.ghe.com + │ + └ enter submit + ``` + +4. Navigate to your Enterprise device login page and enter the code. + + ```txt + ┌ Login with GitHub Copilot + │ + │ https://company.ghe.com/login/device + │ + │ Enter code: C30A-4C07 + │ + └ Waiting for authorization... + ``` + +5. Run `/models` to select the model you want. + --- ### Google Vertex AI